From a8d841e6f875ecb7a825cbc106f6e62b50d558b6 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Thu, 30 Jul 2026 17:22:42 -0700 Subject: [PATCH 01/20] small changes for SQL compatibility (must effort is spread over 4 td PRs) --- pyproject.toml | 8 ++++++++ src/funtracks/actions/add_delete_node.py | 10 +++++++++- src/funtracks/data_model/tracks.py | 6 ++++++ src/funtracks/utils/tracksdata_utils.py | 24 ++++++++++++++++-------- tests/data_model/test_tracks.py | 10 +++++++--- 5 files changed, 46 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 804ddce3..b9fd7985 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,14 @@ dev = [ {include-group = "docs"}, ] +# TEMPORARY: pin tracksdata to a LOCAL editable checkout on the +# `combine-branches-for-funtracks-sql` branch, which merges the four SQL-backend fix +# branches (live-update views #325, numpy-scalar BLOB, pl.Array read truncation, edge-revive +# edge_id, and the missing-attr-key KeyError). Local-only branch, not pushed. Replace with a +# released version pin once those land in royerlab/tracksdata. See .claude/plans/sql.md. +[tool.uv.sources] +tracksdata = { path = "../tracksdata", editable = true } + [tool.setuptools_scm] [tool.ruff] 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/data_model/tracks.py b/src/funtracks/data_model/tracks.py index 22ccc1c8..3e3028f2 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -837,6 +837,12 @@ def add_feature(self, key: str, feature: Feature) -> None: # 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. + # + # TODO(sql): by the accessor policy (attribute I/O → graph_full), this should + # register on graph_full, not the view. Blocked on tracksdata bug #5 — on SQL, + # add_node_attr_key + update_node_attrs fails once any live view exists, for BOTH + # root- and view-registration (see .claude/plans/tracksdata_numpy_scalar_bug.md). + # Once fixed: register on graph_full and delete this workaround. See sql.md TODO. ft = feature["feature_type"] if "node" in ft and key not in self.graph_solution.node_attr_keys(): # "mask" value_type maps to pl.Object via to_polars_dtype diff --git a/src/funtracks/utils/tracksdata_utils.py b/src/funtracks/utils/tracksdata_utils.py index f6135e3c..8ece39a1 100644 --- a/src/funtracks/utils/tracksdata_utils.py +++ b/src/funtracks/utils/tracksdata_utils.py @@ -76,6 +76,7 @@ def create_empty_graph( database: str | None = None, position_attrs: list[str] | None = None, ndim: int = 3, + backend: str = "sql", ) -> td.graph.BaseGraph: """ Create an empty tracksdata base graph with standard node and edge attributes. @@ -100,6 +101,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 "sql". Returns ------- @@ -130,14 +135,17 @@ 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. + if backend == "sql": + graph_td = td.graph.SQLGraph( + drivername="sqlite", + database=database, + overwrite=True, + ) + elif backend == "memory": + graph_td = td.graph.IndexedRXGraph() + else: + raise ValueError(f"Unknown backend {backend!r}; expected 'sql' or 'memory'.") # Add standard node and edge attributes if "pos" in (node_attributes or []) or any( diff --git a/tests/data_model/test_tracks.py b/tests/data_model/test_tracks.py index 06f35b81..73ffd344 100644 --- a/tests/data_model/test_tracks.py +++ b/tests/data_model/test_tracks.py @@ -21,7 +21,9 @@ def test_create_tracks(graph_3d_with_segmentation: td.graph.BaseGraph): tracks = Tracks(graph=empty_graph, ndim=3, **track_attrs) # type: ignore[arg-type] assert tracks.features.position_key == "pos" assert isinstance(tracks.features["pos"], dict) - with pytest.raises(KeyError): + # Querying a non-existent node errors; the exact type is backend-dependent + # (KeyError on the in-memory backend, ValueError on SQL), so accept either. + with pytest.raises((KeyError, ValueError)): tracks.get_positions([1]) # create tracks with graph only @@ -36,7 +38,7 @@ 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): + with pytest.raises((KeyError, ValueError)): tracks.get_position(0) # create track with graph and seg @@ -348,5 +350,7 @@ 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) From c186199cdafe8de033b6f0888244739c75e844bb Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Mon, 10 Aug 2026 09:38:05 -0700 Subject: [PATCH 02/20] incorporate latest tracksdata changes --- pyproject.toml | 11 +++++------ src/funtracks/data_model/tracks.py | 25 ++++++++----------------- tests/data_model/test_tracks.py | 6 +++--- 3 files changed, 16 insertions(+), 26 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 70747bab..65162112 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,13 +72,12 @@ dev = [ {include-group = "docs"}, ] -# TEMPORARY: pin tracksdata to a LOCAL editable checkout on the -# `combine-branches-for-funtracks-sql` branch, which merges the four SQL-backend fix -# branches (live-update views #325, numpy-scalar BLOB, pl.Array read truncation, edge-revive -# edge_id, and the missing-attr-key KeyError). Local-only branch, not pushed. Replace with a -# released version pin once those land in royerlab/tracksdata. See .claude/plans/sql.md. +# TEMPORARY: pin tracksdata to the `cmalinmayor/graph-views` branch, which merges the +# SQL-backend fixes (live-update views #325, numpy-scalar BLOB, pl.Array read truncation, +# edge-revive edge_id, missing-attr-key KeyError). Replace with a released version pin once +# those land in royerlab/tracksdata. See .claude/plans/sql.md. [tool.uv.sources] -tracksdata = { path = "../tracksdata", editable = true } +tracksdata = { git = "https://github.com/cmalinmayor/tracksdata.git", rev = "2d116bd6977105b8053f1aaacf63dffb7123340d" } [tool.setuptools_scm] diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index 3e3028f2..701e0189 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -830,37 +830,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. - # - # TODO(sql): by the accessor policy (attribute I/O → graph_full), this should - # register on graph_full, not the view. Blocked on tracksdata bug #5 — on SQL, - # add_node_attr_key + update_node_attrs fails once any live view exists, for BOTH - # root- and view-registration (see .claude/plans/tracksdata_numpy_scalar_bug.md). - # Once fixed: register on graph_full and delete this workaround. See sql.md TODO. + # 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, diff --git a/tests/data_model/test_tracks.py b/tests/data_model/test_tracks.py index 73ffd344..39813610 100644 --- a/tests/data_model/test_tracks.py +++ b/tests/data_model/test_tracks.py @@ -21,9 +21,7 @@ def test_create_tracks(graph_3d_with_segmentation: td.graph.BaseGraph): tracks = Tracks(graph=empty_graph, ndim=3, **track_attrs) # type: ignore[arg-type] assert tracks.features.position_key == "pos" assert isinstance(tracks.features["pos"], dict) - # Querying a non-existent node errors; the exact type is backend-dependent - # (KeyError on the in-memory backend, ValueError on SQL), so accept either. - with pytest.raises((KeyError, ValueError)): + with pytest.raises(KeyError): tracks.get_positions([1]) # create tracks with graph only @@ -38,6 +36,8 @@ 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 + # 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) From bfe253310276763013adcdc7f5f98f8b971c6e48 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Mon, 10 Aug 2026 09:52:53 -0700 Subject: [PATCH 03/20] tracks.delete_feature should act on graph_full - needs tracksdata fix --- src/funtracks/data_model/tracks.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index 701e0189..b775fa9a 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -886,9 +886,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). + # Schema removal goes through graph_solution (the view), NOT graph_full, and + # propagates UP to the root. This is asymmetric with add_feature (which + # registers on graph_full): tracksdata propagates attr-key ADDS root->view, but + # NOT removes root->view — only view->root. So a root remove_*_attr_key would + # leave the column dangling on the view. Revisit if tracksdata adds root->view + # remove propagation. 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(): From fe1328b3607a9a52f5cf023c889a77a748f61f61 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Mon, 10 Aug 2026 10:14:29 -0700 Subject: [PATCH 04/20] tracksdata now propagates delete_node/edge_attr down to all views --- pyproject.toml | 2 +- src/funtracks/data_model/tracks.py | 16 ++++++---------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 65162112..66eae06b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ dev = [ # edge-revive edge_id, missing-attr-key KeyError). Replace with a released version pin once # those land in royerlab/tracksdata. See .claude/plans/sql.md. [tool.uv.sources] -tracksdata = { git = "https://github.com/cmalinmayor/tracksdata.git", rev = "2d116bd6977105b8053f1aaacf63dffb7123340d" } +tracksdata = { git = "https://github.com/cmalinmayor/tracksdata.git", rev = "10e710adb6030819dae878ded4f2b699ed13c262" } [tool.setuptools_scm] diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index b775fa9a..bdfd11ed 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -886,16 +886,12 @@ def delete_feature(self, key: str) -> None: else: return - # Schema removal goes through graph_solution (the view), NOT graph_full, and - # propagates UP to the root. This is asymmetric with add_feature (which - # registers on graph_full): tracksdata propagates attr-key ADDS root->view, but - # NOT removes root->view — only view->root. So a root remove_*_attr_key would - # leave the column dangling on the view. Revisit if tracksdata adds root->view - # remove propagation. - 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 From 62aa2fc3b163e9f7232c9cbcd90ab800a2be741a Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Mon, 10 Aug 2026 10:19:25 -0700 Subject: [PATCH 05/20] necessary changes in tests_old --- tests_old/data_model/test_tracks.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests_old/data_model/test_tracks.py b/tests_old/data_model/test_tracks.py index 66bd3699..69b85895 100644 --- a/tests_old/data_model/test_tracks.py +++ b/tests_old/data_model/test_tracks.py @@ -36,7 +36,9 @@ def test_create_tracks(graph_3d_with_segmentation: td.graph.GraphView): 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: tracksdata single-node getitem diverges by backend + # (KeyError in-memory, ValueError on SQL) — accept either. + with pytest.raises((KeyError, ValueError)): tracks.get_position(0) # create track with graph and seg @@ -360,5 +362,6 @@ def test_update_mask_syncs_bbox(graph_2d_with_segmentation): stored_mask = tracks.graph.nodes[1][td.DEFAULT_ATTR_KEYS.MASK] stored_bbox = tracks.graph.nodes[1][td.DEFAULT_ATTR_KEYS.BBOX] - assert stored_mask is new_mask + # Value equality, not identity: SQL backend materializes a fresh Mask on read. + assert stored_mask == new_mask assert np.array_equal(stored_bbox, new_mask.bbox) From 60ba04699ff3323a7f9ee7871e15b2490af8d91b Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Mon, 10 Aug 2026 10:53:38 -0700 Subject: [PATCH 06/20] newer td commit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 66eae06b..e0801154 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ dev = [ # edge-revive edge_id, missing-attr-key KeyError). Replace with a released version pin once # those land in royerlab/tracksdata. See .claude/plans/sql.md. [tool.uv.sources] -tracksdata = { git = "https://github.com/cmalinmayor/tracksdata.git", rev = "10e710adb6030819dae878ded4f2b699ed13c262" } +tracksdata = { git = "https://github.com/cmalinmayor/tracksdata.git", rev = "a3adcd5" } [tool.setuptools_scm] From 851f20daa2a256139331dbce9674603c1c908f3b Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Mon, 31 Aug 2026 12:14:02 -0700 Subject: [PATCH 07/20] IndexedRXGraph is default + test both backends --- src/funtracks/utils/tracksdata_utils.py | 75 ++++++++++++------------- tests/conftest.py | 51 ++++++++++++----- 2 files changed, 72 insertions(+), 54 deletions(-) diff --git a/src/funtracks/utils/tracksdata_utils.py b/src/funtracks/utils/tracksdata_utils.py index 925f55f0..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,7 +95,7 @@ def create_empty_graph( database: str | None = None, position_attrs: list[str] | None = None, ndim: int = 3, - backend: str = "sql", + backend: str = "memory", ) -> td.graph.BaseGraph: """ Create an empty tracksdata base graph with standard node and edge attributes. @@ -104,7 +123,7 @@ def create_empty_graph( 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 "sql". + Defaults to "memory". Returns ------- @@ -115,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" @@ -136,16 +149,7 @@ def create_empty_graph( edge_default_values = [0.0] * len(edge_attributes or []) # Initialize an empty graph on the requested backend. - if backend == "sql": - graph_td = td.graph.SQLGraph( - drivername="sqlite", - database=database, - overwrite=True, - ) - elif backend == "memory": - graph_td = td.graph.IndexedRXGraph() - else: - raise ValueError(f"Unknown backend {backend!r}; expected 'sql' or 'memory'.") + graph_td = _new_empty_backend(backend, database) # Add standard node and edge attributes if "pos" in (node_attributes or []) or any( @@ -454,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() @@ -518,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/conftest.py b/tests/conftest.py index 78768e1d..cc27b3b0 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 @@ -179,6 +192,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 +302,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 +336,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 +368,7 @@ def graph_3d_with_segmentation(tmp_path) -> td.graph.BaseGraph: with_iou=True, with_masks=True, database=db_path, + backend=backend, ) @@ -431,9 +451,9 @@ def _make_tracks( @pytest.fixture -def graph_2d_list(tmp_path) -> td.graph.BaseGraph: +def graph_2d_list(tmp_path, backend) -> td.graph.BaseGraph: db_path = str(tmp_path / "graph_2d_list.db") - graph = create_empty_graph(database=db_path) + graph = create_empty_graph(database=db_path, backend=backend) nodes = [ { @@ -472,7 +492,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 +527,7 @@ def _get_graph( with_iou=with_seg, with_masks=with_seg, database=db_path, + backend=backend, ) return _get_graph From 149b9d71c517f83b799bc2acfc95694973be1b33 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Mon, 31 Aug 2026 12:40:54 -0700 Subject: [PATCH 08/20] for windows ci, use the inmemory sql graph, because on disc is very slow --- tests/conftest.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index cc27b3b0..5dcd1cdc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -185,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, @@ -452,7 +457,7 @@ def _make_tracks( @pytest.fixture def graph_2d_list(tmp_path, backend) -> td.graph.BaseGraph: - db_path = str(tmp_path / "graph_2d_list.db") + db_path = ":memory:" if backend == "sql" else str(tmp_path / "graph_2d_list.db") graph = create_empty_graph(database=db_path, backend=backend) nodes = [ From f35b5ae09373975891c299bb2156fd598c2ddc62 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Mon, 31 Aug 2026 13:12:57 -0700 Subject: [PATCH 09/20] add backend option to all functions that return a graph + test for some both backends --- src/funtracks/candidate_graph/compute_graph.py | 10 ++++++++-- src/funtracks/candidate_graph/utils.py | 6 ++++++ src/funtracks/import_export/_tracks_builder.py | 7 ++++++- src/funtracks/import_export/csv/_import.py | 3 +++ src/funtracks/import_export/geff/_import.py | 6 +++++- tests/candidate_graph/test_compute_graph.py | 17 ++++++++++++++--- tests/import_export/test_csv_import.py | 7 +++++-- tests/import_export/test_import_from_geff.py | 8 +++++++- 8 files changed, 54 insertions(+), 10 deletions(-) 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/import_export/_tracks_builder.py b/src/funtracks/import_export/_tracks_builder.py index 65681688..14f453f7 100644 --- a/src/funtracks/import_export/_tracks_builder.py +++ b/src/funtracks/import_export/_tracks_builder.py @@ -475,6 +475,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. @@ -485,6 +486,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 @@ -547,6 +549,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"]] @@ -753,6 +756,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. @@ -763,6 +767,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 @@ -831,7 +836,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 923b1248..d27d7770 100644 --- a/src/funtracks/import_export/geff/_import.py +++ b/src/funtracks/import_export/geff/_import.py @@ -282,6 +282,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. @@ -294,7 +295,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 @@ -362,6 +363,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. @@ -380,6 +382,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 @@ -423,4 +426,5 @@ def import_from_geff( scale=scale, node_name_map=builder.node_name_map, database=database, + backend=backend, ) diff --git a/tests/candidate_graph/test_compute_graph.py b/tests/candidate_graph/test_compute_graph.py index ace7e57f..e52e4a01 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()) @@ -53,6 +59,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]] @@ -173,7 +180,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 @@ -184,7 +191,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/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) From 955980fbf2b9e512d78c6d31cbe0c93989876e61 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Mon, 31 Aug 2026 17:09:23 -0700 Subject: [PATCH 10/20] upgrade tracksdata to v0.1.0rc9 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0e16ad9b..1ee9ddae 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[spatial]>=0.1.0rc8", + "tracksdata>=0.1.0rc9", "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 From f5775bae46f863d7b8a4d7cf0b101f65ff47d8a5 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Tue, 1 Sep 2026 09:40:17 -0400 Subject: [PATCH 11/20] Remove identical test files from test_old --- tests_old/actions/test_deprecated_imports.py | 23 - tests_old/annotators/test_graph_annotator.py | 30 - .../benchmarks/profile_candidate_graph.py | 32 - tests_old/candidate_graph/test_graph_utils.py | 121 --- tests_old/features/test_feature_dict.py | 207 ----- tests_old/features/test_node_features.py | 138 ---- tests_old/import_export/test_name_mapping.py | 732 ------------------ .../import_export/test_tracks_builder.py | 263 ------- tests_old/import_export/test_validation.py | 119 --- tests_old/utils/test_zarr_compat.py | 176 ----- 10 files changed, 1841 deletions(-) delete mode 100644 tests_old/actions/test_deprecated_imports.py delete mode 100644 tests_old/annotators/test_graph_annotator.py delete mode 100644 tests_old/benchmarks/profile_candidate_graph.py delete mode 100644 tests_old/candidate_graph/test_graph_utils.py delete mode 100644 tests_old/features/test_feature_dict.py delete mode 100644 tests_old/features/test_node_features.py delete mode 100644 tests_old/import_export/test_name_mapping.py delete mode 100644 tests_old/import_export/test_tracks_builder.py delete mode 100644 tests_old/import_export/test_validation.py delete mode 100644 tests_old/utils/test_zarr_compat.py diff --git a/tests_old/actions/test_deprecated_imports.py b/tests_old/actions/test_deprecated_imports.py deleted file mode 100644 index ff4afb1d..00000000 --- a/tests_old/actions/test_deprecated_imports.py +++ /dev/null @@ -1,23 +0,0 @@ -import pytest - - -def test_deprecated_update_track_id_import(): - """Test that importing UpdateTrackID emits a deprecation warning.""" - with pytest.warns(DeprecationWarning, match="UpdateTrackID is deprecated"): - from funtracks.actions import UpdateTrackID # noqa: F401 - - -def test_deprecated_update_track_id_is_alias(): - """Test that UpdateTrackID is an alias for UpdateTrackIDs.""" - with pytest.warns(DeprecationWarning): - from funtracks.actions import UpdateTrackID - - from funtracks.actions import UpdateTrackIDs - - assert UpdateTrackID is UpdateTrackIDs - - -def test_invalid_import_raises_import_error(): - """Test that importing a non-existent name raises ImportError.""" - with pytest.raises(ImportError, match="cannot import name 'NonExistent'"): - from funtracks.actions import NonExistent # noqa: F401 diff --git a/tests_old/annotators/test_graph_annotator.py b/tests_old/annotators/test_graph_annotator.py deleted file mode 100644 index 77a294b2..00000000 --- a/tests_old/annotators/test_graph_annotator.py +++ /dev/null @@ -1,30 +0,0 @@ -import pytest - -from funtracks.actions import BasicAction -from funtracks.annotators import GraphAnnotator -from funtracks.data_model import Tracks -from funtracks.features import Time - -track_attrs = {"time_attr": "t", "tracklet_attr": "track_id"} - - -def test_base_graph_annotator(graph_2d_with_segmentation): - tracks = Tracks(graph_2d_with_segmentation, **track_attrs) - ann = GraphAnnotator(tracks, {}) - assert len(ann.features) == 0 - - feat = Time() - ann = GraphAnnotator(tracks, {"time": feat}) - # Features start disabled by default - assert len(ann.all_features) == 1 - assert len(ann.features) == 0 - # Enable to test - ann.activate_features(["time"]) - assert len(ann.features) == 1 - - with pytest.raises(NotImplementedError): - ann.compute() - - with pytest.raises(NotImplementedError): - action = BasicAction(tracks) - ann.update(action) diff --git a/tests_old/benchmarks/profile_candidate_graph.py b/tests_old/benchmarks/profile_candidate_graph.py deleted file mode 100644 index 949a652c..00000000 --- a/tests_old/benchmarks/profile_candidate_graph.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Profile candidate graph generation using the same synthetic data as the benchmark. - -Usage: - uv run --with line-profiler python tests/benchmarks/profile_candidate_graph.py -""" - -from bench_candidate_graph import MAX_EDGE_DISTANCE, _generate_segmentation -from line_profiler import LineProfiler - -from funtracks.candidate_graph.compute_graph import compute_graph_from_seg -from funtracks.candidate_graph.iou import _compute_ious, _get_iou_dict, add_iou -from funtracks.candidate_graph.utils import ( - add_cand_edges, - create_kdtree, - nodes_from_segmentation, -) - -seg_data = _generate_segmentation() - -lp = LineProfiler() -lp.add_function(compute_graph_from_seg) -lp.add_function(nodes_from_segmentation) -lp.add_function(add_cand_edges) -lp.add_function(add_iou) -lp.add_function(_get_iou_dict) -lp.add_function(_compute_ious) -lp.add_function(create_kdtree) - -lp_wrapper = lp(compute_graph_from_seg) -lp_wrapper(seg_data, MAX_EDGE_DISTANCE, iou=True) - -lp.print_stats() diff --git a/tests_old/candidate_graph/test_graph_utils.py b/tests_old/candidate_graph/test_graph_utils.py deleted file mode 100644 index dd464ade..00000000 --- a/tests_old/candidate_graph/test_graph_utils.py +++ /dev/null @@ -1,121 +0,0 @@ -from collections import Counter - -import numpy as np - -from funtracks.candidate_graph import add_cand_edges, nodes_from_segmentation -from funtracks.candidate_graph.utils import ( - _compute_node_frame_dict, - nodes_from_points_list, -) - - -# nodes_from_segmentation -def test_nodes_from_segmentation_empty(): - # test with empty segmentation - empty_graph, node_frame_dict = nodes_from_segmentation( - np.zeros((3, 1, 10, 10), dtype="int32") - ) - assert len(empty_graph.node_ids()) == 0 - assert node_frame_dict == {} - - -def test_nodes_from_segmentation_2d(get_tracks): - tracks = get_tracks(ndim=3, with_seg=True) - segmentation_2d = np.asarray(tracks.segmentation) - - # test with 2D segmentation - node_graph, node_frame_dict = nodes_from_segmentation( - segmentation=segmentation_2d, - ) - assert sorted(node_graph.node_ids()) == [1, 2, 3, 4, 5, 6] - assert node_graph.nodes[2]["t"] == 1 - assert node_graph.nodes[2]["area"] == 305 - assert np.array_equal(node_graph.nodes[2]["pos"], np.array([20, 80])) - - assert node_frame_dict[0] == [1] - assert Counter(node_frame_dict[1]) == Counter([2, 3]) - - # test with scaling - node_graph, node_frame_dict = nodes_from_segmentation( - segmentation=segmentation_2d, scale=[1, 1, 2] - ) - assert sorted(node_graph.node_ids()) == [1, 2, 3, 4, 5, 6] - assert node_graph.nodes[2]["t"] == 1 - assert node_graph.nodes[2]["area"] == 610 - assert np.array_equal(node_graph.nodes[2]["pos"], np.array([20, 160])) - - assert node_frame_dict[0] == [1] - assert Counter(node_frame_dict[1]) == Counter([2, 3]) - - -def test_nodes_from_segmentation_3d(get_tracks): - tracks = get_tracks(ndim=4, with_seg=True) - segmentation_3d = np.asarray(tracks.segmentation) - - # test with 3D segmentation - node_graph, node_frame_dict = nodes_from_segmentation( - segmentation=segmentation_3d, - ) - assert sorted(node_graph.node_ids()) == [1, 2, 3, 4, 5, 6] - assert node_graph.nodes[2]["t"] == 1 - assert node_graph.nodes[2]["area"] == 4169 - assert np.array_equal(node_graph.nodes[2]["pos"], np.array([20, 50, 80])) - - assert node_frame_dict[0] == [1] - assert Counter(node_frame_dict[1]) == Counter([2, 3]) - - # test with scaling - node_graph, node_frame_dict = nodes_from_segmentation( - segmentation=segmentation_3d, scale=[1, 1, 4.5, 1] - ) - assert sorted(node_graph.node_ids()) == [1, 2, 3, 4, 5, 6] - assert node_graph.nodes[2]["t"] == 1 - assert node_graph.nodes[2]["area"] == 4169 * 4.5 - assert np.array_equal(node_graph.nodes[2]["pos"], np.array([20.0, 225.0, 80.0])) - - assert node_frame_dict[0] == [1] - assert Counter(node_frame_dict[1]) == Counter([2, 3]) - - -# add_cand_edges -def test_add_cand_edges_2d(get_tracks): - tracks = get_tracks(ndim=3, with_seg=True) - segmentation_2d = np.asarray(tracks.segmentation) - node_graph, node_frame_dict = nodes_from_segmentation(segmentation_2d) - add_cand_edges(node_graph, max_edge_distance=50, node_frame_dict=node_frame_dict) - # dist(1→2) ≈ 42.4, dist(1→3) ≈ 11.2 — both within 50; nodes 4,5,6 too far - assert sorted(node_graph.edge_list()) == [[1, 2], [1, 3]] - - -def test_add_cand_edges_3d(get_tracks): - tracks = get_tracks(ndim=4, with_seg=True) - segmentation_3d = np.asarray(tracks.segmentation) - node_graph, node_frame_dict = nodes_from_segmentation(segmentation_3d) - add_cand_edges(node_graph, max_edge_distance=15, node_frame_dict=node_frame_dict) - # dist(1→3) ≈ 11.2, dist(1→2) ≈ 42.4 — only (1, 3) within 15 - assert sorted(node_graph.edge_list()) == [[1, 3]] - - -def test_compute_node_frame_dict(get_tracks): - tracks = get_tracks(ndim=3, with_seg=True) - segmentation_2d = np.asarray(tracks.segmentation) - node_graph, _ = nodes_from_segmentation(segmentation_2d) - node_frame_dict = _compute_node_frame_dict(node_graph) - assert node_frame_dict[0] == [1] - assert Counter(node_frame_dict[1]) == Counter([2, 3]) - - -def test_nodes_from_points_list_2d(): - points_list = np.array( - [ - [0, 1, 2, 3], - [2, 3, 4, 5], - [1, 2, 3, 4], - ] - ) - cand_graph, node_frame_dict = nodes_from_points_list(points_list) - assert sorted(cand_graph.node_ids()) == [0, 1, 2] - assert cand_graph.nodes[0]["t"] == 0 - assert np.array_equal(cand_graph.nodes[0]["pos"], np.array([1, 2, 3])) - assert cand_graph.nodes[1]["t"] == 2 - assert np.array_equal(cand_graph.nodes[1]["pos"], np.array([3, 4, 5])) diff --git a/tests_old/features/test_feature_dict.py b/tests_old/features/test_feature_dict.py deleted file mode 100644 index 46e173c8..00000000 --- a/tests_old/features/test_feature_dict.py +++ /dev/null @@ -1,207 +0,0 @@ -import pytest - -from funtracks.features import FeatureDict, LineageID, Position, Time, TrackletID - - -class TestFeatureDict: - def test_init(self): - """Test basic initialization of FeatureDict""" - features = {"time": Time(), "pos": Position(("y", "x"))} - fd = FeatureDict( - features=features, time_key="time", position_key="pos", tracklet_key=None - ) - - assert len(fd) == 2 - assert fd.time_key == "time" - assert fd.position_key == "pos" - assert fd["time"] == Time() - assert fd["pos"] == Position(("y", "x")) - - def test_init_with_list_position(self): - """Test initialization with list of position keys""" - features = { - "time": Time(), - "y": { - "feature_type": "node", - "value_type": "float", - "num_values": 1, - "recompute": False, - "default_value": None, - }, - "x": { - "feature_type": "node", - "value_type": "float", - "num_values": 1, - "recompute": False, - "default_value": None, - }, - } - fd = FeatureDict( - features=features, time_key="time", position_key=["y", "x"], tracklet_key=None - ) - - assert len(fd) == 3 - assert fd.time_key == "time" - assert fd.position_key == ["y", "x"] - # Check that both position features exist - assert "y" in fd - assert "x" in fd - - def test_init_validation(self): - """Test that init validates time and position keys exist""" - features = {"time": Time(), "pos": Position(("y", "x"))} - - # Missing time key - with pytest.raises(KeyError, match="time_key 'invalid' not found"): - FeatureDict( - features, time_key="invalid", position_key="pos", tracklet_key=None - ) - - # Missing position key - with pytest.raises(KeyError, match="position_key 'invalid' not found"): - FeatureDict( - features, time_key="time", position_key="invalid", tracklet_key=None - ) - - # Missing one of multiple position keys - with pytest.raises(KeyError, match="position_key 'z' not found"): - FeatureDict( - features, time_key="time", position_key=["z", "y"], tracklet_key=None - ) - - def test_node_features(self): - """Test node_features property filters correctly""" - features = { - "time": Time(), - "pos": Position(("y", "x")), - "iou": { - "feature_type": "edge", - "value_type": "float", - "num_values": 1, - "display_name": "IoU", - "recompute": True, - "default_value": None, - }, - } - fd = FeatureDict(features, time_key="time", position_key="pos", tracklet_key=None) - - node_feats = fd.node_features - assert len(node_feats) == 2 - assert "time" in node_feats - assert "pos" in node_feats - assert "iou" not in node_feats - - def test_edge_features(self): - """Test edge_features property filters correctly""" - features = { - "time": Time(), - "pos": Position(("y", "x")), - "iou": { - "feature_type": "edge", - "value_type": "float", - "num_values": 1, - "display_name": "IoU", - "recompute": True, - "default_value": None, - }, - } - fd = FeatureDict(features, time_key="time", position_key="pos", tracklet_key=None) - - edge_feats = fd.edge_features - assert len(edge_feats) == 1 - assert "iou" in edge_feats - assert "time" not in edge_feats - - @pytest.mark.parametrize("composite_position", [True, False]) - def test_json_dump_and_load(self, composite_position): - """Test JSON serialization and deserialization""" - if composite_position: - pos_key = "pos" - features = {"time": Time(), pos_key: Position(("y", "x"))} - else: - pos_key = ["y", "x"] - features = { - "time": Time(), - "y": { - "feature_type": "node", - "value_type": "float", - "num_values": 1, - "recompute": False, - "default_value": None, - }, - "x": { - "feature_type": "node", - "value_type": "float", - "num_values": 1, - "recompute": False, - "default_value": None, - }, - } - - fd = FeatureDict( - features, time_key="time", position_key=pos_key, tracklet_key=None - ) - json_dict = fd.dump_json() - - assert "FeatureDict" in json_dict - assert "features" in json_dict["FeatureDict"] - assert "time_key" in json_dict["FeatureDict"] - assert "position_key" in json_dict["FeatureDict"] - assert "tracklet_key" in json_dict["FeatureDict"] - - # Load back from JSON - loaded_fd = FeatureDict.from_json(json_dict) - assert loaded_fd.time_key == fd.time_key - assert loaded_fd.position_key == fd.position_key - assert len(loaded_fd) == len(fd) - - # Check features match - for key in fd: - assert key in loaded_fd - assert loaded_fd[key] == fd[key] - - def test_dict_behavior(self): - """Test that FeatureDict behaves like a dict""" - features = {"time": Time(), "pos": Position(("y", "x"))} - fd = FeatureDict(features, time_key="time", position_key="pos", tracklet_key=None) - - # Can iterate over keys - keys = list(fd.keys()) - assert "time" in keys - assert "pos" in keys - - # Can access like a dict - assert fd["time"] == Time() - assert fd["pos"] == Position(("y", "x")) - - # Can check membership - assert "time" in fd - assert "nonexistent" not in fd - - def test_register_tracklet_feature(self): - """Test registering a tracklet feature sets the tracklet_key.""" - features = {"time": Time(), "pos": Position(("y", "x"))} - fd = FeatureDict(features, time_key="time", position_key="pos", tracklet_key=None) - - assert fd.tracklet_key is None - assert "track_id" not in fd - - fd.register_tracklet_feature("track_id", TrackletID()) - - assert fd.tracklet_key == "track_id" - assert "track_id" in fd - assert fd["track_id"] == TrackletID() - - def test_register_lineage_feature(self): - """Test registering a lineage feature sets the lineage_key.""" - features = {"time": Time(), "pos": Position(("y", "x"))} - fd = FeatureDict(features, time_key="time", position_key="pos", tracklet_key=None) - - assert fd.lineage_key is None - assert "lineage_id" not in fd - - fd.register_lineage_feature("lineage_id", LineageID()) - - assert fd.lineage_key == "lineage_id" - assert "lineage_id" in fd - assert fd["lineage_id"] == LineageID() diff --git a/tests_old/features/test_node_features.py b/tests_old/features/test_node_features.py deleted file mode 100644 index db485394..00000000 --- a/tests_old/features/test_node_features.py +++ /dev/null @@ -1,138 +0,0 @@ -from funtracks.features import ( - Area, - Circularity, - EllipsoidAxes, - Perimeter, - Position, - SegBbox, - SegMask, - Solution, - Time, -) - - -def test_time_feature(): - """Test that Time() returns a valid Feature TypedDict""" - feat = Time() - assert feat["feature_type"] == "node" - assert feat["value_type"] == "int" - assert feat["num_values"] == 1 - assert feat["display_name"] == "Time" - assert feat["default_value"] is None - - -def test_position_feature(): - """Test that Position() returns a valid Feature TypedDict""" - feat = Position(axes=["y", "x"]) - assert feat["feature_type"] == "node" - assert feat["value_type"] == "float" - assert feat["num_values"] == 2 - assert feat["display_name"] == "position" - assert feat["value_names"] == ["y", "x"] - assert feat["default_value"] is None - - -def test_area_feature(): - """Test that Area() returns a valid Feature TypedDict""" - feat = Area(ndim=3) - assert feat["feature_type"] == "node" - assert feat["value_type"] == "float" - assert feat["num_values"] == 1 - assert feat["display_name"] == "Area" - assert feat["default_value"] is None - - feat = Area(ndim=4) - assert feat["display_name"] == "Volume" - - -def test_ellipsoid_axes_feature(): - """Test that EllipsoidAxes() returns a valid Feature TypedDict""" - # ndim=3 means 2D+time -> 2 spatial dimensions - feat = EllipsoidAxes(ndim=3) - assert feat["feature_type"] == "node" - assert feat["value_type"] == "float" - assert feat["num_values"] == 2 - assert feat["display_name"] == "Ellipse axis radii" - assert feat["value_names"] == ["major_axis", "minor_axis"] - - # ndim=4 means 3D+time -> 3 spatial dimensions - feat = EllipsoidAxes(ndim=4) - assert feat["num_values"] == 3 - assert feat["display_name"] == "Ellipsoid axis radii" - assert feat["value_names"] == ["major_axis", "semi_minor_axis", "minor_axis"] - - -def test_circularity_feature(): - """Test that Circularity() returns a valid Feature TypedDict""" - feat = Circularity(ndim=3) - assert feat["feature_type"] == "node" - assert feat["value_type"] == "float" - assert feat["num_values"] == 1 - assert feat["display_name"] == "Circularity" - - feat = Circularity(ndim=4) - assert feat["display_name"] == "Sphericity" - - -def test_perimeter_feature(): - """Test that Perimeter() returns a valid Feature TypedDict""" - feat = Perimeter(ndim=3) - assert feat["feature_type"] == "node" - assert feat["value_type"] == "float" - assert feat["num_values"] == 1 - assert feat["display_name"] == "Perimeter" - - feat = Perimeter(ndim=4) - assert feat["display_name"] == "Surface Area" - - -def test_solution_feature(): - """Test that Solution() returns a valid Feature TypedDict for node and edge.""" - feat = Solution() - assert feat["feature_type"] == ["node", "edge"] - assert feat["value_type"] == "bool" - assert feat["num_values"] == 1 - assert feat["display_name"] == "Solution" - assert feat["default_value"] is True - - -def test_seg_mask_feature(): - """Test that SegMask() returns a valid Feature TypedDict""" - feat = SegMask(ndim=3) - assert feat["feature_type"] == "node" - assert feat["value_type"] == "mask" - assert feat["num_values"] == 1 - assert feat["display_name"] == "Segmentation mask" - assert feat["default_value"] is None - assert feat["derived_features"] == ["bbox"] - - # Custom bbox_key - feat = SegMask(ndim=3, bbox_key="nuc_bbox") - assert feat["derived_features"] == ["nuc_bbox"] - - -def test_seg_bbox_feature(): - """Test that SegBbox() returns a valid Feature TypedDict""" - # ndim=3 means 2D+time -> 2 spatial dimensions -> 4 bbox values - feat = SegBbox(ndim=3) - assert feat["feature_type"] == "node" - assert feat["value_type"] == "int" - assert feat["num_values"] == 4 - assert feat["display_name"] == "Bounding box" - assert feat["default_value"] is None - - # ndim=4 means 3D+time -> 3 spatial dimensions -> 6 bbox values - feat = SegBbox(ndim=4) - assert feat["num_values"] == 6 - - -def test_feature_as_dict(): - """Test that Features are valid dicts""" - feat = Time() - assert isinstance(feat, dict) - assert "feature_type" in feat - assert "value_type" in feat - - # Can convert to regular dict - regular_dict = dict(feat) - assert regular_dict == feat diff --git a/tests_old/import_export/test_name_mapping.py b/tests_old/import_export/test_name_mapping.py deleted file mode 100644 index fe436ed3..00000000 --- a/tests_old/import_export/test_name_mapping.py +++ /dev/null @@ -1,732 +0,0 @@ -"""Unit tests for name mapping helper functions.""" - -from __future__ import annotations - -from funtracks.import_export._name_mapping import ( - _map_remaining_to_self, - _match_display_names_exact, - _match_display_names_fuzzy, - _match_exact, - _match_fuzzy, - build_display_name_mapping, - build_standard_fields, - infer_edge_name_map, - infer_node_name_map, -) - - -class TestMatchExact: - """Test exact matching between target fields and available properties.""" - - def test_perfect_match(self): - """Test when all target fields have exact matches.""" - target_fields = ["time", "x", "y"] - available_props = ["time", "x", "y", "area"] - mapping = {} - - remaining = _match_exact(target_fields, available_props, mapping) - - assert mapping == {"time": "time", "x": "x", "y": "y"} - assert remaining == ["area"] - - def test_partial_match(self): - """Test when only some target fields have exact matches.""" - target_fields = ["time", "x", "y", "z"] - available_props = ["time", "x", "area"] - mapping = {} - - remaining = _match_exact(target_fields, available_props, mapping) - - assert mapping == {"time": "time", "x": "x"} - assert remaining == ["area"] - - def test_no_matches(self): - """Test when no target fields have exact matches.""" - target_fields = ["time", "x", "y"] - available_props = ["t", "X", "Y"] - mapping = {} - - remaining = _match_exact(target_fields, available_props, mapping) - - assert mapping == {} - assert remaining == ["t", "X", "Y"] - - def test_empty_inputs(self): - """Test with empty inputs.""" - mapping = {} - remaining = _match_exact([], [], mapping) - assert mapping == {} - assert remaining == [] - - def test_skip_existing_mapping(self): - """Test that fields already in existing_mapping are skipped.""" - target_fields = ["time", "x", "y"] - available_props = ["time", "x", "y"] - mapping = {"time": "t"} # time already mapped - - remaining = _match_exact(target_fields, available_props, mapping) - - assert mapping == {"time": "t", "x": "x", "y": "y"} - assert "time" in remaining # time should not be consumed - - -class TestMatchFuzzy: - """Test fuzzy matching between target fields and available properties.""" - - def test_case_insensitive_match(self): - """Test case-insensitive fuzzy matching.""" - target_fields = ["time", "x", "y"] - available_props = ["Time", "X", "Y"] - mapping = {} - - remaining = _match_fuzzy(target_fields, available_props, mapping) - - assert mapping == {"time": "Time", "x": "X", "y": "Y"} - assert remaining == [] - - def test_abbreviation_match(self): - """Test matching abbreviations (e.g., 't' matches 'time').""" - target_fields = ["time"] - available_props = ["t"] - mapping = {} - - _ = _match_fuzzy(target_fields, available_props, mapping) - - # 't' should match 'time' (above 40% similarity) - assert "time" in mapping - assert mapping["time"] == "t" - - def test_cutoff_threshold(self): - """Test that matches below cutoff are not returned.""" - target_fields = ["time"] - available_props = ["abc"] # Very dissimilar - mapping = {} - - remaining = _match_fuzzy(target_fields, available_props, mapping, cutoff=0.4) - - assert mapping == {} - assert remaining == ["abc"] - - def test_custom_cutoff(self): - """Test with custom cutoff value.""" - target_fields = ["time"] - available_props = ["ti"] - - # With low cutoff, should match - mapping_low = {} - _ = _match_fuzzy(target_fields, available_props, mapping_low, cutoff=0.2) - assert "time" in mapping_low - - # With high cutoff, should not match - mapping_high = {} - _ = _match_fuzzy(target_fields, available_props, mapping_high, cutoff=0.9) - assert mapping_high == {} - - def test_skip_existing_mapping(self): - """Test that fields already mapped are skipped.""" - target_fields = ["time", "x"] - available_props = ["t", "X"] - mapping = {"time": "t"} - - _ = _match_fuzzy(target_fields, available_props, mapping) - - assert mapping["time"] == "t" # Should remain unchanged - assert "x" in mapping - - def test_empty_available_props(self): - """Test with no available properties.""" - target_fields = ["time", "x", "y"] - available_props = [] - mapping = {} - - remaining = _match_fuzzy(target_fields, available_props, mapping) - - assert mapping == {} - assert remaining == [] - - -class TestMatchDisplayNamesExact: - """Test exact matching between properties and feature display names.""" - - def test_exact_display_name_match(self) -> None: - """Test exact matching with display names.""" - available_props = ["Area", "Circularity", "time"] - display_name_to_key = { - "Area": ("area", 0), - "Circularity": ("circularity", 0), - } - mapping: dict = {} - - remaining = _match_display_names_exact( - available_props, display_name_to_key, mapping - ) - - assert mapping == {"area": "Area", "circularity": "Circularity"} - assert remaining == ["time"] - - def test_no_matches(self) -> None: - """Test when no properties match display names.""" - available_props = ["t", "x", "y"] - display_name_to_key = { - "Area": ("area", 0), - "Circularity": ("circularity", 0), - } - mapping: dict = {} - - remaining = _match_display_names_exact( - available_props, display_name_to_key, mapping - ) - - assert mapping == {} - assert remaining == ["t", "x", "y"] - - def test_empty_inputs(self) -> None: - """Test with empty inputs.""" - mapping: dict = {} - remaining = _match_display_names_exact([], {}, mapping) - assert mapping == {} - assert remaining == [] - - def test_case_sensitive(self) -> None: - """Test that exact matching is case-sensitive.""" - available_props = ["area", "AREA"] - display_name_to_key = {"Area": ("area", 0)} - mapping: dict = {} - - remaining = _match_display_names_exact( - available_props, display_name_to_key, mapping - ) - - assert mapping == {} # Neither "area" nor "AREA" matches "Area" exactly - assert set(remaining) == {"area", "AREA"} - - def test_multi_value_feature(self) -> None: - """Test matching multi-value features by value_names.""" - available_props = ["major_axis", "minor_axis", "Area"] - display_name_to_key = { - "Area": ("area", 0), - "major_axis": ("ellipsoid_axes", 0), - "semi_minor_axis": ("ellipsoid_axes", 1), - "minor_axis": ("ellipsoid_axes", 2), - } - mapping: dict = {} - - remaining = _match_display_names_exact( - available_props, display_name_to_key, mapping - ) - - assert mapping == { - "area": "Area", - "ellipsoid_axes": ["major_axis", "minor_axis"], # sorted by index - } - assert remaining == [] - - -class TestMatchDisplayNamesFuzzy: - """Test fuzzy matching between properties and feature display names.""" - - def test_case_insensitive_match(self) -> None: - """Test case-insensitive fuzzy matching.""" - available_props = ["area", "CIRC"] - display_name_to_key = { - "Area": ("area", 0), - "Circularity": ("circularity", 0), - } - mapping: dict = {} - - _ = _match_display_names_fuzzy(available_props, display_name_to_key, mapping) - - assert "area" in mapping - assert "circularity" in mapping - - def test_abbreviation_match(self) -> None: - """Test matching abbreviations to display names.""" - available_props = ["Circ", "Ecc"] - display_name_to_key = { - "Circularity": ("circularity", 0), - "Eccentricity": ("eccentricity", 0), - } - mapping: dict = {} - - _ = _match_display_names_fuzzy(available_props, display_name_to_key, mapping) - - assert "circularity" in mapping - assert "eccentricity" in mapping - - def test_no_matches(self) -> None: - """Test when no fuzzy matches found.""" - available_props = ["xyz", "abc"] - display_name_to_key = {"Area": ("area", 0)} - mapping: dict = {} - - remaining = _match_display_names_fuzzy( - available_props, display_name_to_key, mapping - ) - - assert mapping == {} - assert set(remaining) == {"xyz", "abc"} - - def test_empty_available_props(self) -> None: - """Test with empty available properties.""" - mapping: dict = {} - remaining = _match_display_names_fuzzy([], {"Area": ("area", 0)}, mapping) - - assert mapping == {} - assert remaining == [] - - def test_custom_cutoff(self) -> None: - """Test with custom cutoff value.""" - available_props = ["Ar"] - display_name_to_key = {"Area": ("area", 0)} - - # With low cutoff, should match - mapping_low: dict = {} - _ = _match_display_names_fuzzy( - available_props, display_name_to_key, mapping_low, cutoff=0.2 - ) - assert "area" in mapping_low - - # With high cutoff, should not match - mapping_high: dict = {} - _ = _match_display_names_fuzzy( - available_props, display_name_to_key, mapping_high, cutoff=0.9 - ) - assert mapping_high == {} - - def test_multi_value_feature(self) -> None: - """Test fuzzy matching multi-value features by value_names.""" - available_props = ["Major_Axis", "Minor_Axis", "area"] - display_name_to_key = { - "Area": ("area", 0), - "major_axis": ("ellipsoid_axes", 0), - "semi_minor_axis": ("ellipsoid_axes", 1), - "minor_axis": ("ellipsoid_axes", 2), - } - mapping: dict = {} - - remaining = _match_display_names_fuzzy( - available_props, display_name_to_key, mapping - ) - - assert mapping == { - "area": "area", - "ellipsoid_axes": ["Major_Axis", "Minor_Axis"], # sorted by index - } - assert remaining == [] - - -class TestMapRemainingToSelf: - """Test identity mapping for remaining properties.""" - - def test_basic_mapping(self): - """Test basic identity mapping.""" - remaining_props = ["custom_col1", "custom_col2", "feature_x"] - - mapping = _map_remaining_to_self(remaining_props) - - assert mapping == { - "custom_col1": "custom_col1", - "custom_col2": "custom_col2", - "feature_x": "feature_x", - } - - def test_empty_input(self): - """Test with empty input.""" - mapping = _map_remaining_to_self([]) - assert mapping == {} - - def test_single_property(self): - """Test with single property.""" - mapping = _map_remaining_to_self(["prop"]) - assert mapping == {"prop": "prop"} - - -class TestBuildStandardFields: - """Test building list of standard fields to match. - - Position attributes (z, y, x) are NOT included - they are matched via - Position feature's value_names to create composite "pos" mapping. - """ - - def test_basic_required_features(self): - """Test standard fields include required features and seg_id.""" - required_features = ["time"] - - standard_fields = build_standard_fields(required_features) - - assert "time" in standard_fields - # Optional fields - assert "seg_id" in standard_fields - # Position attrs should NOT be in standard fields - assert "z" not in standard_fields - assert "y" not in standard_fields - assert "x" not in standard_fields - - def test_multiple_required_features(self): - """Test with multiple required features (e.g., CSV format).""" - required_features = ["time", "id", "parent_id"] - - standard_fields = build_standard_fields(required_features) - - assert "time" in standard_fields - assert "id" in standard_fields - assert "parent_id" in standard_fields - assert "seg_id" in standard_fields - - -class TestBuildDisplayNameMapping: - """Test building display name to feature key mapping.""" - - def test_basic_mapping(self): - """Test basic display name mapping for single-value features.""" - available_computed_features = { - "area": {"display_name": "Area", "num_values": 1}, - "circularity": {"display_name": "Circularity", "num_values": 1}, - "eccentricity": {"display_name": "Eccentricity", "num_values": 1}, - } - - mapping = build_display_name_mapping(available_computed_features) - - assert mapping == { - "Area": ("area", 0), - "Circularity": ("circularity", 0), - "Eccentricity": ("eccentricity", 0), - } - - def test_multi_value_features_use_value_names(self): - """Test that multi-value features map each value_name to (key, index).""" - available_computed_features = { - "area": {"display_name": "Area", "num_values": 1}, - "position": { - "display_name": "Position", - "num_values": 2, - "value_names": ["y", "x"], - }, - "ellipsoid_axes": { - "display_name": "Ellipsoid axis radii", - "num_values": 3, - "value_names": ["major", "semi_minor", "minor"], - }, - } - - mapping = build_display_name_mapping(available_computed_features) - - assert mapping == { - "Area": ("area", 0), - "y": ("position", 0), - "x": ("position", 1), - "major": ("ellipsoid_axes", 0), - "semi_minor": ("ellipsoid_axes", 1), - "minor": ("ellipsoid_axes", 2), - } - - def test_missing_display_name(self): - """Test single-value features without display_name are skipped.""" - available_computed_features = { - "area": {"display_name": "Area", "num_values": 1}, - "other": {"num_values": 1}, # No display_name - } - - mapping = build_display_name_mapping(available_computed_features) - - assert mapping == {"Area": ("area", 0)} - - def test_empty_input(self): - """Test with empty features dict.""" - mapping = build_display_name_mapping({}) - assert mapping == {} - - -class TestInferNodeNameMapIntegration: - """Integration tests for the full infer_node_name_map pipeline. - - Position columns (z, y, x) are matched via Position feature's value_names, - producing composite mapping like {"pos": ["y", "x"]} or {"pos": ["z", "y", "x"]}. - """ - - def test_perfect_exact_matches_2d(self): - """Test when all fields have exact matches (2D data).""" - importable_props = ["time", "x", "y", "area", "circularity"] - required_features = ["time"] - available_computed_features = { - "area": {"feature_type": "node", "display_name": "Area", "num_values": 1}, - "circularity": { - "feature_type": "node", - "display_name": "Circularity", - "num_values": 1, - }, - "pos": { - "feature_type": "node", - "display_name": "Position", - "num_values": 2, - "value_names": ["y", "x"], - }, - } - - mapping = infer_node_name_map( - importable_props, - required_features, - available_computed_features, - ) - - assert mapping["time"] == "time" - # Position should be composite - assert mapping["pos"] == ["y", "x"] - assert mapping["area"] == "area" - assert mapping["circularity"] == "circularity" - - def test_fuzzy_matching_abbreviations(self): - """Test fuzzy matching with abbreviations.""" - importable_props = ["t", "X", "Y", "Circ"] - required_features = ["time"] - available_computed_features = { - "circularity": { - "feature_type": "node", - "display_name": "Circularity", - "num_values": 1, - }, - "pos": { - "feature_type": "node", - "display_name": "Position", - "num_values": 2, - "value_names": ["y", "x"], - }, - } - - mapping = infer_node_name_map( - importable_props, - required_features, - available_computed_features, - ) - - # Should fuzzy match t->time - assert mapping["time"] == "t" - # Should fuzzy match X->x, Y->y via Position value_names - assert mapping["pos"] == ["Y", "X"] - # Should fuzzy match Circ->circularity via display name - assert mapping["circularity"] == "Circ" - - def test_custom_properties(self): - """Test that unmatched properties map to themselves.""" - importable_props = ["time", "x", "y", "custom_col1", "custom_col2"] - required_features = ["time"] - available_computed_features = { - "pos": { - "feature_type": "node", - "display_name": "Position", - "num_values": 2, - "value_names": ["y", "x"], - }, - } - - mapping = infer_node_name_map( - importable_props, - required_features, - available_computed_features, - ) - - # Custom properties should map to themselves - assert mapping["custom_col1"] == "custom_col1" - assert mapping["custom_col2"] == "custom_col2" - - def test_priority_order(self): - """Test that matching happens in correct priority order.""" - # Exact standard match should take priority over fuzzy feature match - importable_props = ["time", "Time", "x", "y"] - required_features = ["time"] - available_computed_features = { - "time_feature": { - "feature_type": "node", - "display_name": "Time", - "num_values": 1, - }, - "pos": { - "feature_type": "node", - "display_name": "Position", - "num_values": 2, - "value_names": ["y", "x"], - }, - } - - mapping = infer_node_name_map( - importable_props, - required_features, - available_computed_features, - ) - - # "time" should match exactly to standard field "time" - assert mapping["time"] == "time" - # "Time" should fuzzy match to feature "time_feature" - assert mapping["time_feature"] == "Time" - - def test_3d_position(self): - """Test inference for 3D position (z, y, x).""" - importable_props = ["t", "z", "y", "x"] - required_features = ["time"] - available_computed_features = { - "pos": { - "feature_type": "node", - "display_name": "Position", - "num_values": 3, - "value_names": ["z", "y", "x"], - }, - } - - mapping = infer_node_name_map( - importable_props, - required_features, - available_computed_features, - ) - - # Should fuzzy match t->time - assert mapping["time"] == "t" - # Should exact match z, y, x via Position value_names -> composite pos - assert mapping["pos"] == ["z", "y", "x"] - - def test_optional_fields(self): - """Test that optional fields (seg_id, track_id) are matched.""" - importable_props = ["time", "x", "y", "seg_id", "track_id"] - required_features = ["time"] - available_computed_features = { - "pos": { - "feature_type": "node", - "display_name": "Position", - "num_values": 2, - "value_names": ["y", "x"], - }, - } - - mapping = infer_node_name_map( - importable_props, - required_features, - available_computed_features, - ) - - assert mapping["seg_id"] == "seg_id" - assert mapping["track_id"] == "track_id" - - def test_csv_format_with_id_columns(self): - """Test inference for CSV format with id and parent_id.""" - importable_props = ["t", "x", "y", "id", "parent_id", "Area"] - required_features = ["time", "id", "parent_id"] - available_computed_features = { - "area": {"feature_type": "node", "display_name": "Area", "num_values": 1}, - "pos": { - "feature_type": "node", - "display_name": "Position", - "num_values": 2, - "value_names": ["y", "x"], - }, - } - - mapping = infer_node_name_map( - importable_props, - required_features, - available_computed_features, - ) - - # Should fuzzy match t->time - assert mapping["time"] == "t" - # Should exact match id, parent_id - assert mapping["id"] == "id" - assert mapping["parent_id"] == "parent_id" - # Should exact match Area via display name - assert mapping["area"] == "Area" - # Position should be composite - assert mapping["pos"] == ["y", "x"] - - -class TestInferEdgeNameMapIntegration: - """Integration tests for the full infer_edge_name_map pipeline.""" - - def test_perfect_exact_matches(self): - """Test when all edge properties have exact matches.""" - importable_props = ["iou", "distance", "custom_edge_prop"] - available_computed_features = { - "iou": {"feature_type": "edge", "display_name": "IOU"}, - "distance": {"feature_type": "edge", "display_name": "Distance"}, - "area": {"feature_type": "node", "display_name": "Area"}, # Should be ignored - } - - mapping = infer_edge_name_map(importable_props, available_computed_features) - - assert mapping["iou"] == "iou" - assert mapping["distance"] == "distance" - assert mapping["custom_edge_prop"] == "custom_edge_prop" - - def test_fuzzy_matching_abbreviations(self): - """Test fuzzy matching with abbreviations.""" - importable_props = ["IOU", "dist"] - available_computed_features = { - "iou": {"feature_type": "edge", "display_name": "IOU"}, - "distance": {"feature_type": "edge", "display_name": "Distance"}, - } - - mapping = infer_edge_name_map(importable_props, available_computed_features) - - # Should fuzzy match IOU->iou, dist->distance - assert mapping["iou"] == "IOU" - assert mapping["distance"] == "dist" - - def test_custom_properties(self): - """Test that unmatched edge properties map to themselves.""" - importable_props = ["custom_edge1", "custom_edge2", "iou"] - available_computed_features = { - "iou": {"feature_type": "edge", "display_name": "IOU"}, - } - - mapping = infer_edge_name_map(importable_props, available_computed_features) - - # Custom properties should map to themselves - assert mapping["custom_edge1"] == "custom_edge1" - assert mapping["custom_edge2"] == "custom_edge2" - # Standard property should exact match - assert mapping["iou"] == "iou" - - def test_empty_input(self): - """Test with empty edge properties list.""" - mapping = infer_edge_name_map([]) - assert mapping == {} - - def test_with_edge_features_dict(self): - """Test inference with edge feature display names.""" - importable_props = ["Overlap", "Dist", "custom"] - available_computed_features = { - "iou": {"feature_type": "edge", "display_name": "Overlap"}, - "distance": {"feature_type": "edge", "display_name": "Distance"}, - "area": {"feature_type": "node", "display_name": "Area"}, # Should be ignored - } - - mapping = infer_edge_name_map(importable_props, available_computed_features) - - # Should match via display names - assert mapping["iou"] == "Overlap" - # Dist should fuzzy match to "distance" edge feature key - assert mapping["distance"] == "Dist" - # Custom should map to itself - assert mapping["custom"] == "custom" - - def test_without_edge_features_dict(self): - """Test inference without edge feature display names (None).""" - importable_props = ["iou", "distance", "custom"] - - mapping = infer_edge_name_map(importable_props, available_computed_features=None) - - # Without feature dict, everything maps to itself - assert mapping["iou"] == "iou" - assert mapping["distance"] == "distance" - assert mapping["custom"] == "custom" - - def test_case_insensitive_matching(self): - """Test that matching is case-insensitive.""" - importable_props = ["IOU", "Distance"] - available_computed_features = { - "iou": {"feature_type": "edge", "display_name": "IOU"}, - "distance": {"feature_type": "edge", "display_name": "Distance"}, - } - - mapping = infer_edge_name_map(importable_props, available_computed_features) - - # Should fuzzy match despite case differences - assert mapping["iou"] == "IOU" - assert mapping["distance"] == "Distance" diff --git a/tests_old/import_export/test_tracks_builder.py b/tests_old/import_export/test_tracks_builder.py deleted file mode 100644 index 468f7792..00000000 --- a/tests_old/import_export/test_tracks_builder.py +++ /dev/null @@ -1,263 +0,0 @@ -"""Tests for TracksBuilder internals.""" - -import numpy as np - -from funtracks.import_export.csv._import import CSVTracksBuilder - - -class TestCombineMultiValueProps: - """Test _combine_props_from_name_map method.""" - - def test_combine_missing_arrays_with_or(self): - """Test that missing arrays are combined with OR logic. - - If any component column has a missing value for a row, the combined - property should also be marked as missing for that row. - """ - # Create a builder and set up minimal state - builder = CSVTracksBuilder() - builder.node_name_map = {"pos": ["y", "x"]} - - # Create in_memory_geff with missing arrays - # y is missing for nodes 0, 2 (indices where missing_y is True) - # x is missing for nodes 1, 2 (indices where missing_x is True) - # Combined should be missing for nodes 0, 1, 2 - builder.in_memory_geff = { - "metadata": None, - "node_ids": np.array([0, 1, 2, 3]), - "edge_ids": np.array([]).reshape(0, 2), - "node_props": { - "y": { - "values": np.array([1.0, 2.0, 3.0, 4.0]), - "missing": np.array([True, False, True, False]), - }, - "x": { - "values": np.array([10.0, 20.0, 30.0, 40.0]), - "missing": np.array([False, True, True, False]), - }, - }, - "edge_props": {}, - } - - # Call the method - builder._combine_multi_value_props( - builder.in_memory_geff["node_props"], builder.node_name_map - ) - - # Check that pos was created with combined values - assert "pos" in builder.in_memory_geff["node_props"] - pos_prop = builder.in_memory_geff["node_props"]["pos"] - - # Check values are stacked correctly - expected_values = np.array([[1.0, 10.0], [2.0, 20.0], [3.0, 30.0], [4.0, 40.0]]) - np.testing.assert_array_equal(pos_prop["values"], expected_values) - - # Check missing is OR of component missing arrays - # Node 0: y missing (T) OR x missing (F) = T - # Node 1: y missing (F) OR x missing (T) = T - # Node 2: y missing (T) OR x missing (T) = T - # Node 3: y missing (F) OR x missing (F) = F - expected_missing = np.array([True, True, True, False]) - np.testing.assert_array_equal(pos_prop["missing"], expected_missing) - - # Check individual columns were removed - assert "y" not in builder.in_memory_geff["node_props"] - assert "x" not in builder.in_memory_geff["node_props"] - - def test_combine_no_missing_arrays(self): - """Test combining when no component has missing arrays.""" - builder = CSVTracksBuilder() - builder.node_name_map = {"pos": ["y", "x"]} - - builder.in_memory_geff = { - "metadata": None, - "node_ids": np.array([0, 1, 2]), - "edge_ids": np.array([]).reshape(0, 2), - "node_props": { - "y": {"values": np.array([1.0, 2.0, 3.0]), "missing": None}, - "x": {"values": np.array([10.0, 20.0, 30.0]), "missing": None}, - }, - "edge_props": {}, - } - - builder._combine_multi_value_props( - builder.in_memory_geff["node_props"], builder.node_name_map - ) - - pos_prop = builder.in_memory_geff["node_props"]["pos"] - assert pos_prop["missing"] is None - - def test_combine_partial_missing_arrays(self): - """Test combining when only some components have missing arrays.""" - builder = CSVTracksBuilder() - builder.node_name_map = {"pos": ["y", "x"]} - - # Only y has a missing array, x does not - builder.in_memory_geff = { - "metadata": None, - "node_ids": np.array([0, 1, 2]), - "edge_ids": np.array([]).reshape(0, 2), - "node_props": { - "y": { - "values": np.array([1.0, 2.0, 3.0]), - "missing": np.array([True, False, False]), - }, - "x": {"values": np.array([10.0, 20.0, 30.0]), "missing": None}, - }, - "edge_props": {}, - } - - builder._combine_multi_value_props( - builder.in_memory_geff["node_props"], builder.node_name_map - ) - - pos_prop = builder.in_memory_geff["node_props"]["pos"] - # Should use the missing array from y (the only one with missing) - expected_missing = np.array([True, False, False]) - np.testing.assert_array_equal(pos_prop["missing"], expected_missing) - - def test_combine_edge_props_with_missing(self): - """Test combining edge properties with missing arrays.""" - builder = CSVTracksBuilder() - builder.edge_name_map = {"multi_edge_feat": ["a", "b"]} - - builder.in_memory_geff = { - "metadata": None, - "node_ids": np.array([0, 1, 2]), - "edge_ids": np.array([[0, 1], [1, 2]]), - "node_props": {}, - "edge_props": { - "a": { - "values": np.array([1.0, 2.0]), - "missing": np.array([True, False]), - }, - "b": { - "values": np.array([10.0, 20.0]), - "missing": np.array([False, True]), - }, - }, - } - - builder._combine_multi_value_props( - builder.in_memory_geff["edge_props"], builder.edge_name_map - ) - - edge_prop = builder.in_memory_geff["edge_props"]["multi_edge_feat"] - expected_values = np.array([[1.0, 10.0], [2.0, 20.0]]) - np.testing.assert_array_equal(edge_prop["values"], expected_values) - - # Edge 0: a missing (T) OR b missing (F) = T - # Edge 1: a missing (F) OR b missing (T) = T - expected_missing = np.array([True, True]) - np.testing.assert_array_equal(edge_prop["missing"], expected_missing) - - -class TestPreprocessNameMap: - """Test _preprocess_name_map method.""" - - def test_removes_none_values(self): - """Test that None values are removed from name maps.""" - builder = CSVTracksBuilder() - builder.node_name_map = { - "time": "t", - "pos": ["y", "x"], - "optional_feat": None, - } - builder.edge_name_map = {"iou": "overlap", "unused": None} - - builder._preprocess_name_map() - - assert "optional_feat" not in builder.node_name_map - assert "unused" not in builder.edge_name_map - assert builder.node_name_map == {"time": "t", "pos": ["y", "x"]} - assert builder.edge_name_map == {"iou": "overlap"} - - def test_removes_empty_lists(self): - """Test that empty lists are removed from name maps.""" - builder = CSVTracksBuilder() - builder.node_name_map = { - "time": "t", - "pos": ["y", "x"], - "empty_feat": [], - } - builder.edge_name_map = None - - builder._preprocess_name_map() - - assert "empty_feat" not in builder.node_name_map - - def test_legacy_separate_coordinates_2d(self): - """Test backward compatibility for separate x, y coordinate mappings.""" - builder = CSVTracksBuilder() - builder.node_name_map = { - "time": "t", - "x": "x_coord", - "y": "y_coord", - } - builder.edge_name_map = None - - builder._preprocess_name_map() - - # x and y should be converted to pos - assert "x" not in builder.node_name_map - assert "y" not in builder.node_name_map - assert "pos" in builder.node_name_map - # Order should be y, x (spatial order) - assert builder.node_name_map["pos"] == ["y_coord", "x_coord"] - - def test_legacy_separate_coordinates_3d(self): - """Test backward compatibility for separate x, y, z coordinate mappings.""" - builder = CSVTracksBuilder() - builder.node_name_map = { - "time": "t", - "x": "x_col", - "y": "y_col", - "z": "z_col", - } - builder.edge_name_map = None - - builder._preprocess_name_map() - - # x, y, z should be converted to pos - assert "x" not in builder.node_name_map - assert "y" not in builder.node_name_map - assert "z" not in builder.node_name_map - assert "pos" in builder.node_name_map - # Order should be z, y, x (spatial order) - assert builder.node_name_map["pos"] == ["z_col", "y_col", "x_col"] - - def test_legacy_coordinates_not_converted_if_pos_exists(self): - """Test that legacy coordinates are not converted if pos already exists.""" - builder = CSVTracksBuilder() - builder.node_name_map = { - "time": "t", - "pos": ["existing_y", "existing_x"], - "x": "x_coord", # Should be ignored - "y": "y_coord", # Should be ignored - } - builder.edge_name_map = None - - builder._preprocess_name_map() - - # pos should remain unchanged, x and y should be kept as-is - assert builder.node_name_map["pos"] == ["existing_y", "existing_x"] - # x and y are NOT removed when pos already exists - assert "x" in builder.node_name_map - assert "y" in builder.node_name_map - - def test_legacy_coordinates_with_none_values(self): - """Test that None coordinate values are handled correctly.""" - builder = CSVTracksBuilder() - builder.node_name_map = { - "time": "t", - "x": "x_coord", - "y": "y_coord", - "z": None, # z is None, should be skipped - } - builder.edge_name_map = None - - builder._preprocess_name_map() - - # Only y and x should be in pos (z was None) - assert builder.node_name_map["pos"] == ["y_coord", "x_coord"] - assert "z" not in builder.node_name_map diff --git a/tests_old/import_export/test_validation.py b/tests_old/import_export/test_validation.py deleted file mode 100644 index 7f8aadc3..00000000 --- a/tests_old/import_export/test_validation.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Tests for validation functions in funtracks.import_export._validation.""" - -import pytest - -from funtracks.import_export._validation import ( - validate_edge_name_map, - validate_node_name_map, -) - - -class TestValidateNodeNameMap: - """Test validate_node_name_map helper function.""" - - def test_valid_node_name_map(self): - """Test that a valid node name_map passes validation.""" - name_map = {"time": "t", "pos": ["y_coord", "x_coord"]} - importable_props = ["t", "x_coord", "y_coord", "area"] - required_features = ["time"] - - # Should not raise - validate_node_name_map(name_map, importable_props, required_features) - - def test_missing_required_feature(self): - """Test that missing required features raise ValueError. - - When a required feature like "time" is missing from name_map, - the None values check catches it first. - """ - name_map = {"pos": ["y_coord", "x_coord"]} # Missing "time" - importable_props = ["t", "x_coord", "y_coord"] - required_features = ["time"] - - with pytest.raises(ValueError, match="cannot contain None values"): - validate_node_name_map(name_map, importable_props, required_features) - - def test_missing_position(self): - """Test that missing position mapping raises ValueError.""" - name_map = {"time": "t"} # Missing "pos" - importable_props = ["t", "x_coord", "y_coord"] - required_features = ["time"] - - with pytest.raises(ValueError, match="must contain 'pos' mapping"): - validate_node_name_map(name_map, importable_props, required_features) - - def test_invalid_position_format(self): - """Test that position list with < 2 elements raises ValueError.""" - name_map = {"time": "t", "pos": ["x_coord"]} # pos list needs at least 2 - importable_props = ["t", "x_coord", "y_coord"] - required_features = ["time"] - - with pytest.raises(ValueError, match="at least 2 coordinate"): - validate_node_name_map(name_map, importable_props, required_features) - - def test_position_as_single_string(self): - """Test that position can be a single string (pre-stacked attribute).""" - name_map = {"time": "t", "pos": "position"} # pos as single stacked attr - importable_props = ["t", "position"] - required_features = ["time"] - - # Should not raise - single string is valid for pre-stacked position - validate_node_name_map(name_map, importable_props, required_features) - - def test_none_value_in_required_field(self): - """Test that None values in required fields raise ValueError.""" - name_map = {"time": None, "pos": ["y_coord", "x_coord"]} - importable_props = ["t", "y_coord", "x_coord"] - required_features = ["time"] - - with pytest.raises(ValueError, match="cannot contain None values"): - validate_node_name_map(name_map, importable_props, required_features) - - def test_duplicate_values_in_position(self): - """Test that duplicate values in position are allowed. - - Multiple position coords can map to the same source property (edge case). - """ - name_map = {"time": "t", "pos": ["coord", "coord"]} # Duplicate "coord" - importable_props = ["t", "coord"] - required_features = ["time"] - - # Should not raise - duplicates are allowed - validate_node_name_map(name_map, importable_props, required_features) - - def test_nonexistent_property(self): - """Test that mapping to non-existent properties raises ValueError.""" - name_map = {"time": "t", "pos": ["y_coord", "x_coord"]} - importable_props = ["t", "x_coord"] # "y_coord" doesn't exist - required_features = ["time"] - - with pytest.raises(ValueError, match="non-existent properties"): - validate_node_name_map(name_map, importable_props, required_features) - - -class TestValidateEdgeNameMap: - """Test validate_edge_name_map helper function.""" - - def test_valid_edge_name_map(self): - """Test that a valid edge name_map passes validation.""" - edge_name_map = {"iou": "overlap", "distance": "dist"} - importable_props = ["overlap", "dist", "weight"] - - # Should not raise - validate_edge_name_map(edge_name_map, importable_props) - - def test_nonexistent_edge_property(self): - """Test that mapping to non-existent edge properties raises ValueError.""" - edge_name_map = {"iou": "overlap", "distance": "dist"} - importable_props = ["overlap"] # "dist" doesn't exist - - with pytest.raises(ValueError, match="non-existent properties"): - validate_edge_name_map(edge_name_map, importable_props) - - def test_empty_importable_props(self): - """Test that empty importable_props list doesn't raise.""" - edge_name_map = {"iou": "overlap"} - importable_props = [] - - # Should not raise when importable_props is empty - validate_edge_name_map(edge_name_map, importable_props) diff --git a/tests_old/utils/test_zarr_compat.py b/tests_old/utils/test_zarr_compat.py deleted file mode 100644 index cea5474f..00000000 --- a/tests_old/utils/test_zarr_compat.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Tests for zarr v2/v3 compatibility utilities.""" - -import pytest -import zarr - -from funtracks.utils import ( - detect_zarr_spec_version, - get_store_path, - is_zarr_v3, - open_zarr_store, - remove_tilde, - setup_zarr_array, - setup_zarr_group, -) - - -class TestIsZarrV3: - def test_returns_bool(self): - result = is_zarr_v3() - assert isinstance(result, bool) - - def test_matches_version_string(self): - expected = zarr.__version__.startswith("3") - assert is_zarr_v3() == expected - - -class TestRemoveTilde: - def test_expands_tilde(self): - result = remove_tilde("~/test/path") - assert "~" not in str(result) - - def test_no_tilde_unchanged(self): - path = "/absolute/path/to/file" - result = remove_tilde(path) - assert str(result) == path - - -class TestDetectZarrSpecVersion: - def test_detect_v2_from_zgroup(self, tmp_path): - # Create a v2-style zarr with .zgroup - zarr_path = tmp_path / "test.zarr" - if is_zarr_v3(): - zarr.open_group(zarr_path, mode="w", zarr_format=2) - else: - zarr.open_group(zarr_path, mode="w") - - result = detect_zarr_spec_version(zarr_path) - assert result == 2 - - @pytest.mark.skipif(not is_zarr_v3(), reason="Requires zarr-python v3") - def test_detect_v3_from_zarr_json(self, tmp_path): - # Create a v3-style zarr with zarr.json - zarr_path = tmp_path / "test.zarr" - zarr.open_group(zarr_path, mode="w", zarr_format=3) - - result = detect_zarr_spec_version(zarr_path) - assert result == 3 - - def test_nonexistent_path_returns_none(self, tmp_path): - result = detect_zarr_spec_version(tmp_path / "nonexistent") - assert result is None - - -class TestSetupZarrGroup: - def test_creates_group(self, tmp_path): - zarr_path = tmp_path / "test.zarr" - group = setup_zarr_group(zarr_path, zarr_format=2, mode="w") - - assert isinstance(group, zarr.Group) - assert zarr_path.exists() - - def test_default_format_is_v2(self, tmp_path): - zarr_path = tmp_path / "test.zarr" - setup_zarr_group(zarr_path, mode="w") - - # Should be v2 format - assert detect_zarr_spec_version(zarr_path) == 2 - - @pytest.mark.skipif(not is_zarr_v3(), reason="Requires zarr-python v3") - def test_creates_v3_format(self, tmp_path): - zarr_path = tmp_path / "test.zarr" - setup_zarr_group(zarr_path, zarr_format=3, mode="w") - - assert detect_zarr_spec_version(zarr_path) == 3 - - @pytest.mark.skipif(is_zarr_v3(), reason="Only for zarr-python v2") - def test_v3_format_warns_on_zarr_v2(self, tmp_path): - zarr_path = tmp_path / "test.zarr" - with pytest.warns(UserWarning, match="zarr-python v2 does not support spec v3"): - setup_zarr_group(zarr_path, zarr_format=3, mode="w") - - -class TestSetupZarrArray: - def test_creates_array(self, tmp_path): - zarr_path = tmp_path / "test.zarr" - arr = setup_zarr_array( - zarr_path, - zarr_format=2, - shape=(10, 10), - dtype="int32", - ) - - assert isinstance(arr, zarr.Array) - assert arr.shape == (10, 10) - assert zarr_path.exists() - - def test_with_chunks(self, tmp_path): - zarr_path = tmp_path / "test.zarr" - arr = setup_zarr_array( - zarr_path, - zarr_format=2, - shape=(100, 100), - dtype="float64", - chunks=(10, 10), - ) - - assert arr.chunks == (10, 10) - - @pytest.mark.skipif(is_zarr_v3(), reason="Only for zarr-python v2") - def test_v3_format_warns_on_zarr_v2(self, tmp_path): - zarr_path = tmp_path / "test.zarr" - with pytest.warns(UserWarning, match="zarr-python v2 does not support spec v3"): - setup_zarr_array(zarr_path, zarr_format=3, shape=(10,), dtype="int32") - - -class TestOpenZarrStore: - def test_opens_existing_store(self, tmp_path): - # Create a zarr first - zarr_path = tmp_path / "test.zarr" - setup_zarr_group(zarr_path, mode="w") - - store = open_zarr_store(zarr_path) - assert store is not None - - def test_raises_on_nonexistent_path(self, tmp_path): - with pytest.raises(FileNotFoundError): - open_zarr_store(tmp_path / "nonexistent") - - def test_returns_correct_store_type(self, tmp_path): - zarr_path = tmp_path / "test.zarr" - setup_zarr_group(zarr_path, mode="w") - - store = open_zarr_store(zarr_path) - - if is_zarr_v3(): - assert isinstance(store, zarr.storage.LocalStore) - else: - assert isinstance(store, zarr.storage.FSStore) - - @pytest.mark.skipif(is_zarr_v3(), reason="Only for zarr-python v2") - def test_warns_opening_v3_with_zarr_v2(self, tmp_path): - # Create a fake v3 zarr by adding zarr.json - zarr_path = tmp_path / "test.zarr" - zarr_path.mkdir() - (zarr_path / "zarr.json").write_text("{}") - - with pytest.warns(UserWarning, match="zarr spec v3 file with zarr-python v2"): - open_zarr_store(zarr_path) - - -class TestGetStorePath: - def test_gets_path_from_store(self, tmp_path): - zarr_path = tmp_path / "test.zarr" - setup_zarr_group(zarr_path, mode="w") - - store = open_zarr_store(zarr_path) - result = get_store_path(store) - - assert result == zarr_path - - def test_raises_on_unknown_store_type(self): - class FakeStore: - pass - - with pytest.raises(ValueError, match="Cannot determine store path"): - get_store_path(FakeStore()) From 343de17eab1800c6f985bf312eb06a787fa708c6 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Tue, 1 Sep 2026 09:45:09 -0400 Subject: [PATCH 12/20] Move solution tracks deprecation tests to main tests --- tests/data_model/test_solution_tracks.py | 52 +++++++++++++++++++- tests_old/data_model/test_solution_tracks.py | 44 ----------------- 2 files changed, 51 insertions(+), 45 deletions(-) diff --git a/tests/data_model/test_solution_tracks.py b/tests/data_model/test_solution_tracks.py index 4f1afa81..9f8fac2c 100644 --- a/tests/data_model/test_solution_tracks.py +++ b/tests/data_model/test_solution_tracks.py @@ -1,8 +1,9 @@ import numpy as np import polars as pl +import pytest from funtracks.actions import AddNode -from funtracks.data_model import Tracks +from funtracks.data_model import SolutionTracks, Tracks from funtracks.import_export import export_to_csv from funtracks.user_actions import UserUpdateSegmentation from funtracks.utils.tracksdata_utils import ( @@ -62,6 +63,55 @@ def test_update_segmentation(graph_2d_with_segmentation): assert np.asarray(tracks.segmentation)[0, 50, 50] == 99 +def test_from_tracks_cls(graph_2d_with_segmentation): + """SolutionTracks.from_tracks is a deprecated shim kept for downstream code + (e.g. motile_tracker) that still constructs a SolutionTracks from a Tracks.""" + tracks = Tracks( + graph_2d_with_segmentation, + ndim=3, + pos_attr="POSITION", + time_attr="TIME", + tracklet_attr=track_attrs["tracklet_attr"], + scale=(2, 2, 2), + ) + with pytest.warns(DeprecationWarning, match="SolutionTracks.from_tracks"): + solution_tracks = SolutionTracks.from_tracks(tracks) + # from_tracks reuses the same segmentation instance. Assert identity rather + # than `==`: GraphArrayView.__eq__ is element-wise and returns an array, + # which makes a truthiness assert ambiguous. + assert solution_tracks.segmentation is tracks.segmentation + assert solution_tracks.features.time_key == tracks.features.time_key + assert solution_tracks.features.position_key == tracks.features.position_key + assert solution_tracks.scale == tracks.scale + assert solution_tracks.ndim == tracks.ndim + assert solution_tracks.get_node_attr(6, tracks.features.tracklet_key) == 5 + + +def test_from_tracks_cls_recompute(graph_2d_with_segmentation): + """A tracklet id still at the -1 sentinel forces from_tracks to recompute, + even though it otherwise reuses the source Tracks' existing track ids.""" + tracks = Tracks( + graph_2d_with_segmentation, + ndim=3, + pos_attr="POSITION", + time_attr="TIME", + tracklet_attr=track_attrs["tracklet_attr"], + scale=(2, 2, 2), + ) + # delete track id (default value -1) on one node triggers reassignment of + # track_ids even when recompute is False. + tracks.graph_full.update_node_attrs( + attrs={tracks.features.tracklet_key: [-1]}, node_ids=[1] + ) + with pytest.warns(DeprecationWarning, match="SolutionTracks.from_tracks"): + solution_tracks = SolutionTracks.from_tracks(tracks) + # should have reassigned new track_id to node 6 + assert solution_tracks.get_node_attr(6, solution_tracks.features.tracklet_key) == 4 + assert ( + solution_tracks.get_node_attr(1, solution_tracks.features.tracklet_key) == 1 + ) # still 1 + + def test_next_track_id_empty(): graph = create_empty_graph( node_attributes=["pos", "track_id"], diff --git a/tests_old/data_model/test_solution_tracks.py b/tests_old/data_model/test_solution_tracks.py index b930e4c2..8532e3c8 100644 --- a/tests_old/data_model/test_solution_tracks.py +++ b/tests_old/data_model/test_solution_tracks.py @@ -34,50 +34,6 @@ def test_next_track_id(graph_2d_with_track_id): assert tracks.get_next_track_id() == 11 -def test_from_tracks_cls(graph_2d_with_segmentation): - tracks = Tracks( - graph_2d_with_segmentation, - ndim=3, - pos_attr="POSITION", - time_attr="TIME", - tracklet_attr=track_attrs["tracklet_attr"], - scale=(2, 2, 2), - ) - solution_tracks = SolutionTracks.from_tracks(tracks) - # persistent-graph: from_tracks reconstructs a Tracks, so solution_tracks.graph is - # no longer the same object as tracks.graph (the rest of the shim still holds). - # assert solution_tracks.graph == tracks.graph - # from_tracks reuses the same segmentation instance. Assert identity rather - # than `==`: on newer tracksdata GraphArrayView.__eq__ is element-wise and - # returns an array, which makes a truthiness assert ambiguous. - assert solution_tracks.segmentation is tracks.segmentation - assert solution_tracks.features.time_key == tracks.features.time_key - assert solution_tracks.features.position_key == tracks.features.position_key - assert solution_tracks.scale == tracks.scale - assert solution_tracks.ndim == tracks.ndim - assert solution_tracks.get_node_attr(6, tracks.features.tracklet_key) == 5 - - -def test_from_tracks_cls_recompute(graph_2d_with_segmentation): - tracks = Tracks( - graph_2d_with_segmentation, - ndim=3, - pos_attr="POSITION", - time_attr="TIME", - tracklet_attr=track_attrs["tracklet_attr"], - scale=(2, 2, 2), - ) - # delete track id (default value -1) on one node triggers reassignment of - # track_ids even when recompute is False. - tracks.graph.nodes[1][tracks.features.tracklet_key] = -1 - solution_tracks = SolutionTracks.from_tracks(tracks) - # should have reassigned new track_id to node 6 - assert solution_tracks.get_node_attr(6, solution_tracks.features.tracklet_key) == 4 - assert ( - solution_tracks.get_node_attr(1, solution_tracks.features.tracklet_key) == 1 - ) # still 1 - - def test_update_segmentation(graph_2d_with_segmentation): tracks = SolutionTracks( graph_2d_with_segmentation, From 8a58c81ed6e914214b8cd155f1a0a744199feefe Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Tue, 1 Sep 2026 09:47:01 -0400 Subject: [PATCH 13/20] remove test old benchmarks --- tests_old/benchmarks/bench_candidate_graph.py | 70 ------------------- 1 file changed, 70 deletions(-) delete mode 100644 tests_old/benchmarks/bench_candidate_graph.py diff --git a/tests_old/benchmarks/bench_candidate_graph.py b/tests_old/benchmarks/bench_candidate_graph.py deleted file mode 100644 index 7d9d63db..00000000 --- a/tests_old/benchmarks/bench_candidate_graph.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Benchmarks for candidate graph generation using synthetic segmentation data. - -To diagnose regressions, we recommend running line-profiler locally as shown in -profile_candidate_graph.py -""" - -import numpy as np -import pytest -from skimage.draw import disk - -from funtracks.candidate_graph.compute_graph import compute_graph_from_seg -from funtracks.data_model import SolutionTracks - -NUM_FRAMES = 50 -FRAME_SHAPE = (700, 1100) -CELLS_PER_FRAME = 150 -MAX_EDGE_DISTANCE = 50.0 - - -def _generate_segmentation( - num_frames=NUM_FRAMES, - frame_shape=FRAME_SHAPE, - cells_per_frame=CELLS_PER_FRAME, - seed=42, -): - """Generate a synthetic segmentation array with random disks.""" - rng = np.random.default_rng(seed) - seg = np.zeros((num_frames, *frame_shape), dtype=np.uint16) - label = 1 - for t in range(num_frames): - for _ in range(cells_per_frame): - cy = rng.integers(20, frame_shape[0] - 20) - cx = rng.integers(20, frame_shape[1] - 20) - radius = rng.integers(10, 30) - rr, cc = disk((cy, cx), radius, shape=frame_shape) - seg[t, rr, cc] = label - label += 1 - return seg - - -@pytest.fixture(scope="module") -def seg_data(): - return _generate_segmentation() - - -def test_compute_graph_from_seg(benchmark, seg_data): - benchmark.pedantic( - compute_graph_from_seg, - args=(seg_data, MAX_EDGE_DISTANCE), - kwargs={"iou": True}, - rounds=1, - iterations=1, - ) - - -def test_graph_to_solution(benchmark, seg_data): - """Benchmark candidate graph -> SolutionTracks (tracklet/lineage assignment). - - Candidate-graph construction is benchmarked separately above and is built here in - (untimed) setup, so only the SolutionTracks construction -- dominated by - TrackAnnotator._assign_tracklet_ids and _assign_lineage_ids -- is measured. - """ - - def setup(): - # Fresh graph per round: SolutionTracks construction mutates the graph - # (adds tracklet/lineage IDs), so each measured call must start unannotated. - graph = compute_graph_from_seg(seg_data, MAX_EDGE_DISTANCE, iou=True) - return (graph,), {"time_attr": "t", "pos_attr": "pos"} - - benchmark.pedantic(SolutionTracks, setup=setup, rounds=1, iterations=1) From 7ce0245b38f2e60ccedf8640814b4bb847ad82f9 Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Tue, 1 Sep 2026 09:57:50 -0400 Subject: [PATCH 14/20] Explicitly test backward compatibility in main tests --- tests/data_model/test_solution_tracks.py | 18 ++++++++++++++++++ tests/data_model/test_tracks.py | 9 +++++++++ tests/utils/test_tracksdata_utils.py | 11 +++++++++++ 3 files changed, 38 insertions(+) diff --git a/tests/data_model/test_solution_tracks.py b/tests/data_model/test_solution_tracks.py index 9f8fac2c..a277eedc 100644 --- a/tests/data_model/test_solution_tracks.py +++ b/tests/data_model/test_solution_tracks.py @@ -14,6 +14,24 @@ track_attrs = {"time_attr": "t", "tracklet_attr": "track_id"} +def test_solution_tracks_construction_warns(graph_2d_with_track_id): + """SolutionTracks is a deprecated alias for Tracks, kept for downstream code + (e.g. motile_tracker) that still constructs one directly.""" + with pytest.warns(DeprecationWarning, match="SolutionTracks is deprecated"): + SolutionTracks(graph_2d_with_track_id, ndim=3, **track_attrs) + + +def test_tracks_accepts_graphview(graph_2d_with_track_id): + """v2 callers pass a GraphView; Tracks unwraps it to the root BaseGraph and + warns, rather than rejecting the old calling convention. (SolutionTracks + unwraps GraphView itself before delegating to Tracks, so this behavior is + only observable by calling Tracks directly.)""" + view = graph_2d_with_track_id.filter().subgraph() + with pytest.warns(DeprecationWarning, match="Passing a GraphView"): + tracks = Tracks(view, ndim=3, **track_attrs) + assert tracks.graph_full is graph_2d_with_track_id + + def test_recompute_track_ids(graph_2d_with_track_id): tracks = Tracks( graph_2d_with_track_id, diff --git a/tests/data_model/test_tracks.py b/tests/data_model/test_tracks.py index 39813610..3e798573 100644 --- a/tests/data_model/test_tracks.py +++ b/tests/data_model/test_tracks.py @@ -106,6 +106,15 @@ def test_nodes_edges(graph_2d_with_segmentation): } +def test_deprecated_graph_property_warns(graph_2d_with_segmentation): + """Tracks.graph is a deprecated alias for graph_solution, kept for downstream + code (e.g. motile_tracker) written against funtracks v2.""" + tracks = Tracks(graph_2d_with_segmentation, ndim=3, **track_attrs) + with pytest.warns(DeprecationWarning, match="Tracks.graph is deprecated"): + graph = tracks.graph + assert graph is tracks.graph_solution + + def test_predecessors_successors(graph_2d_with_segmentation): tracks = Tracks(graph_2d_with_segmentation, ndim=3, **track_attrs) assert tracks.predecessors(2) == [1] diff --git a/tests/utils/test_tracksdata_utils.py b/tests/utils/test_tracksdata_utils.py index e4eb0793..c26230f4 100644 --- a/tests/utils/test_tracksdata_utils.py +++ b/tests/utils/test_tracksdata_utils.py @@ -4,9 +4,11 @@ import numpy as np import pytest +import tracksdata as td from funtracks.utils.tracksdata_utils import ( create_empty_graph, + create_empty_graphview_graph, pixels_to_td_mask, td_mask_to_pixels, ) @@ -189,3 +191,12 @@ def test_create_empty_graph_with_solution_attr(): ) assert graph is not None + + +def test_create_empty_graphview_graph_warns(): + """create_empty_graphview_graph is a deprecated alias for create_empty_graph, + kept for downstream code (e.g. motile_tracker) that still expects a view back.""" + with pytest.warns(DeprecationWarning, match="create_empty_graphview_graph"): + view = create_empty_graphview_graph(node_attributes=["pos"], ndim=3) + + assert isinstance(view, td.graph.GraphView) From 8601eac9bfa76be3c2e2da63c750aeee9efbf4cf Mon Sep 17 00:00:00 2001 From: Caroline Malin-Mayor Date: Tue, 1 Sep 2026 10:14:09 -0400 Subject: [PATCH 15/20] Remove tests_old - all deprecated API is now tested in main tests --- .github/workflows/test.yml | 2 +- .pre-commit-config.yaml | 3 - pyproject.toml | 2 +- tests/candidate_graph/test_compute_graph.py | 4 + tests_old/README.md | 39 - tests_old/__init__.py | 2 - tests_old/actions/__init__.py | 1 - tests_old/actions/test_action_history.py | 62 - tests_old/actions/test_add_delete_edge.py | 231 ---- tests_old/actions/test_add_delete_nodes.py | 236 ---- tests_old/actions/test_base_action.py | 12 - tests_old/actions/test_update_node_attrs.py | 37 - tests_old/actions/test_update_node_segs.py | 79 -- tests_old/annotators/__init__.py | 2 - .../annotators/test_annotator_registry.py | 191 --- tests_old/annotators/test_edge_annotator.py | 160 --- .../annotators/test_regionprops_annotator.py | 203 --- tests_old/annotators/test_track_annotator.py | 313 ----- tests_old/candidate_graph/__init__.py | 0 .../candidate_graph/test_compute_graph.py | 196 --- tests_old/candidate_graph/test_iou.py | 52 - .../test_relabel_segmentation.py | 52 - tests_old/conftest.py | 524 -------- .../test_save_load_False_3_False_0/attrs.json | 1 - .../test_save_load_False_3_False_0/graph.json | 1 - .../test_save_load_False_3_True_0/attrs.json | 1 - .../test_save_load_False_3_True_0/graph.json | 1 - .../test_save_load_False_3_True_0/seg.npy | Bin 200128 -> 0 bytes .../test_save_load_False_4_False_0/attrs.json | 1 - .../test_save_load_False_4_False_0/graph.json | 1 - .../test_save_load_False_4_True_0/attrs.json | 1 - .../test_save_load_False_4_True_0/graph.json | 1 - .../test_save_load_False_4_True_0/seg.npy | Bin 20000128 -> 0 bytes .../test_save_load_True_3_False_0/attrs.json | 1 - .../test_save_load_True_3_False_0/graph.json | 1 - .../test_save_load_True_3_True_0/attrs.json | 1 - .../test_save_load_True_3_True_0/graph.json | 1 - .../test_save_load_True_3_True_0/seg.npy | Bin 200128 -> 0 bytes .../test_save_load_True_4_False_0/attrs.json | 1 - .../test_save_load_True_4_False_0/graph.json | 1 - .../test_save_load_True_4_True_0/attrs.json | 1 - .../test_save_load_True_4_True_0/graph.json | 1 - .../test_save_load_True_4_True_0/seg.npy | Bin 20000128 -> 0 bytes tests_old/data_model/__init__.py | 2 - tests_old/data_model/test_solution_tracks.py | 174 --- tests_old/data_model/test_tracks.py | 367 ----- tests_old/features/__init__.py | 2 - tests_old/import_export/__init__.py | 2 - tests_old/import_export/test_csv_export.py | 280 ---- tests_old/import_export/test_csv_import.py | 745 ----------- .../import_export/test_export_to_geff.py | 456 ------- .../import_export/test_import_from_geff.py | 1191 ----------------- .../import_export/test_import_segmentation.py | 135 -- .../import_export/test_internal_format.py | 134 -- .../import_export/test_solution_roundtrip.py | 78 -- tests_old/import_export/test_utils.py | 43 - tests_old/user_actions/__init__.py | 2 - .../user_actions/test_user_actions_force.py | 48 - .../user_actions/test_user_add_delete_edge.py | 138 -- .../user_actions/test_user_add_delete_node.py | 195 --- .../test_user_swap_predecessors.py | 116 -- .../test_user_update_node_attrs.py | 143 -- .../test_user_update_nodes_attrs.py | 102 -- .../test_user_update_segmentation.py | 291 ---- tests_old/utils/__init__.py | 2 - tests_old/utils/test_tracksdata_utils.py | 190 --- 66 files changed, 6 insertions(+), 7249 deletions(-) delete mode 100644 tests_old/README.md delete mode 100644 tests_old/__init__.py delete mode 100644 tests_old/actions/__init__.py delete mode 100644 tests_old/actions/test_action_history.py delete mode 100644 tests_old/actions/test_add_delete_edge.py delete mode 100644 tests_old/actions/test_add_delete_nodes.py delete mode 100644 tests_old/actions/test_base_action.py delete mode 100644 tests_old/actions/test_update_node_attrs.py delete mode 100644 tests_old/actions/test_update_node_segs.py delete mode 100644 tests_old/annotators/__init__.py delete mode 100644 tests_old/annotators/test_annotator_registry.py delete mode 100644 tests_old/annotators/test_edge_annotator.py delete mode 100644 tests_old/annotators/test_regionprops_annotator.py delete mode 100644 tests_old/annotators/test_track_annotator.py delete mode 100644 tests_old/candidate_graph/__init__.py delete mode 100644 tests_old/candidate_graph/test_compute_graph.py delete mode 100644 tests_old/candidate_graph/test_iou.py delete mode 100644 tests_old/candidate_graph/test_relabel_segmentation.py delete mode 100644 tests_old/conftest.py delete mode 100644 tests_old/data/format_v1/test_save_load_False_3_False_0/attrs.json delete mode 100644 tests_old/data/format_v1/test_save_load_False_3_False_0/graph.json delete mode 100644 tests_old/data/format_v1/test_save_load_False_3_True_0/attrs.json delete mode 100644 tests_old/data/format_v1/test_save_load_False_3_True_0/graph.json delete mode 100644 tests_old/data/format_v1/test_save_load_False_3_True_0/seg.npy delete mode 100644 tests_old/data/format_v1/test_save_load_False_4_False_0/attrs.json delete mode 100644 tests_old/data/format_v1/test_save_load_False_4_False_0/graph.json delete mode 100644 tests_old/data/format_v1/test_save_load_False_4_True_0/attrs.json delete mode 100644 tests_old/data/format_v1/test_save_load_False_4_True_0/graph.json delete mode 100644 tests_old/data/format_v1/test_save_load_False_4_True_0/seg.npy delete mode 100644 tests_old/data/format_v1/test_save_load_True_3_False_0/attrs.json delete mode 100644 tests_old/data/format_v1/test_save_load_True_3_False_0/graph.json delete mode 100644 tests_old/data/format_v1/test_save_load_True_3_True_0/attrs.json delete mode 100644 tests_old/data/format_v1/test_save_load_True_3_True_0/graph.json delete mode 100644 tests_old/data/format_v1/test_save_load_True_3_True_0/seg.npy delete mode 100644 tests_old/data/format_v1/test_save_load_True_4_False_0/attrs.json delete mode 100644 tests_old/data/format_v1/test_save_load_True_4_False_0/graph.json delete mode 100644 tests_old/data/format_v1/test_save_load_True_4_True_0/attrs.json delete mode 100644 tests_old/data/format_v1/test_save_load_True_4_True_0/graph.json delete mode 100644 tests_old/data/format_v1/test_save_load_True_4_True_0/seg.npy delete mode 100644 tests_old/data_model/__init__.py delete mode 100644 tests_old/data_model/test_solution_tracks.py delete mode 100644 tests_old/data_model/test_tracks.py delete mode 100644 tests_old/features/__init__.py delete mode 100644 tests_old/import_export/__init__.py delete mode 100644 tests_old/import_export/test_csv_export.py delete mode 100644 tests_old/import_export/test_csv_import.py delete mode 100644 tests_old/import_export/test_export_to_geff.py delete mode 100644 tests_old/import_export/test_import_from_geff.py delete mode 100644 tests_old/import_export/test_import_segmentation.py delete mode 100644 tests_old/import_export/test_internal_format.py delete mode 100644 tests_old/import_export/test_solution_roundtrip.py delete mode 100644 tests_old/import_export/test_utils.py delete mode 100644 tests_old/user_actions/__init__.py delete mode 100644 tests_old/user_actions/test_user_actions_force.py delete mode 100644 tests_old/user_actions/test_user_add_delete_edge.py delete mode 100644 tests_old/user_actions/test_user_add_delete_node.py delete mode 100644 tests_old/user_actions/test_user_swap_predecessors.py delete mode 100644 tests_old/user_actions/test_user_update_node_attrs.py delete mode 100644 tests_old/user_actions/test_user_update_nodes_attrs.py delete mode 100644 tests_old/user_actions/test_user_update_segmentation.py delete mode 100644 tests_old/utils/__init__.py delete mode 100644 tests_old/utils/test_tracksdata_utils.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ba5647f4..aa9ddcc9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,7 @@ jobs: - name: Test with pytest run: | - uv run pytest --color=yes --cov=funtracks --cov-report=xml --cov-report=term-missing tests tests_old + uv run pytest --color=yes --cov=funtracks --cov-report=xml --cov-report=term-missing tests - name: Coverage uses: codecov/codecov-action@v4 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5bccf11b..b9968cb5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,8 +1,5 @@ ci: autofix_prs: false -# tests_old/ is a frozen verbatim copy of the pre-persistent-graph suite (old -# SolutionTracks API). Exclude it from all hooks so it is never linted/reformatted. -exclude: ^tests_old/ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 diff --git a/pyproject.toml b/pyproject.toml index 1ee9ddae..deeec85e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,7 +90,7 @@ line-length = 90 target-version = "py310" fix = true src = ["src"] -extend-exclude = [".github", "tests_old"] # frozen old-API suite: don't lint/format +extend-exclude = [".github"] [tool.ruff.lint] select = [ diff --git a/tests/candidate_graph/test_compute_graph.py b/tests/candidate_graph/test_compute_graph.py index e52e4a01..21fa0fca 100644 --- a/tests/candidate_graph/test_compute_graph.py +++ b/tests/candidate_graph/test_compute_graph.py @@ -40,6 +40,8 @@ def test_graph_from_segmentation_2d(get_tracks, backend): # segmentation shape must be stored in graph metadata assert tuple(cand_graph.metadata["shape"]) == segmentation_2d.shape + # DEPRECATED: dual-written under the old key too, motile_tracker still reads this + assert tuple(cand_graph.metadata["segmentation_shape"]) == segmentation_2d.shape # Only adjacent frames are connected; nodes 5,6 at t=4 are isolated # because t=3 has no nodes (add_cand_edges only links frame → frame+1) @@ -89,6 +91,8 @@ def test_graph_from_segmentation_3d(get_tracks): # segmentation shape must be stored in graph metadata assert tuple(cand_graph.metadata["shape"]) == segmentation_3d.shape + # DEPRECATED: dual-written under the old key too, motile_tracker still reads this + assert tuple(cand_graph.metadata["segmentation_shape"]) == segmentation_3d.shape # Only adjacent frames connected; nodes 5,6 at t=4 isolated (gap at t=3) assert sorted(cand_graph.edge_list()) == [[1, 2], [1, 3], [2, 4], [3, 4]] diff --git a/tests_old/README.md b/tests_old/README.md deleted file mode 100644 index 97686d45..00000000 --- a/tests_old/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# `tests_old/` — frozen old-API backward-compatibility suite - -This is a **verbatim copy of the test suite as it existed on `main` before the -`persistent-graph` rework** (the old `SolutionTracks` / `tracks.graph` / -`is_solution` / `create_empty_graphview_graph` API). Its job is to run that **old API -against the new code**, proving the backward-compatibility shims still work -(`SolutionTracks`, the deprecated `Tracks.graph` property, the `GraphView`-accepting -`Tracks.__init__`, `create_empty_graphview_graph`, `UpdateTrackID`, and the geff -module-location re-exports). - -## Rules - -- **Frozen. Do not add or update tests here.** All new and changed tests go in - `tests/`, which is the single source of truth. This folder only ever shrinks - (skips) or gets deleted. -- Tests that exercised behavior **intentionally removed** in `persistent-graph` are - marked `@pytest.mark.skip` with a reason (e.g. `in_degree`/`out_degree`, - `load_v1_tracks(solution=)`, the `SolutionTracks`-only `TrackAnnotator`, - `from_tracks` graph-identity). A handful of tests had a one-line adaptation where - only an error-message string or a fixture idiom changed (see `git diff` vs `main`). -- The suite emits many `DeprecationWarning`s by design — that is the proof the old - paths run. They are silenced *within this folder only* by a - `pytest_collection_modifyitems` hook in `tests_old/conftest.py`. - -## Infra hooks wiring this folder in (revert these when deleting) - -- `.github/workflows/test.yml`: pytest runs `... tests tests_old` -- `justfile` `test` target: `... tests/ tests_old/` -- `pyproject.toml` `[tool.ruff] extend-exclude`: `tests_old` (not linted/formatted) -- `.pre-commit-config.yaml` top-level `exclude: ^tests_old/` (no hook touches it) - -## Deleting this suite (one commit, when the deprecation layer is removed) - -1. `rm -rf tests_old/` -2. Revert the four infra hooks listed above. -3. Remove the `src` deprecation shims: `src/funtracks/data_model/solution_tracks.py` - (+ its `__init__` export), the `Tracks.graph` property, the `GraphView` - `Tracks.__init__` path, `create_empty_graphview_graph`, `UpdateTrackID`, and the - `import_export/{export,import}_from_geff.py` re-export modules. diff --git a/tests_old/__init__.py b/tests_old/__init__.py deleted file mode 100644 index 818e0478..00000000 --- a/tests_old/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# This file makes the tests directory a Python package -# to support relative imports diff --git a/tests_old/actions/__init__.py b/tests_old/actions/__init__.py deleted file mode 100644 index 1b68a100..00000000 --- a/tests_old/actions/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# This file makes the tests/actions directory a Python package to support relative imports diff --git a/tests_old/actions/test_action_history.py b/tests_old/actions/test_action_history.py deleted file mode 100644 index 4365f22c..00000000 --- a/tests_old/actions/test_action_history.py +++ /dev/null @@ -1,62 +0,0 @@ -from funtracks.actions import AddNode -from funtracks.actions.action_history import ActionHistory -from funtracks.data_model import SolutionTracks -from funtracks.utils.tracksdata_utils import create_empty_graphview_graph - -# https://github.com/zaboople/klonk/blob/master/TheGURQ.md - - -def test_action_history(): - history = ActionHistory() - empty_graph = create_empty_graphview_graph( - node_attributes=["track_id", "pos"], - edge_attributes=[], - ) - tracks = SolutionTracks(empty_graph, ndim=3, tracklet_attr="track_id", time_attr="t") - pos = [0, 1] - action1 = AddNode(tracks, node=0, attributes={"t": 0, "pos": pos, "track_id": 1}) - - # empty history has no undo or redo - assert not history.undo() - assert not history.redo() - - # add an action to the history - history.add_new_action(action1) - # undo the action - assert history.undo() - assert tracks.graph.num_nodes() == 0 - assert len(history.undo_stack) == 1 - assert len(history.redo_stack) == 1 - assert history._undo_pointer == -1 - - # no more actions to undo - assert not history.undo() - - # redo the action - assert history.redo() - assert tracks.graph.num_nodes() == 1 - assert len(history.undo_stack) == 1 - assert len(history.redo_stack) == 0 - assert history._undo_pointer == 0 - - # no more actions to redo - assert not history.redo() - - # undo and then add new action - assert history.undo() - action2 = AddNode(tracks, node=10, attributes={"t": 10, "pos": pos, "track_id": 2}) - history.add_new_action(action2) - assert tracks.graph.num_nodes() == 1 - # there are 3 things on the stack: action1, action1's inverse, and action 2 - assert len(history.undo_stack) == 3 - assert len(history.redo_stack) == 0 - assert history._undo_pointer == 2 - - # undo back to after action 1 - assert history.undo() - assert history.undo() - assert tracks.graph.num_nodes() == 1 - - assert len(history.undo_stack) == 3 - assert len(history.redo_stack) == 2 - assert history._undo_pointer == 0 diff --git a/tests_old/actions/test_add_delete_edge.py b/tests_old/actions/test_add_delete_edge.py deleted file mode 100644 index c8fe51d5..00000000 --- a/tests_old/actions/test_add_delete_edge.py +++ /dev/null @@ -1,231 +0,0 @@ -import numpy as np -import pytest -from numpy.testing import assert_array_almost_equal -from polars.testing import assert_frame_equal - -from funtracks.actions import ( - ActionGroup, - AddEdge, - DeleteEdge, -) -from funtracks.data_model import SolutionTracks -from funtracks.features import FeatureDict, LineageID, Position, Time, TrackletID -from funtracks.utils.tracksdata_utils import create_empty_graphview_graph - -iou_key = "iou" - - -@pytest.mark.parametrize("ndim", [3, 4]) -@pytest.mark.parametrize("with_seg", [True, False]) -def test_add_delete_edges(get_tracks, ndim, with_seg): - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - reference_graph = tracks.graph - reference_seg = np.asarray(tracks.segmentation).copy() - - # Create an empty tracks with just nodes (no edges) - for edge in tracks.graph.edge_list(): - tracks.graph.remove_edge(*edge) - - edges = [(1, 2), (1, 3), (3, 4), (4, 5)] - - action = ActionGroup(tracks=tracks, actions=[AddEdge(tracks, edge) for edge in edges]) - - with pytest.raises(ValueError, match="Edge .* already exists in the solution"): - AddEdge(tracks, (1, 2)) - - # TODO: What if adding an edge that already exists? - # TODO: test all the edge cases, invalid operations, etc. for all actions - assert set(tracks.graph.node_ids()) == set(reference_graph.node_ids()) - assert_frame_equal( - tracks.graph.edge_attrs(), - reference_graph.edge_attrs(), - check_row_order=False, - check_column_order=False, - ) - if with_seg: - assert_array_almost_equal(tracks.segmentation, reference_seg) - - inverse = action.inverse() - - assert set(tracks.graph.edge_ids()) == set() - if tracks.segmentation is not None: - assert_array_almost_equal(tracks.segmentation, reference_seg) - - re_added = inverse.inverse() - assert set(tracks.graph.node_ids()) == set(reference_graph.node_ids()) - assert set(tracks.graph.edge_ids()) == set(reference_graph.edge_ids()) - assert sorted(tracks.graph.edge_list()) == sorted(reference_graph.edge_list()) - assert_frame_equal( - tracks.graph.edge_attrs(), - reference_graph.edge_attrs(), - check_row_order=False, - check_column_order=False, - ) - if with_seg: - assert_array_almost_equal(tracks.segmentation, reference_seg) - - # Regression: calling inverse.inverse() a second time must not raise - # ValueError about 'edge_id'. AddEdge._apply() used to mutate - # DeleteEdge.attributes via aliasing,corrupting it for subsequent - # inverse() calls (mirrors ActionHistory calling .inverse()on the same - # stored action twice: undo → redo → undo). re_added.inverse() resets - # graph state (edges absent); it uses fresh DeleteEdge objects so it does - # NOT trigger the bug. The second inverse.inverse() calls the SAME DeleteEdge - # objects again — that's where the corruption surfaces. - re_added.inverse() # reset state: edges absent (fresh objects, no bug here) - inverse.inverse() # same DeleteEdge objects called again — must not crash - assert set(tracks.graph.edge_ids()) == set(reference_graph.edge_ids()) - - -def test_add_edge_missing_endpoint(get_tracks): - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - with pytest.raises( - ValueError, match="Cannot add edge .*: endpoint .* not in solution" - ): - AddEdge(tracks, (10, 11)) - - -def test_delete_missing_edge(get_tracks): - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - with pytest.raises( - ValueError, match="Edge .* not in the graph, and cannot be removed" - ): - DeleteEdge(tracks, (10, 11)) - - -@pytest.mark.parametrize("ndim", [3, 4]) -@pytest.mark.parametrize("with_seg", [True, False]) -def test_custom_edge_attributes_preserved(get_tracks, ndim, with_seg): - """Test custom edge attributes preserved through add/delete/re-add cycles.""" - from funtracks.features import Feature - - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - # Register custom edge features so they get saved by DeleteEdge - custom_features = { - "edge_type": Feature( - feature_type="edge", - value_type="str", - num_values=1, - display_name="Edge Type", - default_value=None, - ), - "confidence": Feature( - feature_type="edge", - value_type="float", - num_values=1, - display_name="Confidence", - default_value=None, - ), - "weight": Feature( - feature_type="edge", - value_type="float", - num_values=1, - display_name="Weight", - default_value=None, - ), - } - for key, feature in custom_features.items(): - tracks.add_feature(key, feature) - - # Define custom edge attributes - custom_attrs = { - "edge_type": "division", - "confidence": 0.92, - "weight": 1.5, - } - - # Add an edge with custom attributes - edge = (1, 5) - action = AddEdge(tracks, edge, attributes=custom_attrs) - - # Verify all attributes are present after adding - assert tracks.graph.has_edge(*edge) - for key, value in custom_attrs.items(): - edge_id = tracks.graph.edge_id(*edge) - assert tracks.graph.edges[edge_id][key] == value, ( - f"Attribute {key} not set correctly on edge" - ) - - # Delete the edge - delete_action = action.inverse() - assert not tracks.graph.has_edge(*edge) - - # Re-add the edge by inverting the delete - delete_action.inverse() - assert tracks.graph.has_edge(*edge) - - # Verify all custom attributes are still present after re-adding - for key, value in custom_attrs.items(): - edge_id = tracks.graph.edge_id(*edge) - assert tracks.graph.edges[edge_id][key] == value, ( - f"Attribute {key} not preserved after delete/re-add cycle" - ) - - -def test_add_edge_with_unregistered_edge_attr(tmp_path): - """AddEdge must not crash when the graph has edge attrs absent from tracks.features. - - Reproduces the KeyError that occurs when a pre-built graph (e.g. from the motile - solver) carries edge attributes such as 'iou' or custom solver scores that were - written directly to the graph without going through tracks.add_feature(). - The existing tests are blind to this bug because the get_tracks fixture explicitly - registers 'iou' in the FeatureDict, keeping both registries in sync. - """ - db_path = str(tmp_path / "test.db") - - # Build a graph with "custom_score" on every edge. - # This mirrors what the motile solver does: it writes edge attributes directly - # to the graph without going through tracks.add_feature(). - graph = create_empty_graphview_graph( - node_attributes=["pos", "track_id", "lineage_id"], - edge_attributes=["custom_score"], - database=db_path, - position_attrs=["pos"], - ndim=3, - ) - - graph.bulk_add_nodes( - nodes=[ - { - "t": 0, - "pos": [10.0, 10.0], - "track_id": 1, - "lineage_id": 1, - "solution": True, - }, - { - "t": 1, - "pos": [11.0, 11.0], - "track_id": 2, - "lineage_id": 1, - "solution": True, - }, - ], - indices=[1, 2], - ) - - # Wrap in SolutionTracks without registering "custom_score" in features — - # this is the scenario that triggers the bug. - features = FeatureDict( - features={ - "t": Time(), - "pos": Position(axes=["y", "x"]), - "track_id": TrackletID(), - "lineage_id": LineageID(), - }, - time_key="t", - position_key="pos", - tracklet_key="track_id", - lineage_key="lineage_id", - ) - tracks = SolutionTracks(graph, ndim=3, features=features) - - # Sanity: "custom_score" is in the graph schema but NOT in tracks.features. - assert "custom_score" in tracks.graph.edge_attr_keys() - assert "custom_score" not in tracks.features - - # Before the fix this raises: KeyError: 'custom_score' - AddEdge(tracks, (1, 2)) - - assert tracks.graph.has_edge(1, 2) diff --git a/tests_old/actions/test_add_delete_nodes.py b/tests_old/actions/test_add_delete_nodes.py deleted file mode 100644 index daa7d706..00000000 --- a/tests_old/actions/test_add_delete_nodes.py +++ /dev/null @@ -1,236 +0,0 @@ -import numpy as np -import pytest -from numpy.testing import assert_array_almost_equal, assert_array_equal -from polars.testing import assert_frame_equal -from tracksdata.array import GraphArrayView - -from funtracks.actions import ( - ActionGroup, - AddNode, -) -from funtracks.utils.tracksdata_utils import ( - assert_node_attrs_equal_with_masks, - create_empty_graphview_graph, -) - -from ..conftest import make_2d_disk_mask, make_3d_sphere_mask - - -@pytest.mark.parametrize("ndim", [3, 4]) -@pytest.mark.parametrize("with_seg", [True, False]) -@pytest.mark.skip( - reason="old-API behavior removed in persistent-graph: Tracks.graph is now a " - "read-only deprecated property and can no longer be reassigned." -) -def test_add_delete_nodes(get_tracks, ndim, with_seg): - # Get a tracks instance - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - reference_graph = tracks.graph - reference_seg = np.asarray(tracks.segmentation).copy() if with_seg else None - - # Start with an empty Tracks - node_attributes = [ - tracks.features.time_key, - tracks.features.tracklet_key, - tracks.features.lineage_key, - tracks.features.position_key, - ] - edge_attributes = ["iou"] if with_seg else [] - empty_graph = create_empty_graphview_graph( - node_attributes=node_attributes + (["area", "bbox", "mask"] if with_seg else []), - edge_attributes=edge_attributes, - ndim=ndim, - ) - empty_seg = np.zeros_like(tracks.segmentation) if with_seg else None - tracks.graph = empty_graph - segmentation_shape = (5, 100, 100) if ndim == 3 else (5, 100, 100, 100) - tracks.segmentation = ( - GraphArrayView( - graph=tracks.graph, shape=segmentation_shape, attr_key="node_id", offset=0 - ) - if with_seg - else None - ) - - # add all the nodes from graph_2d/seg_2d - nodes = list(reference_graph.node_ids()) - - actions = [] - for node in nodes: - attrs = {} - attrs[tracks.features.time_key] = reference_graph.nodes[node][ - tracks.features.time_key - ] - if tracks.features.position_key == "pos": - attrs[tracks.features.position_key] = reference_graph.nodes[node][ - tracks.features.position_key - ].to_list() - else: - attrs[tracks.features.position_key] = reference_graph.nodes[node][ - tracks.features.position_key - ] - attrs[tracks.features.tracklet_key] = reference_graph.nodes[node][ - tracks.features.tracklet_key - ] - attrs[tracks.features.lineage_key] = reference_graph.nodes[node][ - tracks.features.lineage_key - ] - if with_seg: - attrs["bbox"] = reference_graph.nodes[node]["bbox"] - attrs["mask"] = reference_graph.nodes[node]["mask"] - - actions.append(AddNode(tracks, node, attributes=attrs)) - action = ActionGroup(tracks=tracks, actions=actions) - - assert set(tracks.graph.node_ids()) == set(reference_graph.node_ids()) - data_tracks = tracks.graph.node_attrs() - data_reference = reference_graph.node_attrs() - if with_seg: - assert_array_almost_equal(tracks.segmentation, reference_seg) - assert_node_attrs_equal_with_masks(data_tracks, data_reference) - else: - assert_frame_equal( - data_reference, # .drop(["mask", "bbox", "area"]), - data_tracks, # .drop(["mask", "bbox", "area"]), - check_column_order=False, - check_row_order=False, - check_dtypes=False, - ) - - # Invert the action to delete all the nodes - del_nodes = action.inverse() - assert set(tracks.graph.node_ids()) == set(empty_graph.node_ids()) - if with_seg: - assert_array_almost_equal(tracks.segmentation, empty_seg) - - # Re-invert the action to add back all the nodes and their attributes - del_nodes.inverse() - assert set(tracks.graph.node_ids()) == set(reference_graph.node_ids()) - data_tracks = tracks.graph.node_attrs() - data_reference = reference_graph.node_attrs() - if with_seg: - assert_array_almost_equal(tracks.segmentation, reference_seg) - assert_node_attrs_equal_with_masks(data_tracks, data_reference) - else: - assert_frame_equal( - data_reference, # .drop(["mask", "bbox", "area"]), - data_tracks, # .drop(["mask", "bbox", "area"]), - check_column_order=False, - check_row_order=False, - check_dtypes=False, - ) - - -def test_add_node_missing_time(get_tracks): - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - with pytest.raises(ValueError, match="Must provide a time attribute for node"): - AddNode(tracks, 8, {}) - - -def test_add_node_missing_pos(get_tracks): - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - # First test: missing track_id raises an error - with pytest.raises(ValueError, match="Must provide a track_id attribute for node"): - AddNode(tracks, 8, {"t": 2}) - - # Second test: with track_id but without segmentation, missing pos raises an error - tracks_no_seg = get_tracks(ndim=3, with_seg=False, is_solution=True) - with pytest.raises( - ValueError, match="Must provide position or segmentation for node" - ): - AddNode(tracks_no_seg, 8, {"t": 2, "track_id": 1}) - - -@pytest.mark.parametrize("ndim", [3, 4]) -@pytest.mark.parametrize("with_seg", [True, False]) -def test_custom_attributes_preserved(get_tracks, ndim, with_seg): - """Test custom node attributes preserved through add/delete/re-add cycles.""" - from funtracks.features import Feature - - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - # Register custom features so they get saved by DeleteNode - custom_features = { - "cell_type": Feature( - feature_type="node", - value_type="str", - num_values=1, - display_name="Cell Type", - default_value=None, - ), - "confidence": Feature( - feature_type="node", - value_type="float", - num_values=1, - display_name="Confidence", - default_value=None, - ), - "user_label": Feature( - feature_type="node", - value_type="str", - num_values=1, - display_name="User Label", - default_value=None, - ), - } - for key, feature in custom_features.items(): - tracks.add_feature(key, feature) - - # Define attributes including custom ones - custom_attrs = { - "t": 2, - "track_id": 10, - "pos": [50.0, 50.0] if ndim == 3 else [50.0, 50.0, 50.0], - # Custom user attributes - "cell_type": "neuron", - "confidence": 0.95, - "user_label": "important_cell", - } - - # Create segmentation if needed - if with_seg: - if ndim == 3: - seg_mask = make_2d_disk_mask(center=(50, 50), radius=5) - else: - seg_mask = make_3d_sphere_mask(center=(50, 50, 50), radius=5) - custom_attrs["mask"] = seg_mask - custom_attrs["bbox"] = seg_mask.bbox - custom_attrs.pop("pos") # pos will be computed from segmentation - - # Add a node with custom attributes - node_id = 100 - action = AddNode(tracks, node_id, custom_attrs.copy()) - # Verify all attributes are present after adding - assert tracks.graph.has_node(node_id) - for key, value in custom_attrs.items(): - if key == "pos": - assert_array_almost_equal(tracks.graph.nodes[node_id][key], np.array(value)) - elif key == "mask": - continue - elif key == "bbox": - assert_array_equal(np.asarray(tracks.graph.nodes[node_id][key]), value) - else: - assert tracks.graph.nodes[node_id][key] == value, ( - f"Attribute {key} not preserved after add" - ) - - # Delete the node - delete_action = action.inverse() - assert node_id not in tracks.graph.node_ids() - - # Re-add the node by inverting the delete - delete_action.inverse() - assert node_id in tracks.graph.node_ids() - - # Verify all custom attributes are still present after re-adding - for key, value in custom_attrs.items(): - if key == "pos": - assert_array_almost_equal(tracks.graph.nodes[node_id][key], np.array(value)) - elif key == "mask": - continue - elif key == "bbox": - assert_array_equal(np.asarray(tracks.graph.nodes[node_id][key]), value) - else: - assert tracks.graph.nodes[node_id][key] == value, ( - f"Attribute {key} not preserved after delete/re-add cycle" - ) diff --git a/tests_old/actions/test_base_action.py b/tests_old/actions/test_base_action.py deleted file mode 100644 index 9e4c0047..00000000 --- a/tests_old/actions/test_base_action.py +++ /dev/null @@ -1,12 +0,0 @@ -import pytest - -from funtracks.actions import ( - Action, -) - - -def test_initialize_base_class(get_tracks): - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - action = Action(tracks) - with pytest.raises(NotImplementedError): - action.inverse() diff --git a/tests_old/actions/test_update_node_attrs.py b/tests_old/actions/test_update_node_attrs.py deleted file mode 100644 index f027ead1..00000000 --- a/tests_old/actions/test_update_node_attrs.py +++ /dev/null @@ -1,37 +0,0 @@ -import pytest - -from funtracks.actions import ( - UpdateNodeAttrs, -) -from funtracks.features import Feature - - -@pytest.mark.parametrize("ndim", [3, 4]) -def test_update_node_attrs(get_tracks, ndim): - tracks = get_tracks(ndim=ndim, with_seg=True, is_solution=True) - node = 1 - - new_feature = Feature( - feature_type="node", - value_type="float", - num_values=1, - display_name="Score", - default_value=None, - ) - tracks.add_feature("score", new_feature) - - action = UpdateNodeAttrs(tracks, node, {"score": 1.0}) - assert tracks.get_node_attr(node, "score") == 1.0 - - inverse = action.inverse() - assert tracks.get_node_attr(node, "score") == -1.0 - - inverse.inverse() - assert tracks.get_node_attr(node, "score") == 1.0 - - -@pytest.mark.parametrize("attr", ["t", "area", "track_id"]) -def test_update_protected_attr(get_tracks, attr): - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - with pytest.raises(ValueError, match="Cannot update attribute .* manually"): - UpdateNodeAttrs(tracks, 1, {attr: 2}) diff --git a/tests_old/actions/test_update_node_segs.py b/tests_old/actions/test_update_node_segs.py deleted file mode 100644 index d3801e65..00000000 --- a/tests_old/actions/test_update_node_segs.py +++ /dev/null @@ -1,79 +0,0 @@ -import numpy as np -import pytest -from numpy.testing import assert_array_almost_equal -from polars.testing import assert_series_equal -from tracksdata.nodes import Mask - -from funtracks.actions import UpdateNodeSeg - - -@pytest.mark.parametrize("ndim", [3, 4]) -def test_update_node_segs(get_tracks, ndim): - # Get tracks with segmentation - tracks = get_tracks(ndim=ndim, with_seg=True, is_solution=True) - reference_graph = tracks.graph.detach().filter().subgraph() - - node = 1 - time = tracks.get_time(node) - - original_seg = np.asarray(tracks.segmentation).copy() - original_area = tracks.graph.nodes[1]["area"] - original_pos = tracks.graph.nodes[1]["pos"] - - # Add a couple pixels to the first node - new_seg = np.asarray(tracks.segmentation).copy() - if ndim == 3: - new_seg[time][0][0] = node - mask = Mask(np.ones((1, 1), dtype=bool), np.array([0, 0, 1, 1])) - else: - new_seg[time][0][0][0] = node - mask = Mask(np.ones((1, 1, 1), dtype=bool), np.array([0, 0, 0, 1, 1, 1])) - - action = UpdateNodeSeg(tracks, node, mask=mask, added=True) - - assert set(tracks.graph.node_ids()) == set(reference_graph.node_ids()) - assert tracks.graph.nodes[1]["area"] == original_area + 1 - assert not np.allclose(tracks.graph.nodes[1]["pos"], original_pos) - assert_array_almost_equal(tracks.segmentation, new_seg) - - inverse = action.inverse() - assert set(tracks.graph.node_ids()) == set(reference_graph.node_ids()) - assert_series_equal( - reference_graph.nodes[1]["pos"], - tracks.graph.nodes[1]["pos"], - ) - assert_array_almost_equal(tracks.segmentation, original_seg) - - inverse.inverse() - - assert set(tracks.graph.node_ids()) == set(reference_graph.node_ids()) - assert tracks.graph.nodes[1]["area"] == original_area + 1 - assert not np.allclose(tracks.graph.nodes[1]["pos"], original_pos) - assert_array_almost_equal(tracks.segmentation, new_seg) - - -def test_update_node_segs_erase_interior_pixel(get_tracks): - """Erasing a pixel *interior* to a node must clear it in the segmentation view. - - Node 1 is a disk centred at (50, 50); erasing its centre pixel does not shrink - the bbox, so it exercises exactly the bbox-unchanged path that regressed. - """ - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - node = 1 - time = tracks.get_time(node) - - original_bbox = np.asarray(tracks.graph.nodes[node]["bbox"]).copy() - original_area = tracks.graph.nodes[node]["area"] - # Precondition: the interior pixel currently belongs to the node. - assert int(np.asarray(tracks.segmentation[time, 50, 50])) == node - - # Erase the single interior pixel (50, 50). - erase_mask = Mask(np.ones((1, 1), dtype=bool), np.array([50, 50, 51, 51])) - UpdateNodeSeg(tracks, node, mask=erase_mask, added=False) - - # The bbox must be unchanged (the erased pixel was interior), which is the - # condition under which the stale-readback bug manifested. - assert np.array_equal(np.asarray(tracks.graph.nodes[node]["bbox"]), original_bbox) - assert tracks.graph.nodes[node]["area"] == original_area - 1 - # The segmentation view must reflect the erase. - assert int(np.asarray(tracks.segmentation[time, 50, 50])) == 0 diff --git a/tests_old/annotators/__init__.py b/tests_old/annotators/__init__.py deleted file mode 100644 index cb930077..00000000 --- a/tests_old/annotators/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# This file makes the tests/annotators directory a Python package -# to support relative imports diff --git a/tests_old/annotators/test_annotator_registry.py b/tests_old/annotators/test_annotator_registry.py deleted file mode 100644 index 8ae3232c..00000000 --- a/tests_old/annotators/test_annotator_registry.py +++ /dev/null @@ -1,191 +0,0 @@ -import pytest - -from funtracks.annotators import EdgeAnnotator, RegionpropsAnnotator, TrackAnnotator -from funtracks.data_model import SolutionTracks, Tracks - -track_attrs = {"time_attr": "t", "tracklet_attr": "track_id"} - - -@pytest.mark.skip( - reason="old-API behavior removed in persistent-graph: every Tracks now has a " - "TrackAnnotator (no SolutionTracks-only distinction)." -) -def test_annotator_registry_init_with_segmentation( - graph_2d_with_segmentation, -): - """Test AnnotatorRegistry initializes regionprops and edge annotators with - segmentation.""" - tracks = Tracks( - graph_2d_with_segmentation, - ndim=3, - **track_attrs, - ) - - annotator_types = [type(ann) for ann in tracks.annotators] - assert RegionpropsAnnotator in annotator_types - assert EdgeAnnotator in annotator_types - assert TrackAnnotator not in annotator_types # Not a SolutionTracks - - -@pytest.mark.skip( - reason="old-API behavior removed in persistent-graph: every Tracks now has a " - "TrackAnnotator (no SolutionTracks-only distinction)." -) -def test_annotator_registry_init_without_segmentation(graph_2d_with_position): - """Test AnnotatorRegistry doesn't create annotators without segmentation.""" - tracks = Tracks(graph_2d_with_position, ndim=3, **track_attrs) - - annotator_types = [type(ann) for ann in tracks.annotators] - assert RegionpropsAnnotator not in annotator_types - assert EdgeAnnotator not in annotator_types - assert TrackAnnotator not in annotator_types - - -def test_annotator_registry_init_solution_tracks( - graph_2d_with_segmentation, -): - """Test AnnotatorRegistry creates all annotators for SolutionTracks with - segmentation.""" - tracks = SolutionTracks( - graph_2d_with_segmentation, - ndim=3, - **track_attrs, - ) - - annotator_types = [type(ann) for ann in tracks.annotators] - assert RegionpropsAnnotator in annotator_types - assert EdgeAnnotator in annotator_types - assert TrackAnnotator in annotator_types - - -def test_enable_disable_features(graph_2d_with_segmentation): - tracks = Tracks( - graph_2d_with_segmentation, - ndim=3, - **track_attrs, - ) - - nodes = list(tracks.graph.node_ids()) - edges = list(tracks.graph.edge_ids()) - - # Core features (time, pos) should be in tracks.features and computed - assert "pos" in tracks.features - assert "t" in tracks.features - assert tracks.graph.nodes[nodes[0]]["pos"] is not None - - # area and other features should NOT be in tracks.features initially - assert "area" not in tracks.features - assert "iou" not in tracks.features - assert "circularity" not in tracks.features - - # Enable multiple features at once - tracks.enable_features(["area", "iou", "circularity"]) - - # Features should now be in FeatureDict - assert "iou" in tracks.features - assert "circularity" in tracks.features - - # Verify values are actually computed on the graph - assert tracks.graph.nodes[nodes[0]]["circularity"] is not None - if edges: - assert None not in tracks.graph.edge_attrs()["iou"].to_list() - - # Disable one feature - tracks.disable_features(["area"]) - - # area should be removed from FeatureDict - assert "area" not in tracks.features - assert "pos" in tracks.features - assert "iou" in tracks.features - assert "circularity" in tracks.features - - # Values no longer exist in the graph for tracksdata - # assert tracks.graph.nodes[1]["area"] is not None - - # Disable the remaining enabled features - tracks.disable_features(["pos", "iou", "circularity"]) - assert "pos" not in tracks.features - assert "iou" not in tracks.features - assert "circularity" not in tracks.features - - -def test_area_on_graph_not_auto_activated(graph_2d_with_segmentation): - """Area pre-populated on a raw graph must not be auto-activated. - - Lock in the behavior introduced when area was removed from the core - auto-detected features: even though the graph already carries area values, - Tracks(graph) without an explicit FeatureDict must leave area out of - tracks.features. Callers opt in via enable_features(["area"]). - """ - assert "area" in graph_2d_with_segmentation.node_attr_keys() - - tracks = Tracks(graph_2d_with_segmentation, ndim=3, **track_attrs) - - assert "area" not in tracks.features - assert "area" in tracks.annotators.all_features - - tracks.enable_features(["area"]) - assert "area" in tracks.features - - -def test_get_available_features(graph_2d_with_segmentation): - """Test get_available_features returns all features from all annotators.""" - tracks = SolutionTracks( - graph_2d_with_segmentation, - ndim=3, - **track_attrs, - ) - - available = tracks.get_available_features() - - # Should have features from all three annotators - assert "pos" in available # regionprops - assert "area" in available # regionprops - assert "iou" in available # edges - assert "track_id" in available # tracks - - -def test_enable_nonexistent_feature(graph_clean): - """Test enabling a nonexistent feature raises KeyError.""" - tracks = Tracks(graph_clean, ndim=3, **track_attrs) - - with pytest.raises(KeyError, match="Features not available"): - tracks.enable_features(["nonexistent"]) - - -def test_disable_nonexistent_feature(graph_clean): - """Test disabling a nonexistent feature raises KeyError.""" - tracks = Tracks(graph_clean, ndim=3, **track_attrs) - - with pytest.raises(KeyError, match="Features not available"): - tracks.disable_features(["nonexistent"]) - - -def test_compute_strict_validation(graph_2d_with_segmentation): - """Test that compute() strictly validates feature keys.""" - tracks = Tracks( - graph_2d_with_segmentation, - ndim=3, - **track_attrs, - ) - - # Get the RegionpropsAnnotator from the annotators - rp_ann = next( - ann for ann in tracks.annotators if isinstance(ann, RegionpropsAnnotator) - ) - - # Enable area first - tracks.enable_features(["area"]) - - # Valid feature key should work - rp_ann.compute(["area"]) - - # Invalid feature key should not raise KeyError - rp_ann.compute(["nonexistent_feature"]) - - # Disabled feature should not raise KeyError - tracks.disable_features(["area"]) - rp_ann.compute(["area"]) - - # None should still work (compute all enabled features) - rp_ann.compute() diff --git a/tests_old/annotators/test_edge_annotator.py b/tests_old/annotators/test_edge_annotator.py deleted file mode 100644 index cbef21c7..00000000 --- a/tests_old/annotators/test_edge_annotator.py +++ /dev/null @@ -1,160 +0,0 @@ -import numpy as np -import pytest -from tracksdata.nodes import Mask - -from funtracks.actions import UpdateNodeSeg, UpdateTrackIDs -from funtracks.annotators import EdgeAnnotator -from funtracks.data_model import SolutionTracks, Tracks - -track_attrs = {"time_attr": "t", "tracklet_attr": "track_id"} - - -@pytest.mark.parametrize("ndim", [3, 4]) -class TestEdgeAnnotator: - def test_init(self, get_graph, ndim): - # Start with clean graph, no existing features - graph = get_graph(ndim, with_seg=True) - tracks = Tracks( - graph, - ndim=ndim, - **track_attrs, - ) - ann = EdgeAnnotator(tracks) - # Features start disabled by default - assert len(ann.all_features) == 1 - assert len(ann.features) == 0 - # Enable features to test - ann.activate_features(list(ann.all_features.keys())) - assert len(ann.features) == 1 - - def test_compute_all(self, get_graph, ndim): - graph = get_graph(ndim, with_seg=True) - tracks = Tracks( - graph, - ndim=ndim, - **track_attrs, - ) - ann = EdgeAnnotator(tracks) - # Enable features - ann.activate_features(list(ann.all_features.keys())) - all_features = ann.features - - # Compute values - ann.compute() - for key in all_features: - assert key in tracks.graph.edge_attr_keys() - - def test_update_all(self, get_graph, ndim) -> None: - graph = get_graph(ndim, with_seg=True) - tracks = Tracks( - graph, - ndim=ndim, - **track_attrs, - ) # type: ignore - # Get the EdgeAnnotator from the registry - ann = next(ann for ann in tracks.annotators if isinstance(ann, EdgeAnnotator)) - # Enable features through tracks (which updates the registry) - tracks.enable_features(list(ann.all_features.keys())) - - node_id = 3 - edge_id = (1, 3) - - node_mask = tracks.get_mask(node_id) - assert node_mask is not None - # remove all but one pixel: copy the mask, set first True to False (keep it), - # remove the rest - new_mask = Mask(node_mask.mask.copy(), node_mask.bbox) - new_mask.mask.flat[np.argmax(new_mask.mask.flat)] = False - expected_iou = pytest.approx(0.0, abs=0.001) - - # Use UpdateNodeSeg action to modify segmentation and update edge - UpdateNodeSeg(tracks, node_id, new_mask, added=False) - assert tracks.get_edge_attr(edge_id, "iou") == expected_iou - - # segmentation is fully erased and you try to update - node_id = 1 - mask = tracks.get_mask(node_id) - assert mask is not None - with pytest.warns(match="Cannot find label 1 in frame .*"): - UpdateNodeSeg(tracks, node_id, mask, added=False) - - assert tracks.graph.edges[tracks.graph.edge_id(*edge_id)]["iou"] == 0 - - def test_add_remove_feature(self, get_graph, ndim): - graph = get_graph(ndim, with_seg=True) - tracks = Tracks( - graph, - ndim=ndim, - **track_attrs, - ) - # Get the EdgeAnnotator from the registry - ann = next(ann for ann in tracks.annotators if isinstance(ann, EdgeAnnotator)) - # Enable features through tracks - tracks.enable_features(list(ann.all_features.keys())) - - node_id = 3 - edge_id = (1, 3) - to_remove_key = next(iter(ann.features)) - - # remove the IOU from computation (tracks level) - tracks.disable_features([to_remove_key]) - node_mask = tracks.get_mask(node_id) - assert node_mask is not None - - # Compute at tracks level - this should not update the removed feature - for a in tracks.annotators: - if isinstance(a, EdgeAnnotator): - a.compute() - # IoU feature was deleted, so IoU is no longer present on the graph - # assert tracks.get_edge_attr(edge_id, to_remove_key) == orig_iou - - # add it back in - tracks.enable_features([to_remove_key]) - # remove all but one pixel - removal = Mask(node_mask.mask.copy(), node_mask.bbox) - removal.mask.flat[np.argmax(removal.mask.flat)] = False - UpdateNodeSeg(tracks, node_id, removal, added=False) - new_iou = pytest.approx(0.0, abs=0.001) - # the feature is now updated - assert tracks.get_edge_attr(edge_id, to_remove_key) == new_iou - - def test_missing_seg(self, get_graph, ndim) -> None: - """Test that EdgeAnnotator gracefully handles missing segmentation.""" - graph = get_graph(ndim, with_seg=False) - tracks = Tracks(graph, ndim=ndim, **track_attrs) # type: ignore - - ann = EdgeAnnotator(tracks) - assert len(ann.features) == 0 - # Should not raise an error, just return silently - ann.compute() # No error expected - - def test_ignores_irrelevant_actions(self, get_graph, ndim): - """Test that EdgeAnnotator ignores actions that don't affect edges.""" - graph = get_graph(ndim, is_solution=True, with_seg=True) - tracks = SolutionTracks( - graph, - ndim=ndim, - **track_attrs, - ) - tracks.enable_features(["iou", track_attrs["tracklet_attr"]]) - - node_id = 3 - edge = (1, 3) - edge_id = tracks.graph.edge_id(*edge) - initial_iou = tracks.get_edge_attr(edge, "iou") - - # If we recomputed IoU now, it would be different - # But we won't - we'll just call UpdateTrackIDs on node 1 - - # UpdateTrackIDs should not trigger edge update - node_id = 1 - original_track_id = tracks.get_track_id(node_id) - new_track_id = original_track_id + 100 - - # Perform UpdateTrackIDs action - UpdateTrackIDs(tracks, node_id, new_track_id) - - # IoU should remain unchanged (no recomputation happened despite seg change) - assert tracks.graph.edges[edge_id]["iou"] == initial_iou - # But track_id should be updated - assert tracks.get_track_id(node_id) == new_track_id diff --git a/tests_old/annotators/test_regionprops_annotator.py b/tests_old/annotators/test_regionprops_annotator.py deleted file mode 100644 index 3193dd06..00000000 --- a/tests_old/annotators/test_regionprops_annotator.py +++ /dev/null @@ -1,203 +0,0 @@ -import numpy as np -import pytest -from tracksdata.nodes import Mask - -from funtracks.actions import UpdateNodeSeg, UpdateTrackIDs -from funtracks.annotators import RegionpropsAnnotator -from funtracks.data_model import SolutionTracks, Tracks - -track_attrs = {"time_attr": "t", "tracklet_attr": "track_id"} - - -@pytest.mark.parametrize("ndim", [3, 4]) -class TestRegionpropsAnnotator: - def test_init(self, get_graph, ndim): - graph = get_graph(ndim, with_seg=True) - tracks = Tracks( - graph, - ndim=ndim, - **track_attrs, - ) - rp_ann = RegionpropsAnnotator(tracks) - # Features start disabled by default - assert len(rp_ann.all_features) == 5 - assert len(rp_ann.features) == 0 - # Enable features - rp_ann.activate_features(list(rp_ann.all_features.keys())) - assert ( - len(rp_ann.features) == 5 - ) # pos, area, ellipse_axis_radii, circularity, perimeter - - def test_compute_all(self, get_graph, ndim): - graph = get_graph(ndim, with_seg=True) - tracks = Tracks( - graph, - ndim=ndim, - **track_attrs, - ) - rp_ann = RegionpropsAnnotator(tracks) - tracks.enable_features(list(rp_ann.all_features.keys())) - - for key in rp_ann.all_features: - assert key in tracks.graph.node_attr_keys() - for node_id in tracks.graph.node_ids(): - value = tracks.graph.nodes[node_id][key] - assert value is not None - - def test_update_all(self, get_graph, ndim): - graph = get_graph(ndim, with_seg=True) - tracks = Tracks( - graph, - ndim=ndim, - **track_attrs, - ) - node_id = 3 - - # Get the RegionpropsAnnotator from the registry - rp_ann = next( - ann for ann in tracks.annotators if isinstance(ann, RegionpropsAnnotator) - ) - # Enable features through tracks - tracks.enable_features(list(rp_ann.all_features.keys())) - - node_mask = tracks.get_mask(node_id) - removal = Mask(node_mask.mask.copy(), node_mask.bbox) - removal.mask.flat[np.argmax(removal.mask.flat)] = False - expected_area = 1 - - # Use UpdateNodeSeg action to modify segmentation and update features - UpdateNodeSeg(tracks, node_id, removal, added=False) - assert tracks.get_node_attr(node_id, "area") == expected_area - for key in rp_ann.features: - assert key in tracks.graph.node_attr_keys() - - # segmentation is fully erased and you try to update - node_id = 1 - mask = tracks.get_mask(node_id) - with pytest.warns( - match="Cannot find label 1 in frame .*: updating regionprops values to None" - ): - UpdateNodeSeg(tracks, node_id, mask, added=False) - # all regionprops features should be the defaults, because seg doesn't exist - for key in rp_ann.features: - actual = tracks.graph.nodes[node_id][key] - expected = tracks.graph._node_attr_schemas()[key].default_value - # Convert to numpy arrays for comparison (handles both scalar and array types) - actual_np = np.asarray(actual) - expected_np = np.asarray(expected) - assert np.array_equal(actual_np, expected_np) - - def test_add_remove_feature(self, get_graph, ndim): - graph = get_graph(ndim, with_seg=True) - tracks = Tracks( - graph, - ndim=ndim, - **track_attrs, - ) - # Get the RegionpropsAnnotator from the registry - rp_ann = next( - ann for ann in tracks.annotators if isinstance(ann, RegionpropsAnnotator) - ) - all_feature_keys = list(rp_ann.all_features.keys()) - to_remove_key = all_feature_keys[1] # area - # area is not auto-enabled, so enable it first before testing disable - tracks.enable_features([to_remove_key]) - tracks.disable_features([to_remove_key]) - - rp_ann.compute() - assert to_remove_key not in tracks.graph.node_attr_keys() - - # add it back in - tracks.enable_features([to_remove_key]) - # but remove a different one - second_remove_key = all_feature_keys[2] # ellipse_axis_radii - tracks.disable_features([second_remove_key]) - - # remove all but one pixel - node_id = 3 - node_mask = tracks.get_mask(node_id) - assert node_mask is not None - removal = Mask(node_mask.mask.copy(), node_mask.bbox) - removal.mask.flat[np.argmax(removal.mask.flat)] = False - # Use UpdateNodeSeg action to modify segmentation and update features - UpdateNodeSeg(tracks, node_id, removal, added=False) - # the one we added back in is now present - assert tracks.get_node_attr(node_id, to_remove_key) is not None - - def test_missing_seg(self, get_graph, ndim): - """Test that RegionpropsAnnotator gracefully handles missing segmentation.""" - graph = get_graph(ndim, with_seg=False) - tracks = Tracks(graph, ndim=ndim, **track_attrs) - rp_ann = RegionpropsAnnotator(tracks) - assert len(rp_ann.features) == 0 - # Should not raise an error, just return silently - rp_ann.compute() # No error expected - - def test_centroid_world_coords_with_scale(self, get_graph, ndim): - """Centroid in 'pos' must be pixel_centroid * scale (world units). - - Without the fix, skimage returns local_centroid * spacing + bbox_min_pixel - (mixed units). The correct formula is (local_centroid + bbox_min_pixel) * - spacing = pixel_centroid * spacing. - - Node 6 has a cube/square at corner (96, 96, ...) with width 4, - so pixel centroid = 97.5 in each spatial axis. - """ - graph = get_graph(ndim, with_seg=True) - if ndim == 3: - scale = [1.0, 2.0, 3.0] - pixel_centroid = np.array([97.5, 97.5]) - else: - scale = [1.0, 2.0, 3.0, 4.0] - pixel_centroid = np.array([97.5, 97.5, 97.5]) - - tracks = Tracks(graph, ndim=ndim, scale=scale, **track_attrs) - # Force recomputation so regionprops runs with the given scale as spacing - tracks.enable_features(["pos"]) - - pos = np.array(tracks.graph.nodes[6]["pos"]) - expected = pixel_centroid * np.array(scale[1:]) - - bug_value = np.array([1.5] * len(pixel_centroid)) * np.array( - scale[1:] - ) + np.array([96.0] * len(pixel_centroid)) - np.testing.assert_allclose( - pos, - expected, - atol=0.1, - err_msg=( - f"World centroid must be pixel_centroid * scale. " - f"Got {pos}, expected {expected}. " - f"Bug value would be local_centroid * scale + bbox_min = {bug_value}" - ), - ) - - def test_ignores_irrelevant_actions(self, get_graph, ndim): - """Test that RegionpropsAnnotator ignores actions that don't affect - segmentation. - """ - graph = get_graph(ndim, is_solution=True, with_seg=True) - tracks = SolutionTracks( - graph, - ndim=ndim, - **track_attrs, - ) - tracks.enable_features(["area", "track_id"]) - - node_id = 1 - initial_area = tracks.get_node_attr(node_id, "area") - - # Make the stored area stale by writing a fake value directly to the graph, - # bypassing the action system. If UpdateTrackIDs incorrectly triggers - # RegionpropsAnnotator, it would recompute area back to initial_area and - # the assertion below would fail. - fake_area = initial_area + 999 - tracks.graph.update_node_attrs(attrs={"area": [fake_area]}, node_ids=[node_id]) - - original_track_id = tracks.get_track_id(node_id) - new_track_id = original_track_id + 100 - - UpdateTrackIDs(tracks, node_id, new_track_id) - - assert tracks.get_node_attr(node_id, "area") == fake_area - assert tracks.get_track_id(node_id) == new_track_id diff --git a/tests_old/annotators/test_track_annotator.py b/tests_old/annotators/test_track_annotator.py deleted file mode 100644 index 9c7b1837..00000000 --- a/tests_old/annotators/test_track_annotator.py +++ /dev/null @@ -1,313 +0,0 @@ -import numpy as np -import pytest -from tracksdata.nodes import Mask - -from funtracks.actions import UpdateNodeSeg -from funtracks.annotators import TrackAnnotator -from funtracks.user_actions import ( - UserAddEdge, - UserAddNode, - UserDeleteEdge, - UserDeleteNode, -) - - -@pytest.mark.parametrize("ndim", [3, 4]) -@pytest.mark.parametrize("with_seg", [True, False]) -class TestTrackAnnotator: - def test_init(self, get_tracks, ndim, with_seg) -> None: - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - ann = TrackAnnotator(tracks) - # Features start disabled by default - assert len(ann.all_features) == 2 - assert len(ann.features) == 0 - assert len(ann.lineage_id_to_nodes) == 2 - - ann = TrackAnnotator(tracks, tracklet_key="track_id") - assert len(ann.all_features) == 2 - assert len(ann.features) == 0 - assert len(ann.lineage_id_to_nodes) == 2 - assert len(ann.tracklet_id_to_nodes) == 4 - assert ann.max_lineage_id == 2 - assert ann.max_tracklet_id == 5 - - def test_compute_all(self, get_tracks, ndim, with_seg) -> None: - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - ann = TrackAnnotator(tracks, tracklet_key=tracks.features.tracklet_key) - # Enable features - ann.activate_features(list(ann.all_features.keys())) - all_features = ann.features - - # Compute values - ann.compute() - for node in tracks.graph.node_ids(): - for key in all_features: - assert tracks.graph.nodes[node][key] is not None - - lineages = [ - [1, 2, 3, 4, 5], - [6], - ] - tracklets = [ - [1], - [2], - [3, 4, 5], - [6], - ] - for components, key in zip( - [lineages, tracklets], [ann.lineage_key, ann.tracklet_key], strict=True - ): - # one unique id per component - id_sets = [ - list(set(tracks.get_nodes_attr(component, key))) - for component in components - ] - for id_set in id_sets: - assert len(id_set) == 1 - # no shared ids across components - assert len({id_set[0] for id_set in id_sets}) == len(id_sets) - - def test_add_remove_feature(self, get_tracks, ndim, with_seg): - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - ann = TrackAnnotator(tracks, tracklet_key=tracks.features.tracklet_key) - # Enable features - ann.activate_features(list(ann.all_features.keys())) - # compute the original tracklet and lineage ids - ann.compute() - # add an edge - node_id = 6 - edge_id = (4, 6) - attrs = {"iou": 0, "solution": True} if with_seg else {"solution": True} - tracks.graph.add_edge(source_id=edge_id[0], target_id=edge_id[1], attrs=attrs) - to_remove_key = ann.lineage_key - orig_lin = tracks.get_node_attr(node_id, ann.lineage_key) - orig_tra = tracks.get_node_attr(node_id, ann.tracklet_key) - - # remove one feature from computation (annotator level, not FeatureDict) - ann.deactivate_features([to_remove_key]) - ann.compute() # this should update tra but not lin - # lineage_id is still in tracks.features but not recomputed - assert tracks.get_node_attr(node_id, ann.lineage_key) == orig_lin - assert tracks.get_node_attr(node_id, ann.tracklet_key) != orig_tra - - # add it back in - ann.activate_features([to_remove_key]) - ann.compute() - # now both are updated - assert tracks.get_node_attr(node_id, ann.lineage_key) != orig_lin - assert tracks.get_node_attr(node_id, ann.tracklet_key) != orig_tra - - @pytest.mark.skip( - reason="old-API behavior removed in persistent-graph: TrackAnnotator is no " - "longer restricted to SolutionTracks; every Tracks has one." - ) - def test_invalid(self, get_tracks, ndim, with_seg) -> None: - # Create regular Tracks (not SolutionTracks) to test error handling - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=False) - with pytest.raises( - ValueError, match="Currently the TrackAnnotator only works on SolutionTracks" - ): - TrackAnnotator(tracks) # type: ignore - - def test_ignores_irrelevant_actions(self, get_tracks, ndim, with_seg): - """Test that TrackAnnotator ignores actions that don't affect track IDs.""" - if not with_seg: - pytest.skip("Test requires segmentation") - - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - tracks.enable_features(["area", tracks.features.tracklet_key]) - - node_id = 3 - initial_track_id = tracks.get_track_id(node_id) - - # UpdateNodeSeg should not trigger track ID update - node_mask = tracks.get_mask(node_id) - removal = Mask(node_mask.mask.copy(), node_mask.bbox) - removal.mask.flat[np.argmax(removal.mask.flat)] = False - - # Perform UpdateNodeSeg action - UpdateNodeSeg(tracks, node_id, removal, added=False) - - # Track ID should remain unchanged (no track update happened) - assert tracks.get_track_id(node_id) == initial_track_id - # But area should be updated - assert tracks.get_node_attr(node_id, "area") == 1 - - def test_lineage_id_updated_on_add_and_delete_edge( - self, get_tracks, ndim, with_seg - ) -> None: - tracks = get_tracks(ndim=3, with_seg=False, is_solution=True) - tracks.enable_features(["lineage_id"]) - - # get the existing TrackAnnotator - ann = next(a for a in tracks.annotators if isinstance(a, TrackAnnotator)) - - # ---- UserAddEdge: merge lineages ---- - source_node = 2 - target_node = 6 - UserAddEdge(tracks, edge=(source_node, target_node)) - - # Assert target component adopts source lineage id - assert tracks.get_node_attr(target_node, ann.lineage_key) == tracks.get_node_attr( - source_node, ann.lineage_key - ) - assert set(ann.lineage_id_to_nodes[1]) == {1, 2, 3, 4, 5, 6} - assert 2 not in ann.lineage_id_to_nodes - - # ---- UserDeleteEdge: split lineage ---- - source_node = 3 - target_node = 4 - - edge = next(e for e in tracks.graph.edge_list() if set(e) == {3, 4}) - - expected_lineage_id = ann.max_lineage_id + 1 - UserDeleteEdge(tracks, edge=edge) - - # Assert target component gets a new lineage id - component = [4, 5] - for node in component: - assert tracks.get_node_attr(node, ann.lineage_key) == expected_lineage_id - - # Assert source component keeps original lineage id - component = [1, 3, 2, 6] - for node in component: - assert tracks.get_node_attr(node, ann.lineage_key) == tracks.get_node_attr( - source_node, ann.lineage_key - ) - - assert set(ann.lineage_id_to_nodes[1]) == {1, 2, 3, 6} - assert set(ann.lineage_id_to_nodes[expected_lineage_id]) == {4, 5} - - # ---- Add a node with existing track id ---- - # After the split, only node 3 has track_id=3, and it has lineage_id=1 - # (nodes 4,5 got new track_id=6 and lineage_id=3) - attrs = {"pos": ([5, 8]), tracks.features.time_key: (5), "track_id": (3)} - UserAddNode(tracks, node=7, attributes=attrs) - - # Assert new node adopts lineage of existing track (track_id=3 -> lineage_id=1) - assert tracks.get_node_attr(7, ann.lineage_key) == 1 - assert 7 in ann.lineage_id_to_nodes[1] - - # ---- Add a node with a new track id ---- - attrs = {"pos": ([5, 8]), tracks.features.time_key: (5), "track_id": (4)} - expected_lineage_id = ann.max_lineage_id + 1 - UserAddNode(tracks, node=8, attributes=attrs) - - # Assert new node adopts a new lineage id - assert tracks.get_node_attr(8, ann.lineage_key) == expected_lineage_id - assert 8 in ann.lineage_id_to_nodes[expected_lineage_id] - - # ---- Ensure that deleting a node updates lineage bookkeeping ---- - UserDeleteNode(tracks, node=8) - assert expected_lineage_id not in ann.lineage_id_to_nodes # whole list removed - - def test_lineage_id_updated_on_division(self, get_tracks, ndim, with_seg) -> None: - """Test that creating a division correctly updates track and lineage IDs. - - When adding edge (4, 6) to create a division at node 4: - - Existing child (5): gets a new track_id, keeps same lineage_id - - New child (6): keeps same track_id, gets source's lineage_id - """ - # Graph structure: 1 → 2, 1 → 3 → 4 → 5, and 6 (separate) - tracks = get_tracks(ndim=3, with_seg=False, is_solution=True) - tracks.enable_features(["lineage_id"]) - ann = next(a for a in tracks.annotators if isinstance(a, TrackAnnotator)) - - source_node = 4 - existing_child = 5 - target_node = 6 - source_lineage = tracks.get_node_attr(source_node, ann.lineage_key) - target_lineage_before = tracks.get_node_attr(target_node, ann.lineage_key) - existing_child_track_id_before = tracks.get_track_id(existing_child) - existing_child_lineage_id_before = tracks.get_lineage_id(existing_child) - target_track_id_before = tracks.get_track_id(target_node) - assert source_lineage != target_lineage_before - - action = UserAddEdge(tracks, edge=(source_node, target_node)) - - # Existing child (5): new track_id, same lineage_id - assert tracks.get_track_id(existing_child) != existing_child_track_id_before - assert tracks.get_node_attr(existing_child, ann.lineage_key) == source_lineage - - # New child (6): same track_id, source's lineage_id - assert tracks.get_track_id(target_node) == target_track_id_before - assert tracks.get_node_attr(target_node, ann.lineage_key) == source_lineage - - # All nodes in the lineage tree should share the same lineage id - for node in [1, 2, 3, 4, 5, 6]: - assert tracks.get_node_attr(node, ann.lineage_key) == source_lineage - - # Undo should restore original IDs - action.inverse() - assert tracks.get_track_id(existing_child) == existing_child_track_id_before - assert tracks.get_track_id(target_node) == target_track_id_before - assert tracks.get_node_attr(target_node, ann.lineage_key) == target_lineage_before - assert tracks.get_node_attr(source_node, ann.lineage_key) == source_lineage - assert ( - tracks.get_node_attr(existing_child, ann.lineage_key) - == existing_child_lineage_id_before - ) - - def test_lineage_id_updated_on_delete_division_edge( - self, get_tracks, ndim, with_seg - ) -> None: - """Test that deleting a division edge correctly updates track and lineage IDs. - - When deleting edge (1, 2) from a division at node 1: - - Sibling (3): gets parent's track_id, keeps same lineage_id - - Orphaned child (2): keeps same track_id, gets new lineage_id - """ - # Graph structure: 1 → 2, 1 → 3 → 4 → 5, and 6 (separate) - tracks = get_tracks(ndim=3, with_seg=False, is_solution=True) - tracks.enable_features(["lineage_id"]) - ann = next(a for a in tracks.annotators if isinstance(a, TrackAnnotator)) - - parent = 1 - orphaned_child = 2 - sibling = 3 - parent_track_id = tracks.get_track_id(parent) - parent_lineage = tracks.get_node_attr(parent, ann.lineage_key) - orphaned_track_id_before = tracks.get_track_id(orphaned_child) - orphaned_lineage_before = tracks.get_node_attr(orphaned_child, ann.lineage_key) - sibling_track_id_before = tracks.get_track_id(sibling) - assert parent_lineage == orphaned_lineage_before # same lineage before - - action = UserDeleteEdge(tracks, edge=(parent, orphaned_child)) - - # Sibling (3): gets parent's track_id, keeps same lineage_id - assert tracks.get_track_id(sibling) == parent_track_id - assert tracks.get_node_attr(sibling, ann.lineage_key) == parent_lineage - - # Orphaned child (2): keeps same track_id, gets new lineage_id - assert tracks.get_track_id(orphaned_child) == orphaned_track_id_before - assert tracks.get_node_attr(orphaned_child, ann.lineage_key) != parent_lineage - - # Undo should restore original IDs - action.inverse() - assert tracks.get_track_id(sibling) == sibling_track_id_before - assert tracks.get_track_id(orphaned_child) == orphaned_track_id_before - assert tracks.get_node_attr(orphaned_child, ann.lineage_key) == parent_lineage - - def test_disabled_tracklet_key_does_nothing(self, get_tracks, ndim, with_seg) -> None: - """Test that TrackAnnotator does nothing when tracklet_key is disabled.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - ann = TrackAnnotator(tracks) - - # Don't activate any features - they should all be disabled - assert len(ann.features) == 0 - - # Store original bookkeeping state - original_tracklet_map = dict(ann.tracklet_id_to_nodes) - original_lineage_map = dict(ann.lineage_id_to_nodes) - original_max_tracklet = ann.max_tracklet_id - original_max_lineage = ann.max_lineage_id - - # Perform an action that would normally update track IDs - UserAddEdge(tracks, edge=(4, 6)) - - # Bookkeeping should remain unchanged since features are disabled - assert ann.tracklet_id_to_nodes == original_tracklet_map - assert ann.lineage_id_to_nodes == original_lineage_map - assert ann.max_tracklet_id == original_max_tracklet - assert ann.max_lineage_id == original_max_lineage diff --git a/tests_old/candidate_graph/__init__.py b/tests_old/candidate_graph/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests_old/candidate_graph/test_compute_graph.py b/tests_old/candidate_graph/test_compute_graph.py deleted file mode 100644 index 6abfff97..00000000 --- a/tests_old/candidate_graph/test_compute_graph.py +++ /dev/null @@ -1,196 +0,0 @@ -import numpy as np -import pytest - -from funtracks.candidate_graph import ( - compute_graph_from_points_list, - compute_graph_from_seg, -) - - -def test_graph_from_segmentation_2d(get_tracks): - tracks = get_tracks(ndim=3, with_seg=True) - segmentation_2d = np.asarray(tracks.segmentation) - - cand_graph = compute_graph_from_seg( - segmentation=segmentation_2d, - max_edge_distance=100, - iou=True, - ) - - # Same node IDs as the segmentation labels - assert set(cand_graph.node_ids()) == set(tracks.graph.node_ids()) - - # t, pos, area must match the source graph for every node - for node in cand_graph.node_ids(): - for key in ["t", "pos", "area"]: - assert np.array(cand_graph.nodes[node][key]) == pytest.approx( - np.array(tracks.graph.nodes[node][key]), abs=0.01 - ) - - # mask and bbox must be present on every node - for node in cand_graph.node_ids(): - assert cand_graph.nodes[node]["mask"] is not None - assert cand_graph.nodes[node]["bbox"] is not None - - # segmentation shape must be stored in graph metadata - assert tuple(cand_graph.metadata["segmentation_shape"]) == segmentation_2d.shape - - # Only adjacent frames are connected; nodes 5,6 at t=4 are isolated - # because t=3 has no nodes (add_cand_edges only links frame → frame+1) - assert sorted(cand_graph.edge_list()) == [[1, 2], [1, 3], [2, 4], [3, 4]] - - # For edges shared with tracks.graph, iou must agree - cand_edges = {tuple(e) for e in cand_graph.edge_list()} - ref_edges = {tuple(e) for e in tracks.graph.edge_list()} - for src, tgt in cand_edges & ref_edges: - cand_iou = cand_graph.edges[cand_graph.edge_id(src, tgt)]["iou"] - ref_iou = tracks.graph.edges[tracks.graph.edge_id(src, tgt)]["iou"] - assert cand_iou == pytest.approx(ref_iou, abs=0.01) - - # lower edge distance: only (1, 3) is within 15 pixels (~11.2), (1, 2) is ~42 away - cand_graph = compute_graph_from_seg( - segmentation=segmentation_2d, - max_edge_distance=15, - ) - assert set(cand_graph.node_ids()) == set(tracks.graph.node_ids()) - assert sorted(cand_graph.edge_list()) == [[1, 3]] - - -def test_graph_from_segmentation_3d(get_tracks): - tracks = get_tracks(ndim=4, with_seg=True) - segmentation_3d = np.asarray(tracks.segmentation) - - cand_graph = compute_graph_from_seg( - segmentation=segmentation_3d, - max_edge_distance=100, - ) - - assert set(cand_graph.node_ids()) == set(tracks.graph.node_ids()) - - for node in cand_graph.node_ids(): - for key in ["t", "pos", "area"]: - assert np.array(cand_graph.nodes[node][key]) == pytest.approx( - np.array(tracks.graph.nodes[node][key]), abs=0.01 - ) - - # mask and bbox must be present on every node - for node in cand_graph.node_ids(): - assert cand_graph.nodes[node]["mask"] is not None - assert cand_graph.nodes[node]["bbox"] is not None - - # segmentation shape must be stored in graph metadata - assert tuple(cand_graph.metadata["segmentation_shape"]) == segmentation_3d.shape - - # Only adjacent frames connected; nodes 5,6 at t=4 isolated (gap at t=3) - assert sorted(cand_graph.edge_list()) == [[1, 2], [1, 3], [2, 4], [3, 4]] - - -def test_graph_from_segmentation_with_duplicate_nodes(get_tracks): - tracks = get_tracks(ndim=3, with_seg=True) - segmentation_2d = np.asarray(tracks.segmentation).copy() - segmentation_2d[1][20:25, 20:25] = 1 # duplicate label 1 in frame 1 - with pytest.raises(ValueError, match="Duplicate values found among nodes"): - compute_graph_from_seg( - segmentation=segmentation_2d, - max_edge_distance=100, - ) - - -def test_graph_from_segmentation_t_start(get_tracks): - """t_start shifts all node time values so sliced segmentations get absolute times.""" - tracks = get_tracks(ndim=3, with_seg=True) - segmentation_2d = np.asarray(tracks.segmentation) - - # Slice frames 1 onward and use t_start=1 to restore absolute times - sliced = segmentation_2d[1:] - cand_graph = compute_graph_from_seg( - segmentation=sliced, - max_edge_distance=100, - t_start=1, - ) - - # All nodes should have t >= 1 - for node in cand_graph.node_ids(): - assert cand_graph.nodes[node]["t"] >= 1 - - # t values should match the original tracks graph for shared nodes - for node in cand_graph.node_ids(): - if node in set(tracks.graph.node_ids()): - assert cand_graph.nodes[node]["t"] == tracks.graph.nodes[node]["t"] - - # Edges should still be formed between adjacent (shifted) frames - assert cand_graph.num_edges() > 0 - - -def test_graph_from_segmentation_negative_t_start(get_tracks): - """t_start < 0 must raise.""" - tracks = get_tracks(ndim=3, with_seg=True) - segmentation_2d = np.asarray(tracks.segmentation) - with pytest.raises(ValueError, match="t_start must be >= 0"): - compute_graph_from_seg( - segmentation=segmentation_2d, - max_edge_distance=100, - t_start=-1, - ) - - -def test_graph_from_segmentation_t_start_zero_matches_default(get_tracks): - """t_start=0 must produce identical output to omitting t_start.""" - tracks = get_tracks(ndim=3, with_seg=True) - segmentation_2d = np.asarray(tracks.segmentation) - - default_graph = compute_graph_from_seg( - segmentation=segmentation_2d, - max_edge_distance=100, - iou=True, - ) - explicit_graph = compute_graph_from_seg( - segmentation=segmentation_2d, - max_edge_distance=100, - iou=True, - t_start=0, - ) - - assert set(explicit_graph.node_ids()) == set(default_graph.node_ids()) - - for node in default_graph.node_ids(): - for key in ["t", "pos", "area"]: - assert np.array(explicit_graph.nodes[node][key]) == pytest.approx( - np.array(default_graph.nodes[node][key]), abs=0.01 - ) - - assert sorted(explicit_graph.edge_list()) == sorted(default_graph.edge_list()) - - for src, tgt in default_graph.edge_list(): - default_iou = default_graph.edges[default_graph.edge_id(src, tgt)]["iou"] - explicit_iou = explicit_graph.edges[explicit_graph.edge_id(src, tgt)]["iou"] - assert explicit_iou == pytest.approx(default_iou, abs=0.01) - - assert tuple(explicit_graph.metadata["segmentation_shape"]) == tuple( - default_graph.metadata["segmentation_shape"] - ) - - -def test_graph_from_points_list(): - points_list = np.array( - [ - # t, z, y, x - [0, 1, 1, 1], - [2, 3, 3, 3], - [1, 2, 2, 2], - [2, 6, 6, 6], - [2, 1, 1, 1], - ] - ) - cand_graph = compute_graph_from_points_list(points_list, max_edge_distance=3) - assert cand_graph.num_edges() == 3 - assert len(list(cand_graph.predecessors(3))) == 0 - - # test scale - cand_graph = compute_graph_from_points_list( - points_list, max_edge_distance=3, scale=[1, 1, 1, 5] - ) - assert cand_graph.num_edges() == 0 - assert len(list(cand_graph.predecessors(3))) == 0 - assert np.array(cand_graph.nodes[0]["pos"]) == pytest.approx([1, 1, 5]) - assert cand_graph.nodes[0]["t"] == 0 diff --git a/tests_old/candidate_graph/test_iou.py b/tests_old/candidate_graph/test_iou.py deleted file mode 100644 index 0fc3e28a..00000000 --- a/tests_old/candidate_graph/test_iou.py +++ /dev/null @@ -1,52 +0,0 @@ -import numpy as np -import pytest - -from funtracks.candidate_graph import add_cand_edges, nodes_from_segmentation -from funtracks.candidate_graph.iou import _compute_ious, add_iou - - -def test_compute_ious_2d(get_tracks): - tracks = get_tracks(ndim=3, with_seg=True) - segmentation_2d = np.asarray(tracks.segmentation) - - ious = _compute_ious(segmentation_2d[0], segmentation_2d[1]) - expected = [(1, 3, 555.46 / 1408.0)] - for iou, expected_iou in zip(ious, expected, strict=False): - assert iou == pytest.approx(expected_iou, abs=0.01) - - ious = _compute_ious(segmentation_2d[1], segmentation_2d[1]) - expected = [(2, 2, 1.0), (3, 3, 1.0)] - for iou, expected_iou in zip(ious, expected, strict=False): - assert iou == pytest.approx(expected_iou, abs=0.01) - - -def test_compute_ious_3d(get_tracks): - tracks = get_tracks(ndim=4, with_seg=True) - segmentation_3d = np.asarray(tracks.segmentation) - - ious = _compute_ious(segmentation_3d[0], segmentation_3d[1]) - expected = [(1, 3, 0.30)] - for iou, expected_iou in zip(ious, expected, strict=False): - assert iou == pytest.approx(expected_iou, abs=0.01) - - ious = _compute_ious(segmentation_3d[1], segmentation_3d[1]) - expected = [(2, 2, 1.0), (3, 3, 1.0)] - for iou, expected_iou in zip(ious, expected, strict=False): - assert iou == pytest.approx(expected_iou, abs=0.01) - - -def test_add_iou_2d(get_tracks): - tracks = get_tracks(ndim=3, with_seg=True) - segmentation_2d = np.asarray(tracks.segmentation) - - cand_graph, node_frame_dict = nodes_from_segmentation(segmentation_2d) - add_cand_edges(cand_graph, max_edge_distance=100, node_frame_dict=node_frame_dict) - add_iou(cand_graph, segmentation_2d, node_frame_dict=node_frame_dict) - - # For edges shared with tracks.graph, iou must agree - cand_edges = {tuple(e) for e in cand_graph.edge_list()} - ref_edges = {tuple(e) for e in tracks.graph.edge_list()} - for src, tgt in cand_edges & ref_edges: - cand_iou = cand_graph.edges[cand_graph.edge_id(src, tgt)]["iou"] - ref_iou = tracks.graph.edges[tracks.graph.edge_id(src, tgt)]["iou"] - assert cand_iou == pytest.approx(ref_iou, abs=0.01) diff --git a/tests_old/candidate_graph/test_relabel_segmentation.py b/tests_old/candidate_graph/test_relabel_segmentation.py deleted file mode 100644 index 9ea20e96..00000000 --- a/tests_old/candidate_graph/test_relabel_segmentation.py +++ /dev/null @@ -1,52 +0,0 @@ -import numpy as np -import pytest -from numpy.testing import assert_array_equal -from skimage.draw import disk - -from funtracks.utils import ensure_unique_labels, relabel_segmentation_with_track_id - - -@pytest.fixture -def segmentation_2d_repeat_labels(): - frame_shape = (100, 100) - total_shape = (2, *frame_shape) - segmentation = np.zeros(total_shape, dtype="int32") - # make frame with one cell in center with label 1 - rr, cc = disk(center=(50, 50), radius=20, shape=(100, 100)) - segmentation[0][rr, cc] = 1 - - # make frame with two cells - # first cell centered at (20, 80) with label 1 - # second cell centered at (60, 45) with label 2 - rr, cc = disk(center=(20, 80), radius=10, shape=frame_shape) - segmentation[1][rr, cc] = 1 - rr, cc = disk(center=(60, 45), radius=15, shape=frame_shape) - segmentation[1][rr, cc] = 2 - return segmentation - - -def test_relabel_segmentation(get_tracks): - tracks = get_tracks(ndim=3, with_seg=True) - segmentation = np.asarray(tracks.segmentation) - - # Use only nodes 1 and 2 (single tracklet: node 1 at t=0, node 2 at t=1) - subgraph = tracks.graph.filter(node_ids=[1, 2]).subgraph() - relabeled = relabel_segmentation_with_track_id(subgraph, segmentation) - - # Nodes 1 and 2 form one tracklet → both get label 1 - assert (relabeled[0][segmentation[0] == 1] == 1).all() - assert (relabeled[1][segmentation[1] == 2] == 1).all() - # Node 3 not in subgraph → pixels become 0 - assert (relabeled[1][segmentation[1] == 3] == 0).all() - - -def test_ensure_unique_labels_2d(segmentation_2d_repeat_labels): - expected = segmentation_2d_repeat_labels.copy().astype(np.uint64) - frame = expected[1] - frame[frame == 2] = 3 - frame[frame == 1] = 2 - expected[1] = frame - - print(np.unique(expected[1], return_counts=True)) # noqa - result = ensure_unique_labels(segmentation_2d_repeat_labels) - assert_array_equal(expected, result) diff --git a/tests_old/conftest.py b/tests_old/conftest.py deleted file mode 100644 index 91854cd0..00000000 --- a/tests_old/conftest.py +++ /dev/null @@ -1,524 +0,0 @@ -from collections.abc import Callable -from typing import TYPE_CHECKING - -import numpy as np -import polars as pl -import pytest -import tracksdata as td -from skimage.draw import disk -from tracksdata.nodes import Mask - -from funtracks.utils.tracksdata_utils import ( - create_empty_graphview_graph, -) - -if TYPE_CHECKING: - from typing import Any - - from funtracks.data_model import SolutionTracks, Tracks - - -def make_2d_disk_mask(center=(50, 50), radius=20) -> Mask: - """Create a 2D disk mask with bounding box. - - Args: - center: Center coordinates (y, x) - radius: Radius of the disk - - Returns: - tracksdata Mask object with boolean mask and bbox - """ - radius_actual = radius - 1 - mask_shape = (2 * radius - 1, 2 * radius - 1) - rr, cc = disk(center=(radius_actual, radius_actual), radius=radius, shape=mask_shape) - mask_disk = np.zeros(mask_shape, dtype="bool") - mask_disk[rr, cc] = True - return Mask( - mask_disk, - bbox=np.array( - [ - center[0] - radius_actual, - center[1] - radius_actual, - center[0] + radius_actual + 1, - center[1] + radius_actual + 1, - ] - ), - ) - - -def make_3d_sphere_mask(center=(50, 50, 50), radius=20) -> Mask: - """Create a 3D sphere mask with bounding box. - - Args: - center: Center coordinates (z, y, x) - radius: Radius of the sphere - - Returns: - tracksdata Mask object with boolean mask and bbox - """ - mask_shape = (2 * radius + 1, 2 * radius + 1, 2 * radius + 1) - mask_sphere = sphere(center=(radius, radius, radius), radius=radius, shape=mask_shape) - return Mask( - mask_sphere, - bbox=np.array( - [ - center[0] - radius, - center[1] - radius, - center[2] - radius, - center[0] + radius + 1, - center[1] + radius + 1, - center[2] + radius + 1, - ] - ), - ) - - -def make_2d_square_mask(start_corner=(0, 0), width=4) -> Mask: - """Create a 2D square mask with bounding box. - - Args: - start_corner: Top-left corner coordinates (y, x) - width: Width and height of the square - - Returns: - tracksdata Mask object with boolean mask and bbox - """ - mask_shape = (width, width) - mask_square = np.ones(mask_shape, dtype="bool") - return Mask( - mask_square, - bbox=np.array( - [ - start_corner[0], - start_corner[1], - start_corner[0] + width, - start_corner[1] + width, - ] - ), - ) - - -def make_3d_cube_mask(start_corner=(0, 0, 0), width=4) -> Mask: - """Create a 3D cube mask with bounding box. - - Args: - start_corner: Corner coordinates (z, y, x) - width: Width, height, and depth of the cube - - Returns: - tracksdata Mask object with boolean mask and bbox - """ - mask_shape = (width, width, width) - mask_cube = np.ones(mask_shape, dtype="bool") - return Mask( - mask_cube, - bbox=np.array( - [ - start_corner[0], - start_corner[1], - start_corner[2], - start_corner[0] + width, - start_corner[1] + width, - start_corner[2] + width, - ] - ), - ) - - -def _make_graph( - *, - ndim: int = 3, - with_pos: bool = False, - with_track_id: bool = False, - with_area: bool = False, - with_iou: bool = False, - with_masks: bool = False, - database: str | None = None, -) -> td.graph.GraphView: - """Generate a test graph with configurable features. - - Args: - ndim: 3 for 2D spatial + time, 4 for 3D spatial + time - with_pos: Include position attribute - with_track_id: Include track_id attribute - with_area: Include area attribute (requires with_pos=True) - 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) - - Returns: - A graph with the requested features - """ - - node_attributes = [] - node_default_values = [] - edge_attributes = [] - if with_pos: - node_attributes.append("pos") - node_default_values.append(0.0) - if with_track_id: - node_attributes.append("track_id") - node_default_values.append(-1) - node_attributes.append("lineage_id") - node_default_values.append(-1) - if with_area: - node_attributes.append("area") - node_default_values.append(0.0) - if with_iou: - edge_attributes.append("iou") - if with_masks: - node_attributes.append(td.DEFAULT_ATTR_KEYS.MASK) - node_default_values.append(0.0) - node_attributes.append(td.DEFAULT_ATTR_KEYS.BBOX) - node_default_values.append(0.0) - - graph = create_empty_graphview_graph( - node_attributes=node_attributes, - node_default_values=node_default_values, - edge_attributes=edge_attributes, - database=database, - position_attrs=["pos"] if with_pos else None, - ndim=ndim, - ) - - # Base node data (always has time) - base_nodes = [ - (1, {"t": 0}), - (2, {"t": 1}), - (3, {"t": 1}), - (4, {"t": 2}), - (5, {"t": 4}), - (6, {"t": 4}), - ] - - # Position data - if ndim == 3: # 2D spatial - positions = { - 1: [50, 50], - 2: [20, 80], - 3: [60, 45], - 4: [1.5, 1.5], - 5: [1.5, 1.5], - 6: [97.5, 97.5], - } - areas = {1: 1245, 2: 305, 3: 697, 4: 16, 5: 16, 6: 16} - ious = {(1, 2): 0.0, (1, 3): 0.395, (3, 4): 0.0, (4, 5): 1.0} - else: # 3D spatial - positions = { - 1: [50, 50, 50], - 2: [20, 50, 80], - 3: [60, 50, 45], - 4: [1.5, 1.5, 1.5], - 5: [1.5, 1.5, 1.5], - 6: [97.5, 97.5, 97.5], - } - areas = {1: 33401, 2: 4169, 3: 14147, 4: 64, 5: 64, 6: 64} - ious = {(1, 2): 0.0, (1, 3): 0.302, (3, 4): 0.0, (4, 5): 1.0} - - # Track and lineage IDs - track_ids = {1: 1, 2: 2, 3: 3, 4: 3, 5: 3, 6: 5} - lineage_ids = {1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 2} - - # Mask data (matches segmentation structure) - segmentation_shape: tuple[int, ...] - if ndim == 3: # 2D spatial - masks = { - 1: make_2d_disk_mask(center=(50, 50), radius=20), - 2: make_2d_disk_mask(center=(20, 80), radius=10), - 3: make_2d_disk_mask(center=(60, 45), radius=15), - 4: make_2d_square_mask(start_corner=(0, 0), width=4), - 5: make_2d_square_mask(start_corner=(0, 0), width=4), - 6: make_2d_square_mask(start_corner=(96, 96), width=4), - } - segmentation_shape = (5, 100, 100) - else: # 3D spatial - masks = { - 1: make_3d_sphere_mask(center=(50, 50, 50), radius=20), - 2: make_3d_sphere_mask(center=(20, 50, 80), radius=10), - 3: make_3d_sphere_mask(center=(60, 50, 45), radius=15), - 4: make_3d_cube_mask(start_corner=(0, 0, 0), width=4), - 5: make_3d_cube_mask(start_corner=(0, 0, 0), width=4), - 6: make_3d_cube_mask(start_corner=(96, 96, 96), width=4), - } - segmentation_shape = (5, 100, 100, 100) - - # Build nodes with requested features - nodes_id_list = [] - nodes_attrs_list = [] - for node_id, attrs in base_nodes: - node_attrs: dict[str, Any] = dict(attrs) # Start with time - node_attrs["solution"] = True - if with_pos: - # TODO: don't hardcode "pos" and other column names - node_attrs["pos"] = positions[node_id] - if with_track_id: - node_attrs["track_id"] = track_ids[node_id] - node_attrs["lineage_id"] = lineage_ids[node_id] - if with_area: - node_attrs["area"] = float(areas[node_id]) - # I think this is necessary, to keep the dtype the same, - # in case the scale are not integers - if with_masks: - mask = masks[node_id] - node_attrs[td.DEFAULT_ATTR_KEYS.MASK] = mask - node_attrs[td.DEFAULT_ATTR_KEYS.BBOX] = mask.bbox - nodes_id_list.append(node_id) - nodes_attrs_list.append(node_attrs) - - edges = [ - {"source_id": 1, "target_id": 2, "solution": True}, - {"source_id": 1, "target_id": 3, "solution": True}, - {"source_id": 3, "target_id": 4, "solution": True}, - {"source_id": 4, "target_id": 5, "solution": True}, - ] - - graph.bulk_add_nodes(nodes=nodes_attrs_list, indices=nodes_id_list) - graph.bulk_add_edges(edges) - if with_masks: - graph._update_metadata(segmentation_shape=segmentation_shape) - - # Add IOUs to edges if requested - if with_iou: - for edge, iou in ious.items(): - if graph.has_edge(edge[0], edge[1]): - edge_id = graph.edge_id(edge[0], edge[1]) - graph.update_edge_attrs(attrs={"iou": iou}, edge_ids=[edge_id]) - - return graph - - -@pytest.fixture -def graph_clean(tmp_path) -> td.graph.GraphView: - """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) - - -@pytest.fixture -def graph_2d_with_position(tmp_path) -> td.graph.GraphView: - """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) - - -@pytest.fixture -def graph_2d_with_track_id(tmp_path) -> td.graph.GraphView: - """Graph with 2D positions and track_id - for SolutionTracks 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) - - -@pytest.fixture -def graph_2d_with_segmentation(tmp_path) -> td.graph.GraphView: - """Graph with segmentation (masks/bboxes) and all computed features.""" - db_path = str(tmp_path / "graph_2d_segmentation.db") - return _make_graph( - ndim=3, - with_pos=True, - with_track_id=True, - with_area=True, - with_iou=True, - with_masks=True, - database=db_path, - ) - - -@pytest.fixture -def graph_3d_with_position(tmp_path) -> td.graph.GraphView: - """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) - - -@pytest.fixture -def graph_3d_with_track_id(tmp_path) -> td.graph.GraphView: - """Graph with 3D positions and track_id - for SolutionTracks 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) - - -@pytest.fixture -def graph_3d_with_segmentation(tmp_path) -> td.graph.GraphView: - """Graph with segmentation (masks/bboxes) and all computed features.""" - db_path = str(tmp_path / "graph_3d_segmentation.db") - return _make_graph( - ndim=4, - with_pos=True, - with_track_id=True, - with_area=True, - with_iou=True, - with_masks=True, - database=db_path, - ) - - -@pytest.fixture -def get_tracks(get_graph) -> Callable[..., "Tracks | SolutionTracks"]: - """Factory fixture to create Tracks or SolutionTracks instances. - - Returns a factory function that can be called with: - ndim: 3 for 2D spatial + time, 4 for 3D spatial + time - with_seg: Whether to include segmentation (mask/bbox as node attributes) - is_solution: Whether to return SolutionTracks instead of Tracks - - Example: - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - - Note: - Uses a pre-built FeatureDict to avoid recomputing features that already - exist in the test graph fixtures. - """ - from funtracks.data_model import SolutionTracks, Tracks - from funtracks.features import ( - Area, - FeatureDict, - IoU, - LineageID, - Position, - SegBbox, - SegMask, - Solution, - Time, - TrackletID, - ) - - def _make_tracks( - ndim: int, - with_seg: bool = True, - is_solution: bool = False, - ) -> Tracks | SolutionTracks: - # Determine axis names based on ndim - axis_names = ["z", "y", "x"] if ndim == 4 else ["y", "x"] - - graph = get_graph(ndim=ndim, is_solution=is_solution, with_seg=with_seg) - - # Build FeatureDict based on what exists in the graph - features_dict: dict[str, Any] = { - "t": Time(), - "pos": Position(axes=axis_names), - "solution": Solution(), - } - - if with_seg: - features_dict["mask"] = SegMask(ndim) - features_dict["bbox"] = SegBbox(ndim) - features_dict["area"] = Area(ndim=ndim) - features_dict["iou"] = IoU() - if is_solution: - features_dict["track_id"] = TrackletID() - features_dict["lineage_id"] = LineageID() - - feature_dict = FeatureDict( - features=features_dict, - time_key="t", - position_key="pos", - tracklet_key="track_id" if is_solution else None, - lineage_key="lineage_id" if is_solution else None, - ) - - # Create the appropriate Tracks type with pre-built FeatureDict - if is_solution: - return SolutionTracks( - graph, - ndim=ndim, - features=feature_dict, - ) - else: - return Tracks( - graph, - ndim=ndim, - features=feature_dict, - ) - - return _make_tracks - - -@pytest.fixture -def graph_2d_list(tmp_path) -> td.graph.GraphView: - db_path = str(tmp_path / "graph_2d_list.db") - graph = create_empty_graphview_graph(database=db_path) - - nodes = [ - { - "y": 100, - "x": 50, - "t": 0, - "area": 1245, - "track_id": 1, - "lineage_id": 1, - }, - { - "y": 20, - "x": 100, - "t": 1, - "area": 500, - "track_id": 2, - "lineage_id": 2, - }, - ] - graph.add_node_attr_key("y", default_value=0.0, dtype=pl.Float64) - graph.add_node_attr_key("x", default_value=0.0, dtype=pl.Float64) - graph.add_node_attr_key("area", default_value=0.0, dtype=pl.Float64) - graph.add_node_attr_key("track_id", default_value=0.0, dtype=pl.Float64) - graph.add_node_attr_key("lineage_id", default_value=0.0, dtype=pl.Float64) - - graph.bulk_add_nodes(nodes=nodes, indices=[1, 2]) - return graph - - -def sphere(center, radius, shape): - assert len(center) == len(shape) - indices = np.moveaxis(np.indices(shape), 0, -1) # last dim is the index - distance = np.linalg.norm(np.subtract(indices, np.asarray(center)), axis=-1) - mask = distance <= radius - return mask - - -@pytest.fixture -def get_graph(tmp_path) -> Callable[..., td.graph.GraphView]: - """Factory fixture to create a graph with configurable features. - - Args: - ndim: 3 for 2D spatial + time, 4 for 3D spatial + time - with_pos: Include position attribute (default True) - is_solution: Include track_id and lineage_id (default False) - with_seg: Include mask, bbox, area, and iou (default False) - - Returns: - A newly created graph with the requested features - - Example: - graph = get_graph(ndim=3, with_seg=True) - graph = get_graph(ndim=4, is_solution=True, with_seg=True) - """ - counter = [0] - - def _get_graph( - ndim: int = 3, - with_pos: bool = True, - is_solution: bool = False, - with_seg: bool = False, - ) -> td.graph.GraphView: - counter[0] += 1 - db_path = str(tmp_path / f"graph_{counter[0]}.db") - return _make_graph( - ndim=ndim, - with_pos=with_pos, - with_track_id=is_solution, - with_area=with_seg, - with_iou=with_seg, - with_masks=with_seg, - database=db_path, - ) - - return _get_graph - - -def pytest_collection_modifyitems(items): - """Silence the (expected) deprecation warnings emitted by this frozen old-API - suite, scoped to tests_old only so the main tests/ suite's warnings stay visible. - A command-line ``-W error::DeprecationWarning`` still overrides this, which is how - we verify the deprecated paths are actually exercised.""" - for item in items: - if "tests_old" in str(item.fspath): - item.add_marker(pytest.mark.filterwarnings("ignore::DeprecationWarning")) diff --git a/tests_old/data/format_v1/test_save_load_False_3_False_0/attrs.json b/tests_old/data/format_v1/test_save_load_False_3_False_0/attrs.json deleted file mode 100644 index 00ca9cd1..00000000 --- a/tests_old/data/format_v1/test_save_load_False_3_False_0/attrs.json +++ /dev/null @@ -1 +0,0 @@ -{"scale": null, "ndim": 3, "features": {"FeatureDict": {"features": {"t": {"feature_type": "node", "value_type": "int", "num_values": 1, "display_name": "Time", "required": true, "default_value": null}, "pos": {"feature_type": "node", "value_type": "float", "num_values": 2, "display_name": "position", "value_names": ["y", "x"], "required": true, "default_value": null, "spatial_dims": true}}, "time_key": "t", "position_key": "pos", "tracklet_key": null}}} diff --git a/tests_old/data/format_v1/test_save_load_False_3_False_0/graph.json b/tests_old/data/format_v1/test_save_load_False_3_False_0/graph.json deleted file mode 100644 index d0a025b5..00000000 --- a/tests_old/data/format_v1/test_save_load_False_3_False_0/graph.json +++ /dev/null @@ -1 +0,0 @@ -{"directed": true, "multigraph": false, "graph": {}, "nodes": [{"t": 0, "pos": [50, 50], "id": 1}, {"t": 1, "pos": [20, 80], "id": 2}, {"t": 1, "pos": [60, 45], "id": 3}, {"t": 2, "pos": [1.5, 1.5], "id": 4}, {"t": 4, "pos": [1.5, 1.5], "id": 5}, {"t": 4, "pos": [97.5, 97.5], "id": 6}], "links": [{"source": 1, "target": 2}, {"source": 1, "target": 3}, {"source": 3, "target": 4}, {"source": 4, "target": 5}]} diff --git a/tests_old/data/format_v1/test_save_load_False_3_True_0/attrs.json b/tests_old/data/format_v1/test_save_load_False_3_True_0/attrs.json deleted file mode 100644 index 1755ec3e..00000000 --- a/tests_old/data/format_v1/test_save_load_False_3_True_0/attrs.json +++ /dev/null @@ -1 +0,0 @@ -{"scale": null, "ndim": 3, "features": {"FeatureDict": {"features": {"t": {"feature_type": "node", "value_type": "int", "num_values": 1, "display_name": "Time", "required": true, "default_value": null}, "pos": {"feature_type": "node", "value_type": "float", "num_values": 2, "display_name": "position", "value_names": ["y", "x"], "required": true, "default_value": null, "spatial_dims": true}, "area": {"feature_type": "node", "value_type": "float", "num_values": 1, "display_name": "Area", "required": true, "default_value": null}, "iou": {"feature_type": "edge", "value_type": "float", "num_values": 1, "display_name": "IoU", "required": true, "default_value": null}}, "time_key": "t", "position_key": "pos"}}} diff --git a/tests_old/data/format_v1/test_save_load_False_3_True_0/graph.json b/tests_old/data/format_v1/test_save_load_False_3_True_0/graph.json deleted file mode 100644 index 802ca054..00000000 --- a/tests_old/data/format_v1/test_save_load_False_3_True_0/graph.json +++ /dev/null @@ -1 +0,0 @@ -{"directed": true, "multigraph": false, "graph": {}, "nodes": [{"t": 0, "pos": [50, 50], "area": 1245, "id": 1}, {"t": 1, "pos": [20, 80], "area": 305, "id": 2}, {"t": 1, "pos": [60, 45], "area": 697, "id": 3}, {"t": 2, "pos": [1.5, 1.5], "area": 16, "id": 4}, {"t": 4, "pos": [1.5, 1.5], "area": 16, "id": 5}, {"t": 4, "pos": [97.5, 97.5], "area": 16, "id": 6}], "links": [{"iou": 0.0, "source": 1, "target": 2}, {"iou": 0.395, "source": 1, "target": 3}, {"iou": 0.0, "source": 3, "target": 4}, {"iou": 1.0, "source": 4, "target": 5}]} diff --git a/tests_old/data/format_v1/test_save_load_False_3_True_0/seg.npy b/tests_old/data/format_v1/test_save_load_False_3_True_0/seg.npy deleted file mode 100644 index 6440622383fd7489f5baf1205275b5271746a104..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 200128 zcmeIvzmDWY5C`Br{ss3Gn=MEi91R)~R?{`0Z`~8ovZvMLY`|j=I^UqIrU)|n){o5CJ_qTVyynFid>EVx` z-aWm2e0l%P!|%_Jr}xjlKD>WC{r~jK``gdoy!rpX;t(EnXzMBo5bmJvvt=A-_=^m@Kb4ma1!zbLZ#H;n%ggjj%wf3#)-xVO)W;I@| zYmyOli__XQ(!U!*qKzV6t!oo8b&Jy4G}6BtLZXc#Uae~rF?Ea5+BDL?8$zOuB3`X) z6ESs*(%LlAzZ*iLjUryHYZEbbi_+RO(!U!*qKzV6t!oo8b&Jy4G}6BtLZXc#Uae~r zF?Ea5+BDL?8$z;;B4({>lM!`|)5YQusyR<_N z6s*>Mu5O^Q(?*?ht$mkv$bo{@+RxPuGY=hO8uwn2OKwj5& zm@_cgRT-k`>`wr1y) ztBhA`TT{16-~%+Nu`R7R0OP77mim^gUp44Knw9vLu006h>SLBVFIm5O$OAT9;(Tq* z0oj_4e9imP_ol%P-u#mL`L}~p8J_?F0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5)85%}noWO}CwcF-Wxvv_%SZ7bkBw)H>X-Fcjg_)mZU0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oPv1@bRhr^lrPG?$-pSXYumv+E&1MZ0mo%yYn~~@t*(z z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF j5Fn6S;FD9l>76_6{?kp*;^o=3t$_2`*8hC>^Yi!*cEBtr diff --git a/tests_old/data/format_v1/test_save_load_False_4_False_0/attrs.json b/tests_old/data/format_v1/test_save_load_False_4_False_0/attrs.json deleted file mode 100644 index 0d7d20ba..00000000 --- a/tests_old/data/format_v1/test_save_load_False_4_False_0/attrs.json +++ /dev/null @@ -1 +0,0 @@ -{"scale": null, "ndim": 4, "features": {"FeatureDict": {"features": {"t": {"feature_type": "node", "value_type": "int", "num_values": 1, "display_name": "Time", "required": true, "default_value": null}, "pos": {"feature_type": "node", "value_type": "float", "num_values": 3, "display_name": "position", "value_names": ["z", "y", "x"], "required": true, "default_value": null, "spatial_dims": true}}, "time_key": "t", "position_key": "pos", "tracklet_key": null}}} diff --git a/tests_old/data/format_v1/test_save_load_False_4_False_0/graph.json b/tests_old/data/format_v1/test_save_load_False_4_False_0/graph.json deleted file mode 100644 index 4d000548..00000000 --- a/tests_old/data/format_v1/test_save_load_False_4_False_0/graph.json +++ /dev/null @@ -1 +0,0 @@ -{"directed": true, "multigraph": false, "graph": {}, "nodes": [{"t": 0, "pos": [50, 50, 50], "id": 1}, {"t": 1, "pos": [20, 50, 80], "id": 2}, {"t": 1, "pos": [60, 50, 45], "id": 3}, {"t": 2, "pos": [1.5, 1.5, 1.5], "id": 4}, {"t": 4, "pos": [1.5, 1.5, 1.5], "id": 5}, {"t": 4, "pos": [97.5, 97.5, 97.5], "id": 6}], "links": [{"source": 1, "target": 2}, {"source": 1, "target": 3}, {"source": 3, "target": 4}, {"source": 4, "target": 5}]} diff --git a/tests_old/data/format_v1/test_save_load_False_4_True_0/attrs.json b/tests_old/data/format_v1/test_save_load_False_4_True_0/attrs.json deleted file mode 100644 index 9a6ed6ac..00000000 --- a/tests_old/data/format_v1/test_save_load_False_4_True_0/attrs.json +++ /dev/null @@ -1 +0,0 @@ -{"scale": null, "ndim": 4, "features": {"FeatureDict": {"features": {"t": {"feature_type": "node", "value_type": "int", "num_values": 1, "display_name": "Time", "required": true, "default_value": null}, "pos": {"feature_type": "node", "value_type": "float", "num_values": 3, "display_name": "position", "value_names": ["z", "y", "x"], "required": true, "default_value": null, "spatial_dims": true}, "area": {"feature_type": "node", "value_type": "float", "num_values": 1, "display_name": "Volume", "required": true, "default_value": null}, "iou": {"feature_type": "edge", "value_type": "float", "num_values": 1, "display_name": "IoU", "required": true, "default_value": null}}, "time_key": "t", "position_key": "pos"}}} diff --git a/tests_old/data/format_v1/test_save_load_False_4_True_0/graph.json b/tests_old/data/format_v1/test_save_load_False_4_True_0/graph.json deleted file mode 100644 index a0bbda5d..00000000 --- a/tests_old/data/format_v1/test_save_load_False_4_True_0/graph.json +++ /dev/null @@ -1 +0,0 @@ -{"directed": true, "multigraph": false, "graph": {}, "nodes": [{"t": 0, "pos": [50, 50, 50], "area": 33401, "id": 1}, {"t": 1, "pos": [20, 50, 80], "area": 4169, "id": 2}, {"t": 1, "pos": [60, 50, 45], "area": 14147, "id": 3}, {"t": 2, "pos": [1.5, 1.5, 1.5], "area": 64, "id": 4}, {"t": 4, "pos": [1.5, 1.5, 1.5], "area": 64, "id": 5}, {"t": 4, "pos": [97.5, 97.5, 97.5], "area": 64, "id": 6}], "links": [{"iou": 0.0, "source": 1, "target": 2}, {"iou": 0.302, "source": 1, "target": 3}, {"iou": 0.0, "source": 3, "target": 4}, {"iou": 1.0, "source": 4, "target": 5}]} diff --git a/tests_old/data/format_v1/test_save_load_False_4_True_0/seg.npy b/tests_old/data/format_v1/test_save_load_False_4_True_0/seg.npy deleted file mode 100644 index 236d56ee3c48ab8641576a62fa27dac0ef8feb00..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20000128 zcmeF)F|RC5k{00kTz)-v)KmO(OKmC_~ z^ZS4Pw}1QB|Kp$i&F}y3@2CF#li&GAfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N 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 zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly 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 z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk 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 z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72s}{WYHy1BflvIaC&gLZXX~thd7Smn z-OVG5s0k1tK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkKchrrcBa?vWS`h0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1a=5qEi~>OJ!Gy6#97>D>#Trzob}J$%_EDb2@oJafB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV6S;z|}(I-qAzmxZRAOKeW|H~IUu}UKzWCDUV zN@fWVAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ;4OhT->vo5@BjDPnnm{Bu7Est z{rh!!bP+QF0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72uujXSz2r2hMo5UHH+-MT>*LQ`uFSd=ptqU1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5SS2%v$WR44Lk1zY8Kghy8`mq_3zi^(M8Mz2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UATS{iXKAg88+P6c)GV_1 zb_L|I>))@-ql=gc5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ z;A@YLQ2>ZRAOLp$|H&6Su?peDW(2_$QJ5t_fB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjYG1>$_H{kq4$e*|h4 zIjda(dF=Z4>+ zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkKcLLko4+7mbIyceihR)~R?{`0Z`~8ovZvMLY`|j=I^UqIrU)|n){o5CJ_qTVyynFid>EVx` z-aWm2e0l%P!|%_Jr}xjlKD>WC{r~jK``gdoy!rpX;t(EnXzMBo5bmJvvt=A-_=^m@Kb4ma1!zbLZ#H;n%ggjj%wf3#)-xVO)W;I@| zYmyOli__XQ(!U!*qKzV6t!oo8b&Jy4G}6BtLZXc#Uae~rF?Ea5+BDL?8$zOuB3`X) z6ESs*(%LlAzZ*iLjUryHYZEbbi_+RO(!U!*qKzV6t!oo8b&Jy4G}6BtLZXc#Uae~r zF?Ea5+BDL?8$z;;B4({>lM!`|)5YQusyR<_N z6s*>Mu5O^Q(?*?ht$mkv$bo{@+RxPuGY=hO8uwn2OKwj5& zm@_cgRT-k`>`wr1y) ztBhA`TT{16-~%+Nu`R7R0OP77mim^gUp44Knw9vLu006h>SLBVFIm5O$OAT9;(Tq* z0oj_4e9imP_ol%P-u#mL`L}~p8J_?F0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5)85%}noWO}CwcF-Wxvv_%SZ7bkBw)H>X-Fcjg_)mZU0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oPv1@bRhr^lrPG?$-pSXYumv+E&1MZ0mo%yYn~~@t*(z z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF j5Fn6S;FD9l>76_6{?kp*;^o=3t$_2`*8hC>^Yi!*cEBtr diff --git a/tests_old/data/format_v1/test_save_load_True_4_False_0/attrs.json b/tests_old/data/format_v1/test_save_load_True_4_False_0/attrs.json deleted file mode 100644 index 8e17387a..00000000 --- a/tests_old/data/format_v1/test_save_load_True_4_False_0/attrs.json +++ /dev/null @@ -1 +0,0 @@ -{"scale": null, "ndim": 4, "features": {"FeatureDict": {"features": {"t": {"feature_type": "node", "value_type": "int", "num_values": 1, "display_name": "Time", "required": true, "default_value": null}, "pos": {"feature_type": "node", "value_type": "float", "num_values": 3, "display_name": "position", "value_names": ["z", "y", "x"], "required": true, "default_value": null, "spatial_dims": true}, "track_id": {"feature_type": "node", "value_type": "int", "num_values": 1, "display_name": "Tracklet ID", "required": true, "default_value": null}, "lineage_id": {"feature_type": "node", "value_type": "int", "num_values": 1, "display_name": "Lineage ID", "required": true, "default_value": null}}, "time_key": "t", "position_key": "pos", "tracklet_key": "track_id", "lineage_key": "lineage_id"}}} diff --git a/tests_old/data/format_v1/test_save_load_True_4_False_0/graph.json b/tests_old/data/format_v1/test_save_load_True_4_False_0/graph.json deleted file mode 100644 index 4ce05440..00000000 --- a/tests_old/data/format_v1/test_save_load_True_4_False_0/graph.json +++ /dev/null @@ -1 +0,0 @@ -{"directed": true, "multigraph": false, "graph": {}, "nodes": [{"t": 0, "pos": [50, 50, 50], "track_id": 1, "lineage_id": 1, "id": 1}, {"t": 1, "pos": [20, 50, 80], "track_id": 2, "lineage_id": 1, "id": 2}, {"t": 1, "pos": [60, 50, 45], "track_id": 3, "lineage_id": 1, "id": 3}, {"t": 2, "pos": [1.5, 1.5, 1.5], "track_id": 3, "lineage_id": 1, "id": 4}, {"t": 4, "pos": [1.5, 1.5, 1.5], "track_id": 3, "lineage_id": 1, "id": 5}, {"t": 4, "pos": [97.5, 97.5, 97.5], "track_id": 5, "lineage_id": 2, "id": 6}], "links": [{"source": 1, "target": 2}, {"source": 1, "target": 3}, {"source": 3, "target": 4}, {"source": 4, "target": 5}]} diff --git a/tests_old/data/format_v1/test_save_load_True_4_True_0/attrs.json b/tests_old/data/format_v1/test_save_load_True_4_True_0/attrs.json deleted file mode 100644 index e87005fe..00000000 --- a/tests_old/data/format_v1/test_save_load_True_4_True_0/attrs.json +++ /dev/null @@ -1 +0,0 @@ -{"scale": null, "ndim": 4, "features": {"FeatureDict": {"features": {"t": {"feature_type": "node", "value_type": "int", "num_values": 1, "display_name": "Time", "required": true, "default_value": null}, "pos": {"feature_type": "node", "value_type": "float", "num_values": 3, "display_name": "position", "value_names": ["z", "y", "x"], "required": true, "default_value": null, "spatial_dims": true}, "area": {"feature_type": "node", "value_type": "float", "num_values": 1, "display_name": "Volume", "required": true, "default_value": null}, "iou": {"feature_type": "edge", "value_type": "float", "num_values": 1, "display_name": "IoU", "required": true, "default_value": null}, "track_id": {"feature_type": "node", "value_type": "int", "num_values": 1, "display_name": "Tracklet ID", "required": true, "default_value": null}, "lineage_id": {"feature_type": "node", "value_type": "int", "num_values": 1, "display_name": "Lineage ID", "required": true, "default_value": null}}, "time_key": "t", "position_key": "pos", "tracklet_key": "track_id", "lineage_key": "lineage_id"}}} diff --git a/tests_old/data/format_v1/test_save_load_True_4_True_0/graph.json b/tests_old/data/format_v1/test_save_load_True_4_True_0/graph.json deleted file mode 100644 index c50a5dcb..00000000 --- a/tests_old/data/format_v1/test_save_load_True_4_True_0/graph.json +++ /dev/null @@ -1 +0,0 @@ -{"directed": true, "multigraph": false, "graph": {}, "nodes": [{"t": 0, "pos": [50, 50, 50], "track_id": 1, "area": 33401, "lineage_id": 1, "id": 1}, {"t": 1, "pos": [20, 50, 80], "track_id": 2, "area": 4169, "lineage_id": 1, "id": 2}, {"t": 1, "pos": [60, 50, 45], "track_id": 3, "area": 14147, "lineage_id": 1, "id": 3}, {"t": 2, "pos": [1.5, 1.5, 1.5], "track_id": 3, "area": 64, "lineage_id": 1, "id": 4}, {"t": 4, "pos": [1.5, 1.5, 1.5], "track_id": 3, "area": 64, "lineage_id": 1, "id": 5}, {"t": 4, "pos": [97.5, 97.5, 97.5], "track_id": 5, "area": 64, "lineage_id": 2, "id": 6}], "links": [{"iou": 0.0, "source": 1, "target": 2}, {"iou": 0.302, "source": 1, "target": 3}, {"iou": 0.0, "source": 3, "target": 4}, {"iou": 1.0, "source": 4, "target": 5}]} diff --git a/tests_old/data/format_v1/test_save_load_True_4_True_0/seg.npy b/tests_old/data/format_v1/test_save_load_True_4_True_0/seg.npy deleted file mode 100644 index 236d56ee3c48ab8641576a62fa27dac0ef8feb00..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20000128 zcmeF)F|RC5k{00kTz)-v)KmO(OKmC_~ z^ZS4Pw}1QB|Kp$i&F}y3@2CF#li&GAfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N 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 zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly 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 z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk 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 z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72s}{WYHy1BflvIaC&gLZXX~thd7Smn z-OVG5s0k1tK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkKchrrcBa?vWS`h0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1a=5qEi~>OJ!Gy6#97>D>#Trzob}J$%_EDb2@oJafB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV6S;z|}(I-qAzmxZRAOKeW|H~IUu}UKzWCDUV zN@fWVAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ;4OhT->vo5@BjDPnnm{Bu7Est z{rh!!bP+QF0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72uujXSz2r2hMo5UHH+-MT>*LQ`uFSd=ptqU1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5SS2%v$WR44Lk1zY8Kghy8`mq_3zi^(M8Mz2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UATS{iXKAg88+P6c)GV_1 zb_L|I>))@-ql=gc5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ z;A@YLQ2>ZRAOLp$|H&6Su?peDW(2_$QJ5t_fB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjYG1>$_H{kq4$e*|h4 zIjda(dF=Z4>+ zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkKcLLko4+7mbIyceih= 2 # header + at least 1 row - - header = lines[0].strip().split(",") - assert "id" in header - - exported_ids = {int(line.split(",")[header.index("id")]) for line in lines[1:]} - - # CSV should include expanded graph nodes (NOT just requested subset) - expected_graph_nodes = {1, 3, 4, 6} - assert exported_ids == expected_graph_nodes - - # 2. Validate segmentation - if seg_file_format == "zarr": - seg = zarr.open(str(seg_path), mode="r") - seg_arr = np.asarray(seg[:]) - else: - seg_arr = tifffile.imread(str(seg_path)) - - assert seg_arr.shape == tracks.segmentation.shape - - unique_vals = set(seg_arr.flatten()) - {0} - - original = np.asarray(tracks.segmentation[:]) - - graph_nodes = expected_graph_nodes - - if seg_relabel is None: - expected = np.where(np.isin(original, list(graph_nodes)), original, 0) - - np.testing.assert_array_equal(seg_arr, expected) - assert unique_vals == graph_nodes - - else: - if seg_relabel == "lineage": - label_key = tracks.features.lineage_key - else: - label_key = tracks.features.tracklet_key - - labels = tracks.graph.node_attrs(attr_keys=[label_key])[label_key].to_list() - node_to_label = dict(zip(tracks.graph.node_ids(), labels, strict=True)) - - expected = np.zeros_like(original) - - for n in graph_nodes: - expected[original == n] = node_to_label[n] - - np.testing.assert_array_equal(seg_arr, expected) - - expected_vals = {node_to_label[n] for n in graph_nodes} - assert unique_vals == expected_vals diff --git a/tests_old/import_export/test_csv_import.py b/tests_old/import_export/test_csv_import.py deleted file mode 100644 index 2e551f55..00000000 --- a/tests_old/import_export/test_csv_import.py +++ /dev/null @@ -1,745 +0,0 @@ -import numpy as np -import pandas as pd -import pytest - -from funtracks.data_model import SolutionTracks -from funtracks.import_export import tracks_from_df - - -@pytest.fixture -def simple_df_2d(): - """Simple 2D DataFrame.""" - return pd.DataFrame( - { - "time": [0, 1, 1, 2], - "y": [10.0, 20.0, 30.0, 40.0], - "x": [15.0, 25.0, 35.0, 45.0], - "id": [1, 2, 3, 4], - "parent_id": [-1, 1, 1, 2], - } - ) - - -@pytest.fixture -def df_3d(): - """3D DataFrame.""" - return pd.DataFrame( - { - "time": [0, 1, 1], - "z": [5.0, 10.0, 15.0], - "y": [10.0, 20.0, 30.0], - "x": [15.0, 25.0, 35.0], - "id": [1, 2, 3], - "parent_id": [-1, 1, 1], - } - ) - - -class TestDataFrameImportBasic: - """Test basic DataFrame import.""" - - def test_import_2d(self, simple_df_2d): - """Test importing 2D DataFrame.""" - tracks = tracks_from_df(simple_df_2d) - - # persistent-graph: tracks_from_df now returns a Tracks (SolutionTracks is a - # Tracks subclass), so the old isinstance(SolutionTracks) check no longer holds. - # assert isinstance(tracks, SolutionTracks) - assert tracks.graph.num_nodes() == 4 - assert tracks.graph.num_edges() == 3 - assert tracks.ndim == 3 - - def test_import_3d(self, df_3d): - """Test importing 3D DataFrame.""" - tracks = tracks_from_df(df_3d) - - assert tracks.ndim == 4 - assert tracks.graph.num_nodes() == 3 - # Check z coordinate - pos = tracks.get_position(1) - assert len(pos) == 3 # z, y, x - assert pos[0] == 5.0 # z - - def test_with_scale(self, simple_df_2d): - """Test importing with scale.""" - scale = [1.0, 2.0, 1.5] - tracks = tracks_from_df(simple_df_2d, scale=scale) - - assert tracks.scale == scale - - def test_node_positions(self, simple_df_2d): - """Test that node positions are correctly imported.""" - tracks = tracks_from_df(simple_df_2d) - - pos_1 = tracks.get_position(1) - assert pos_1 == [10.0, 15.0] # y, x - - pos_2 = tracks.get_position(2) - assert pos_2 == [20.0, 25.0] - - def test_edges_created(self, simple_df_2d): - """Test that edges are created from parent_id.""" - tracks = tracks_from_df(simple_df_2d) - - # Check specific edges exist - assert tracks.graph.has_edge(1, 2) - assert tracks.graph.has_edge(1, 3) - assert tracks.graph.has_edge(2, 4) - - # Check node 1 has two children (division) - assert len(list(tracks.graph.successors(1))) == 2 - - -class TestSegmentationHandling: - """Test DataFrame import with segmentation.""" - - def test_with_2d_segmentation(self, simple_df_2d): - """Test importing with 2D segmentation.""" - # Add seg_id column (required when segmentation provided) - df = simple_df_2d.copy() - df["seg_id"] = df["id"] - - seg = np.zeros((3, 100, 100), dtype=np.uint16) - seg[0, 10, 15] = 1 - seg[1, 20, 25] = 2 - seg[1, 30, 35] = 3 - seg[2, 40, 45] = 4 - - tracks = tracks_from_df(df, seg) - - assert tracks.segmentation is not None - assert tracks.segmentation.shape == (3, 100, 100) - - def test_with_2d_segmentation_no_seg_id(self, simple_df_2d): - """Test importing with 2D segmentation but no seg_id column. - - When no seg_id is provided, segmentation labels are assumed to match - node IDs, so no relabeling is needed. - """ - # simple_df_2d has position but no seg_id — node IDs equal seg labels - seg = np.zeros((3, 100, 100), dtype=np.uint16) - seg[0, 10, 15] = 1 - seg[1, 20, 25] = 2 - seg[1, 30, 35] = 3 - seg[2, 40, 45] = 4 - - tracks = tracks_from_df(simple_df_2d, seg) - - assert tracks.segmentation is not None - assert tracks.segmentation.shape == (3, 100, 100) - assert np.asarray(tracks.segmentation)[0, 10, 15] == 1 - - def test_seg_id_matches_id(self, simple_df_2d): - """Test when seg_id matches id (no relabeling needed).""" - # Add seg_id column matching id - df = simple_df_2d.copy() - df["seg_id"] = df["id"] - - seg = np.zeros((3, 100, 100), dtype=np.uint16) - seg[0, 10, 15] = 1 - seg[1, 20, 25] = 2 - seg[1, 30, 35] = 3 - seg[2, 40, 45] = 4 - - tracks = tracks_from_df(df, seg) - assert tracks.segmentation is not None - # Segmentation should not be relabeled - assert np.asarray(tracks.segmentation)[0, 10, 15] == 1 - - -class TestEdgeCases: - """Test edge cases and boundary conditions.""" - - def test_single_node(self): - """Test DataFrame with single node.""" - df = pd.DataFrame( - { - "time": [0], - "y": [10.0], - "x": [15.0], - "id": [1], - "parent_id": [-1], - } - ) - - tracks = tracks_from_df(df) - - assert tracks.graph.num_nodes() == 1 - assert tracks.graph.num_edges() == 0 - - def test_multiple_roots(self): - """Test multiple independent lineages.""" - df = pd.DataFrame( - { - "time": [0, 0, 1, 1], - "y": [10.0, 20.0, 15.0, 25.0], - "x": [15.0, 25.0, 20.0, 30.0], - "id": [1, 2, 3, 4], - "parent_id": [-1, -1, 1, 2], # Two roots - } - ) - - tracks = tracks_from_df(df) - - assert tracks.graph.num_nodes() == 4 - assert tracks.graph.num_edges() == 2 - - # Should have two root nodes - roots = [n for n in tracks.graph.node_ids() if tracks.graph.in_degree(n) == 0] - assert len(roots) == 2 - - def test_division_nan_parent(self): - """Test division where root parent_id is NaN (not -1). - - Regression test: boolean column detection must not convert parent_id - to bool when all non-null values are 1 (since 1 == True in Python). - With NaN root, parent_id=[NaN, 1, 1] would be cast to - [False, True, True], making the root's False pass the edge filter - as int(False)=0 and create a spurious edge (0, node_id). - """ - df = pd.DataFrame( - { - "time": [0, 1, 1], - "y": [10.0, 20.0, 30.0], - "x": [15.0, 25.0, 35.0], - "id": [1, 2, 3], - "parent_id": [np.nan, 1, 1], - } - ) - - tracks = tracks_from_df(df) - - assert tracks.graph.num_nodes() == 3 - assert tracks.graph.num_edges() == 2 - - children = list(tracks.graph.successors(1)) - assert set(children) == {2, 3} - - def test_division_event(self): - """Test cell division (one parent, two children).""" - df = pd.DataFrame( - { - "time": [0, 1, 1], - "y": [10.0, 20.0, 30.0], - "x": [15.0, 25.0, 35.0], - "id": [1, 2, 3], - "parent_id": [-1, 1, 1], # 1 divides into 2 and 3 - } - ) - - tracks = tracks_from_df(df) - - assert tracks.graph.num_nodes() == 3 - assert tracks.graph.num_edges() == 2 - - # Node 1 should have two children - children = list(tracks.graph.successors(1)) - assert len(children) == 2 - assert set(children) == {2, 3} - - def test_long_track(self): - """Test a long track without divisions.""" - df = pd.DataFrame( - { - "time": list(range(10)), - "y": [float(i * 10) for i in range(10)], - "x": [float(i * 10) for i in range(10)], - "id": list(range(1, 11)), - "parent_id": [-1] + list(range(1, 10)), - } - ) - - tracks = tracks_from_df(df) - - assert tracks.graph.num_nodes() == 10 - assert tracks.graph.num_edges() == 9 - - # Should form a single linear chain - roots = [n for n in tracks.graph.node_ids() if tracks.graph.in_degree(n) == 0] - assert len(roots) == 1 - - # Each non-leaf node should have exactly one child - non_leaves = [ - n for n in tracks.graph.node_ids() if tracks.graph.out_degree(n) > 0 - ] - for node in non_leaves: - assert tracks.graph.out_degree(node) == 1 - - def test_orphaned_node_raises_error(self): - """Test that node with invalid parent_id raises error.""" - df = pd.DataFrame( - { - "time": [0, 1], - "y": [10.0, 20.0], - "x": [15.0, 25.0], - "id": [1, 2], - "parent_id": [-1, 999], # Parent 999 doesn't exist - } - ) - - # tracks_from_df validates that parent exists - with pytest.raises(ValueError, match="missing nodes"): - tracks_from_df(df) - - -class TestFeatureHandling: - """Test feature computation and loading.""" - - def test_load_area_from_df(self): - """Test loading pre-computed area from DataFrame.""" - df = pd.DataFrame( - { - "time": [0, 1], - "y": [10.0, 20.0], - "x": [15.0, 25.0], - "id": [1, 2], - "parent_id": [-1, 1], - "area": [100.0, 200.0], - } - ) - - node_name_map = { - "id": "id", - "parent_id": "parent_id", - "time": "time", - "pos": ["y", "x"], - "area": "area", - } - tracks = tracks_from_df(df, node_name_map=node_name_map) - - # Area should be loaded from DataFrame - assert tracks.get_node_attr(1, "area") == 100.0 - assert tracks.get_node_attr(2, "area") == 200.0 - - def test_load_multi_value_feature_from_columns(self): - """Test loading a multi-value feature from separate columns.""" - df = pd.DataFrame( - { - "time": [0, 1], - "y": [10.0, 20.0], - "x": [15.0, 25.0], - "id": [1, 2], - "parent_id": [-1, 1], - "major_axis": [5.0, 6.0], - "minor_axis": [2.0, 3.0], - } - ) - - # Map ellipsoid_axes to a list of column names - # Position uses composite "pos" mapping - name_map = { - "id": "id", - "parent_id": "parent_id", - "time": "time", - "pos": ["y", "x"], # Composite position mapping - "ellipsoid_axes": ["major_axis", "minor_axis"], - } - - tracks = tracks_from_df(df, node_name_map=name_map) - - # The multi-value feature should be loaded as a tuple/array - axes_1 = tracks.get_node_attr(1, "ellipsoid_axes") - axes_2 = tracks.get_node_attr(2, "ellipsoid_axes") - - # Values should be combined in order (as a list) - assert list(axes_1) == [5.0, 2.0] - assert list(axes_2) == [6.0, 3.0] - - -class TestDuplicateMappings: - """Test duplicate value handling in name_map.""" - - def test_seg_id_same_as_id(self, simple_df_2d): - """Test that seg_id can map to same column as id.""" - # This is valid when segmentation labels already match node IDs - name_map = { - "id": "id", - "parent_id": "parent_id", - "time": "time", - "pos": ["y", "x"], # Composite position mapping - "seg_id": "id", # seg_id maps to same column as id - } - - tracks = tracks_from_df(simple_df_2d, node_name_map=name_map) - - # Both id and seg_id should be present with same values - assert tracks.graph.num_nodes() == 4 - for node_id in tracks.graph.node_ids(): - assert tracks.get_node_attr(node_id, "seg_id") == node_id - - def test_duplicate_mapping_with_segmentation(self, simple_df_2d): - """Test seg_id=id with actual segmentation (no relabeling needed).""" - name_map = { - "id": "id", - "parent_id": "parent_id", - "time": "time", - "pos": ["y", "x"], # Composite position mapping - "seg_id": "id", # seg_id = id - } - - seg = np.zeros((3, 100, 100), dtype=np.uint16) - seg[0, 10, 15] = 1 - seg[1, 20, 25] = 2 - seg[1, 30, 35] = 3 - seg[2, 40, 45] = 4 - - tracks = tracks_from_df(simple_df_2d, segmentation=seg, node_name_map=name_map) - - assert tracks.segmentation is not None - # Segmentation should not be relabeled since seg_id == id - assert np.asarray(tracks.segmentation)[0, 10, 15] == 1 - - -class TestValidationErrors: - """Test that invalid data raises appropriate errors.""" - - def test_non_unique_ids(self): - """Test that non-unique IDs raise error.""" - df = pd.DataFrame( - { - "time": [0, 1], - "y": [10.0, 20.0], - "x": [15.0, 25.0], - "id": [1, 1], # Duplicate! - "parent_id": [-1, -1], - } - ) - - with pytest.raises(ValueError, match="unique"): - tracks_from_df(df) - - def test_missing_required_column(self): - """Test that missing required columns raise error.""" - df = pd.DataFrame( - { - # Missing 'time' column - "y": [10.0, 20.0], - "x": [15.0, 25.0], - "id": [1, 2], - "parent_id": [-1, 1], - } - ) - - # tracks_from_df validates required columns - with pytest.raises(ValueError, match="None values"): - tracks_from_df(df) - - def test_pos_mapping_dimension_mismatch(self): - """Test that pos mapping dimension must match segmentation ndim. - - When the position mapping has more coordinates than the segmentation - has spatial dimensions, an error is raised during validation. - """ - df = pd.DataFrame( - { - "time": [0, 1], - "y": [10.0, 20.0], - "x": [15.0, 25.0], - "id": [1, 2], - "parent_id": [-1, 1], - "seg_id": [1, 2], - } - ) - - # 2D segmentation (ndim=3: time + 2 spatial) - seg = np.zeros((2, 100, 100), dtype=np.uint16) - seg[0, 10, 15] = 1 - seg[1, 20, 25] = 2 - - # But provide 3D position mapping (3 coords but seg is 2D spatial) - name_map = { - "id": "id", - "parent_id": "parent_id", - "time": "time", - "pos": ["y", "x", "y"], # 3 coords but seg is 2D - "seg_id": "seg_id", - } - - # The mismatch is caught during validation (pos has spatial_dims=True) - with pytest.raises(ValueError, match="pos.*has 3 values.*2 spatial dimensions"): - tracks_from_df(df, segmentation=seg, node_name_map=name_map) - - -class TestSpatialDimsValidation: - """Test validation of features with spatial_dims=True.""" - - def test_ellipse_axis_radii_dimension_mismatch_with_segmentation(self): - """Test that ellipse_axis_radii with wrong number of values raises error. - - When segmentation is provided, features with spatial_dims=True must - have num_values matching the number of spatial dimensions. - """ - df = pd.DataFrame( - { - "time": [0, 1], - "y": [10.0, 20.0], - "x": [15.0, 25.0], - "id": [1, 2], - "parent_id": [-1, 1], - "seg_id": [1, 2], - "major_axis": [5.0, 6.0], - "semi_minor_axis": [3.0, 4.0], - "minor_axis": [2.0, 3.0], - } - ) - - # 2D segmentation (ndim=3: time + 2 spatial dims) - seg = np.zeros((2, 100, 100), dtype=np.uint16) - seg[0, 10, 15] = 1 - seg[1, 20, 25] = 2 - - # Provide 3D ellipse_axis_radii mapping (3 values but seg is 2D spatial) - name_map = { - "id": "id", - "parent_id": "parent_id", - "time": "time", - "pos": ["y", "x"], - "seg_id": "seg_id", - "ellipse_axis_radii": ["major_axis", "semi_minor_axis", "minor_axis"], - } - - with pytest.raises(ValueError, match="ellipse_axis_radii.*has 3 values"): - tracks_from_df(df, segmentation=seg, node_name_map=name_map) - - def test_ellipse_axis_radii_correct_dimensions(self): - """Test that ellipse_axis_radii with correct dimensions passes validation.""" - df = pd.DataFrame( - { - "time": [0, 1], - "y": [10.0, 20.0], - "x": [15.0, 25.0], - "id": [1, 2], - "parent_id": [-1, 1], - "seg_id": [1, 2], - "major_axis": [5.0, 6.0], - "minor_axis": [2.0, 3.0], - } - ) - - # 2D segmentation (ndim=3: time + 2 spatial dims) - seg = np.zeros((2, 100, 100), dtype=np.uint16) - seg[0, 10, 15] = 1 - seg[1, 20, 25] = 2 - - # Provide 2D ellipse_axis_radii mapping (2 values matching 2D spatial) - name_map = { - "id": "id", - "parent_id": "parent_id", - "time": "time", - "pos": ["y", "x"], - "seg_id": "seg_id", - "ellipse_axis_radii": ["major_axis", "minor_axis"], - } - - # Should not raise - tracks = tracks_from_df(df, segmentation=seg, node_name_map=name_map) - assert tracks.ndim == 3 - - def test_pos_spatial_dims_mismatch_with_segmentation(self): - """Test that position with wrong number of values raises error. - - Position has spatial_dims=True, so the validation catches mismatches - between pos mapping and segmentation dimensions. - """ - df = pd.DataFrame( - { - "time": [0, 1], - "z": [5.0, 10.0], - "y": [10.0, 20.0], - "x": [15.0, 25.0], - "id": [1, 2], - "parent_id": [-1, 1], - "seg_id": [1, 2], - } - ) - - # 2D segmentation (ndim=3: time + 2 spatial dims) - seg = np.zeros((2, 100, 100), dtype=np.uint16) - seg[0, 10, 15] = 1 - seg[1, 20, 25] = 2 - - # Provide 3D position mapping (3 coords but seg is 2D spatial) - name_map = { - "id": "id", - "parent_id": "parent_id", - "time": "time", - "pos": ["z", "y", "x"], # 3 coords but seg is 2D - "seg_id": "seg_id", - } - - with pytest.raises(ValueError, match="pos.*has 3 values"): - tracks_from_df(df, segmentation=seg, node_name_map=name_map) - - def test_spatial_dims_mismatch_without_segmentation(self): - """Test that spatial_dims validation uses position as fallback. - - When no segmentation is provided, position mapping is used as the - source of truth for spatial dimensions. - """ - df = pd.DataFrame( - { - "time": [0, 1], - "y": [10.0, 20.0], - "x": [15.0, 25.0], - "id": [1, 2], - "parent_id": [-1, 1], - "major_axis": [5.0, 6.0], - "semi_minor_axis": [3.0, 4.0], - "minor_axis": [2.0, 3.0], - } - ) - - # Provide 3D ellipse_axis_radii but 2D position (no segmentation) - name_map = { - "id": "id", - "parent_id": "parent_id", - "time": "time", - "pos": ["y", "x"], # 2D position - "ellipse_axis_radii": ["major_axis", "semi_minor_axis", "minor_axis"], # 3D - } - - # Should raise - ellipse_axis_radii has 3 values but expected 2 - with pytest.raises( - ValueError, match="ellipse_axis_radii.*has 3 values.*expected 2" - ): - tracks_from_df(df, node_name_map=name_map) - - def test_spatial_dims_consistent_without_segmentation(self): - """Test that consistent spatial_dims features pass without segmentation.""" - df = pd.DataFrame( - { - "time": [0, 1], - "y": [10.0, 20.0], - "x": [15.0, 25.0], - "id": [1, 2], - "parent_id": [-1, 1], - "major_axis": [5.0, 6.0], - "minor_axis": [2.0, 3.0], - } - ) - - # Both pos and ellipse_axis_radii have 2 values (consistent) - name_map = { - "id": "id", - "parent_id": "parent_id", - "time": "time", - "pos": ["y", "x"], - "ellipse_axis_radii": ["major_axis", "minor_axis"], - } - - # Should not raise - dimensions are consistent - tracks = tracks_from_df(df, node_name_map=name_map) - assert tracks is not None - - def test_empty_list_in_name_map_removed(self): - """Test that empty lists in name_map are removed during preprocessing.""" - df = pd.DataFrame( - { - "time": [0, 1], - "y": [10.0, 20.0], - "x": [15.0, 25.0], - "id": [1, 2], - "parent_id": [-1, 1], - } - ) - - # Include an empty list for ellipse_axis_radii - name_map = { - "id": "id", - "parent_id": "parent_id", - "time": "time", - "pos": ["y", "x"], - "ellipse_axis_radii": [], # Empty list - should be removed - } - - # Should not raise - empty list is removed during preprocessing - tracks = tracks_from_df(df, node_name_map=name_map) - assert tracks is not None - # The empty mapping should not result in a feature being added - assert "ellipse_axis_radii" not in tracks.graph.node_attr_keys() - - def test_import_without_position_with_segmentation(self): - """Test that position can be omitted when segmentation is provided. - - When segmentation is provided, position can be computed from centroids, - so it's not required in the name_map. - """ - # Create a simple 2D+T segmentation with two labeled regions - segmentation = np.zeros((2, 10, 10), dtype=np.int32) - segmentation[0, 2:5, 2:5] = 1 # Label 1 at t=0 - segmentation[1, 4:7, 4:7] = 2 # Label 2 at t=1 - - df = pd.DataFrame( - { - "time": [0, 1], - "id": [1, 2], - "parent_id": [-1, 1], - } - ) - - # No position in name_map - should be computed from segmentation - name_map = { - "id": "id", - "parent_id": "parent_id", - "time": "time", - # No "pos" mapping - } - - tracks = tracks_from_df(df, node_name_map=name_map, segmentation=segmentation) - assert tracks is not None - - # Position should be computed from segmentation centroids - assert "pos" in tracks.graph.node_attr_keys() - pos_1 = tracks.graph.nodes[1]["pos"] - # Centroid of 3x3 region at [2:5, 2:5] is approximately [3, 3] - np.testing.assert_array_almost_equal(pos_1, [3.0, 3.0], decimal=0) - - def test_import_without_position_without_segmentation_fails(self): - """Test that position is required when no segmentation is provided.""" - df = pd.DataFrame( - { - "time": [0, 1], - "id": [1, 2], - "parent_id": [-1, 1], - } - ) - - # No position in name_map and no segmentation - name_map = { - "id": "id", - "parent_id": "parent_id", - "time": "time", - } - - with pytest.raises(ValueError, match="pos.*mapping.*segmentation"): - tracks_from_df(df, node_name_map=name_map) - - -def test_csv_preserves_tracklet_ids_with_rename(): - """Importing a CSV with 'Tracklet ID' column mapped to 'tracklet_id' - must preserve the original values instead of silently recomputing.""" - df = pd.DataFrame( - { - "ID": [1, 2, 3, 10, 11, 12], - "Time": [0, 1, 2, 0, 1, 2], - "y": [10.0, 11.0, 12.0, 50.0, 51.0, 52.0], - "x": [10.0, 11.0, 12.0, 50.0, 51.0, 52.0], - "Parent ID": [-1, 1, 2, -1, 10, 11], - "Tracklet ID": [42, 42, 42, 99, 99, 99], - } - ) - name_map = { - "id": "ID", - "time": "Time", - "pos": ["y", "x"], - "parent_id": "Parent ID", - "tracklet_id": "Tracklet ID", - } - tracks = tracks_from_df(df, node_name_map=name_map) - - expected = dict(zip(df["ID"], df["Tracklet ID"], strict=True)) - for nid in df["ID"]: - assert tracks.get_track_id(nid) == expected[nid], ( - f"node {nid}: expected tracklet {expected[nid]}, " - f"got {tracks.get_track_id(nid)} (silent recompute?)" - ) diff --git a/tests_old/import_export/test_export_to_geff.py b/tests_old/import_export/test_export_to_geff.py deleted file mode 100644 index d78cd4d4..00000000 --- a/tests_old/import_export/test_export_to_geff.py +++ /dev/null @@ -1,456 +0,0 @@ -import numpy as np -import polars as pl -import pytest -import tifffile -import zarr - -from funtracks.data_model import SolutionTracks, Tracks -from funtracks.import_export import export_to_geff, import_from_geff, write_to_geff - - -def _assert_valid_geff_export(export_dir, expected_num_nodes=None): - """Assert basic export correctness and return the opened zarr Group.""" - z = zarr.open((export_dir / "tracks.geff").as_posix(), mode="r") - assert isinstance(z, zarr.Group) - - attrs = dict(z.attrs) - assert "geff" in attrs - assert "axes" in attrs["geff"] - for ax in attrs["geff"]["axes"]: - assert ax["scale"] is not None - - if expected_num_nodes is not None: - assert len(z["nodes/ids"][:]) == expected_num_nodes - - return z - - -# --- Segmentation export --- - - -@pytest.mark.parametrize("ndim", [3, 4]) -@pytest.mark.parametrize("seg_relabel", ["tracklet", "lineage", None]) -def test_export_segmentation_relabel(get_tracks, ndim, seg_relabel, tmp_path): - """Test segmentation export with each relabel strategy.""" - tracks = get_tracks(ndim=ndim, with_seg=True, is_solution=True) - - export_dir = tmp_path / "export" - export_dir.mkdir() - export_to_geff(tracks, export_dir, seg_relabel=seg_relabel) - - z = _assert_valid_geff_export(export_dir, tracks.graph.num_nodes()) - - # Segmentation file must exist - seg_path = export_dir / "segmentation" - seg_zarr = zarr.open(str(seg_path), mode="r") - assert isinstance(seg_zarr, zarr.Array) - assert seg_zarr.shape == tracks.segmentation.shape - - # Verify pixel values match relabel strategy - unique_vals = set(seg_zarr[:].flatten()) - {0} - if seg_relabel is not None: - if seg_relabel == "lineage": - label_key = tracks.features.lineage_key - else: - label_key = tracks.features.tracklet_key - label_vals = set( - tracks.graph.node_attrs(attr_keys=[label_key])[label_key].to_list() - ) - assert unique_vals == label_vals - else: - assert unique_vals == set(tracks.graph.node_ids()) - - # segmentation_shape must be in metadata - attrs = dict(z.attrs) - assert "segmentation_shape" in attrs - assert tuple(attrs["segmentation_shape"]) == tracks.segmentation.shape - - -def test_export_no_segmentation_saved(get_tracks, tmp_path): - """Test that save_segmentation=False suppresses segmentation file.""" - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - - export_dir = tmp_path / "export" - export_dir.mkdir() - export_to_geff(tracks, export_dir, save_segmentation=False) - - z = _assert_valid_geff_export(export_dir, tracks.graph.num_nodes()) - - assert not (export_dir / "segmentation").exists() - - # segmentation_shape is still written (from graph metadata, not from the file) - attrs = dict(z.attrs) - assert "segmentation_shape" in attrs - - -def test_export_without_seg_on_tracks(get_tracks, tmp_path): - """Test export when tracks have no segmentation at all.""" - tracks = get_tracks(ndim=3, with_seg=False, is_solution=True) - - export_dir = tmp_path / "export" - export_dir.mkdir() - export_to_geff(tracks, export_dir) - - z = _assert_valid_geff_export(export_dir, tracks.graph.num_nodes()) - - assert not (export_dir / "segmentation").exists() - - attrs = dict(z.attrs) - assert "segmentation_shape" not in attrs - - -@pytest.mark.parametrize("seg_relabel", ["tracklet", "lineage"]) -@pytest.mark.skip( - reason="old-API behavior removed in persistent-graph: there is no non-solution " - "Tracks, so relabel-on-non-solution no longer raises." -) -def test_export_seg_relabel_non_solution_raises(get_tracks, seg_relabel, tmp_path): - """Relabeling by tracklet/lineage on non-solution tracks raises ValueError.""" - tracks = get_tracks(ndim=3, with_seg=True, is_solution=False) - - export_dir = tmp_path / "export" - export_dir.mkdir() - with pytest.raises(ValueError): - export_to_geff(tracks, export_dir, seg_relabel=seg_relabel) - - -def test_export_segmentation_non_solution(get_tracks, tmp_path): - """Non-solution tracks export segmentation fine with seg_relabel=None.""" - tracks = get_tracks(ndim=3, with_seg=True, is_solution=False) - - export_dir = tmp_path / "export" - export_dir.mkdir() - export_to_geff(tracks, export_dir, seg_relabel=None) - - z = _assert_valid_geff_export(export_dir, tracks.graph.num_nodes()) - - # No relabel: segmentation pixels keep original node_ids - seg_zarr = zarr.open(str(export_dir / "segmentation"), mode="r") - assert isinstance(seg_zarr, zarr.Array) - assert seg_zarr.shape == tracks.segmentation.shape - assert set(seg_zarr[:].flatten()) - {0} == set(tracks.graph.node_ids()) - - attrs = dict(z.attrs) - assert "segmentation_shape" in attrs - assert tuple(attrs["segmentation_shape"]) == tracks.segmentation.shape - - -# --- Position attribute splitting --- - - -@pytest.mark.parametrize("ndim", [3, 4]) -@pytest.mark.parametrize("is_solution", [True, False]) -def test_export_split_position_attrs(get_graph, ndim, is_solution, tmp_path): - """Test export with split (list) position attributes.""" - graph = get_graph(ndim, is_solution=is_solution, with_seg=False) - - pos_keys = ["y", "x"] if ndim == 3 else ["z", "y", "x"] - for key in pos_keys: - graph.add_node_attr_key(key, default_value=0.0, dtype=pl.Float64) - for node in graph.node_ids(): - pos = graph.nodes[node]["pos"] - for i, key in enumerate(pos_keys): - graph.nodes[node][key] = pos[i] - graph.remove_node_attr_key("pos") - - tracks_cls = SolutionTracks if is_solution else Tracks - tracks = tracks_cls( - graph, - time_attr="t", - pos_attr=pos_keys, - tracklet_attr="track_id" if is_solution else None, - ndim=ndim, - ) - - export_dir = tmp_path / "export" - export_dir.mkdir() - export_to_geff(tracks, export_dir, save_segmentation=False) - - z = _assert_valid_geff_export(export_dir, tracks.graph.num_nodes()) - - # Verify axis names include the split position keys - axes = dict(z.attrs)["geff"]["axes"] - axis_names = [ax["name"] for ax in axes] - for key in pos_keys: - assert key in axis_names - - -# --- Node subset export --- - - -@pytest.mark.parametrize("ndim", [3, 4]) -def test_export_node_subset(get_tracks, ndim, tmp_path): - """Test exporting a subset of nodes includes ancestors.""" - tracks = get_tracks(ndim=ndim, with_seg=True, is_solution=True) - - export_dir = tmp_path / "export" - export_dir.mkdir() - - # Nodes 4 and 6 requested; ancestors 1 and 3 should be included automatically - node_ids = [4, 6] - export_to_geff(tracks, export_dir, node_ids=node_ids, seg_relabel=None) - - expected_graph_nodes = np.array([1, 3, 4, 6]) - z = _assert_valid_geff_export(export_dir, len(expected_graph_nodes)) - - node_ids_array = z["nodes/ids"][:] - assert np.array_equal(np.sort(node_ids_array), expected_graph_nodes) - - # Segmentation filtered to subset nodes (seg_relabel=None for direct ID check) - seg_path = export_dir / "segmentation" - seg_zarr = zarr.open(str(seg_path), mode="r") - assert seg_zarr.shape == tracks.segmentation.shape - - exported = np.asarray(seg_zarr[:]) - original = np.asarray(tracks.segmentation[:]) - graph_nodes = set(expected_graph_nodes) - - expected = np.where(np.isin(original, list(graph_nodes)), original, 0) - np.testing.assert_array_equal(exported, expected) - assert set(exported.flatten()) - {0} == graph_nodes - - -def test_export_node_subset_seg_relabel(get_tracks, tmp_path): - """Test subset export with relabeled segmentation.""" - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - - export_dir = tmp_path / "export" - export_dir.mkdir() - - node_ids = [4, 6] - export_to_geff(tracks, export_dir, node_ids=node_ids, seg_relabel="tracklet") - - expected_graph_nodes = np.array([1, 3, 4, 6]) - _assert_valid_geff_export(export_dir, len(expected_graph_nodes)) - - seg_zarr = zarr.open(str(export_dir / "segmentation"), mode="r") - exported = np.asarray(seg_zarr[:]) - original = np.asarray(tracks.segmentation[:]) - - label_key = tracks.features.tracklet_key - labels = tracks.graph.node_attrs(attr_keys=[label_key])[label_key] - node_to_label = dict(zip(tracks.graph.node_ids(), labels.to_list(), strict=True)) - - graph_nodes = set(expected_graph_nodes) - expected = np.zeros_like(original) - for n in graph_nodes: - expected[original == n] = node_to_label[n] - np.testing.assert_array_equal(exported, expected) - - unique_vals = set(exported.flatten()) - {0} - expected_vals = {node_to_label[n] for n in graph_nodes} - assert unique_vals == expected_vals - - -def test_export_node_subset_without_seg(get_tracks, tmp_path): - """Test subset export when tracks have no segmentation.""" - tracks = get_tracks(ndim=3, with_seg=False, is_solution=True) - - export_dir = tmp_path / "export" - export_dir.mkdir() - - export_to_geff(tracks, export_dir, node_ids=[4, 6]) - - expected_graph_nodes = np.array([1, 3, 4, 6]) - _assert_valid_geff_export(export_dir, len(expected_graph_nodes)) - - assert not (export_dir / "segmentation").exists() - - -# --- Overwrite and error handling --- - - -def test_export_overwrite(get_tracks, tmp_path): - """Test export with overwrite=True into non-empty directory.""" - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - - export_dir = tmp_path / "export" - export_dir.mkdir() - (export_dir / "existing_file.txt").write_text("already here") - - export_to_geff(tracks, export_dir, overwrite=True) - - _assert_valid_geff_export(export_dir, tracks.graph.num_nodes()) - - # Segmentation is still written correctly alongside the overwritten dir - seg_zarr = zarr.open(str(export_dir / "segmentation"), mode="r") - assert isinstance(seg_zarr, zarr.Array) - assert seg_zarr.shape == tracks.segmentation.shape - - -def test_export_non_directory_raises(get_tracks, tmp_path): - """Test that exporting to a file path (not a directory) raises an error.""" - tracks = get_tracks(ndim=3, with_seg=False, is_solution=True) - - file_path = tmp_path / "not_a_dir" - file_path.write_text("test") - - with pytest.raises(Exception): # noqa: B017 - export_to_geff(tracks, file_path) - - -# --- Metadata --- - - -@pytest.mark.parametrize("ndim", [3, 4]) -@pytest.mark.parametrize("with_seg", [True, False]) -def test_export_metadata(get_tracks, ndim, with_seg, tmp_path): - """Test axes structure, segmentation_shape, and FeatureDict in metadata.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - export_dir = tmp_path / "export" - export_dir.mkdir() - export_to_geff(tracks, export_dir, save_segmentation=with_seg) - - z = _assert_valid_geff_export(export_dir, tracks.graph.num_nodes()) - attrs = dict(z.attrs) - - # Correct number of axes - axes = attrs["geff"]["axes"] - assert len(axes) == ndim - expected_types = ( - ["time", "space", "space"] if ndim == 3 else ["time", "space", "space", "space"] - ) - assert [ax["type"] for ax in axes] == expected_types - - # segmentation_shape present iff with_seg - if with_seg: - assert "segmentation_shape" in attrs - assert tuple(attrs["segmentation_shape"]) == tracks.segmentation.shape - else: - assert "segmentation_shape" not in attrs - - # FeatureDict stored in extra.funtracks - assert "funtracks" in attrs["geff"].get("extra", {}) - assert "features" in attrs["geff"]["extra"]["funtracks"] - - -# --- Tiff segmentation export (unchanged) --- - - -@pytest.mark.parametrize("ndim", [3, 4], ids=["2d", "3d"]) -def test_export_to_geff_seg_tiff(get_tracks, ndim, tmp_path): - """Test that segmentation can be exported as tiff alongside the geff graph.""" - tracks = get_tracks(ndim=ndim, with_seg=True, is_solution=True) - export_dir = tmp_path / "export" - export_dir.mkdir() - - export_to_geff(tracks, export_dir, seg_file_format="tiff") - - # tiff is saved next to (sibling of) the geff directory, not inside it - assert (tmp_path / "segmentation.tif").exists() - assert not (export_dir / "segmentation.tif").exists() - assert not (export_dir / "segmentation").exists() - - seg_arr = tifffile.imread(str(tmp_path / "segmentation.tif")) - assert seg_arr.shape == tracks.segmentation.shape - - # values should be tracklet_ids (default seg_relabel="tracklet") - unique_vals = set(seg_arr.flatten()) - {0} - label_key = tracks.features.tracklet_key - track_ids = set(tracks.graph.node_attrs(attr_keys=[label_key])[label_key].to_list()) - assert unique_vals == track_ids - - # Check metadata references the tiff path with ../../ prefix (sibling of geff dir) - z = zarr.open((export_dir / "tracks.geff").as_posix(), mode="r") - related = dict(z.attrs)["geff"].get("related_objects", []) - assert any(obj["path"] == "../../segmentation.tif" for obj in related) - - -# --- write_to_geff --- - - -@pytest.mark.parametrize("ndim", [3, 4]) -@pytest.mark.parametrize("with_seg", [False, True]) -def test_write_to_geff_roundtrip(get_tracks, ndim, with_seg, tmp_path): - """write_to_geff then import_from_geff recovers the tracks.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - geff_path = tmp_path / "my_tracks.geff" - write_to_geff(tracks, geff_path) - - loaded = import_from_geff(geff_path) - - assert loaded.graph.num_nodes() == tracks.graph.num_nodes() - assert loaded.graph.num_edges() == tracks.graph.num_edges() - assert set(loaded.graph.node_ids()) == set(tracks.graph.node_ids()) - assert loaded.features.dump_json() == tracks.features.dump_json() - - if with_seg: - assert loaded.segmentation is not None - assert loaded.segmentation.shape == tracks.segmentation.shape - np.testing.assert_array_equal( - np.asarray(loaded.segmentation[:]), np.asarray(tracks.segmentation[:]) - ) - - -def test_write_to_geff_no_parent_container(get_tracks, tmp_path): - """write_to_geff writes directly to the path — no parent .zgroup or - tracks.geff subfolder.""" - tracks = get_tracks(ndim=3, with_seg=False, is_solution=True) - - geff_path = tmp_path / "my_tracks.geff" - write_to_geff(tracks, geff_path) - - # The store is at the given path, not nested inside it - z = zarr.open(str(geff_path), mode="r") - assert "geff" in dict(z.attrs) - assert "nodes" in z - - # No .zgroup/.zattrs at the parent level (tmp_path is not a zarr group) - assert not (tmp_path / ".zgroup").exists() - assert not (tmp_path / ".zattrs").exists() - - -def test_write_to_geff_overwrite(get_tracks, tmp_path): - """write_to_geff with overwrite=True replaces existing store.""" - tracks = get_tracks(ndim=3, with_seg=False, is_solution=True) - - geff_path = tmp_path / "my_tracks.geff" - write_to_geff(tracks, geff_path) - write_to_geff(tracks, geff_path, overwrite=True) - - loaded = import_from_geff(geff_path) - assert loaded.graph.num_nodes() == tracks.graph.num_nodes() - - -def test_write_to_geff_no_overwrite_raises(get_tracks, tmp_path): - """write_to_geff with overwrite=False raises on existing store.""" - tracks = get_tracks(ndim=3, with_seg=False, is_solution=True) - - geff_path = tmp_path / "my_tracks.geff" - write_to_geff(tracks, geff_path) - - with pytest.raises(FileExistsError): - write_to_geff(tracks, geff_path, overwrite=False) - - -def test_write_to_geff_metadata(get_tracks, tmp_path): - """write_to_geff stores axes and FeatureDict metadata.""" - tracks = get_tracks(ndim=3, with_seg=False, is_solution=True) - - geff_path = tmp_path / "my_tracks.geff" - write_to_geff(tracks, geff_path) - - z = zarr.open(str(geff_path), mode="r") - attrs = dict(z.attrs) - - axes = attrs["geff"]["axes"] - assert len(axes) == 3 - assert [ax["type"] for ax in axes] == ["time", "space", "space"] - - assert "funtracks" in attrs["geff"].get("extra", {}) - assert "features" in attrs["geff"]["extra"]["funtracks"] - - -def test_write_to_geff_segmentation_shape(get_tracks, tmp_path): - """write_to_geff writes segmentation_shape when masks are present.""" - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - - geff_path = tmp_path / "my_tracks.geff" - write_to_geff(tracks, geff_path) - - z = zarr.open(str(geff_path), mode="r") - attrs = dict(z.attrs) - assert "segmentation_shape" in attrs - assert tuple(attrs["segmentation_shape"]) == tracks.segmentation.shape diff --git a/tests_old/import_export/test_import_from_geff.py b/tests_old/import_export/test_import_from_geff.py deleted file mode 100644 index c60ad167..00000000 --- a/tests_old/import_export/test_import_from_geff.py +++ /dev/null @@ -1,1191 +0,0 @@ -import dask.array as da -import numpy as np -import pytest -import tifffile -import zarr -from geff.testing.data import create_mock_geff - -from funtracks.data_model import SolutionTracks -from funtracks.import_export import export_to_geff, import_from_geff -from funtracks.import_export.geff._import import GeffTracksBuilder, import_graph_from_geff -from funtracks.utils.tracksdata_utils import create_empty_graphview_graph - - -@pytest.fixture -def valid_geff(): - store, memory_geff = create_mock_geff( - node_id_dtype="uint", - node_axis_dtypes={"position": "float64", "time": "int64"}, - directed=True, - num_nodes=5, - num_edges=2, - include_t=True, - include_z=False, - include_y=True, - include_x=True, - extra_node_props={ - "track_id": np.arange(5), - "seg_id": np.array([10, 20, 30, 40, 50]), - "lineage_id": np.arange(5), - "area": np.array([20, 41, 42, 776, 21]), - "circ": np.array([0.2, 0.1, 0.5, 0.3, 0.45]), - "random_feature": np.array(["a", "b", "c", "d", "e"]), - "random_feature2": np.array(["a", "b", "c", "d", "e"]), - }, - ) - return store, memory_geff - - -@pytest.fixture -def invalid_geff(): - invalid_store, invalid_memory_geff = create_mock_geff( - node_id_dtype="uint", - node_axis_dtypes={"position": "float64", "time": "int64"}, - directed=True, - num_nodes=5, - num_edges=2, - include_t=True, - include_z=False, - include_y=True, - include_x=True, - extra_node_props={ - "track_id": np.arange(5), - "seg_id": np.array([10.453, 20.23, 30.56, 40.78, 50.92]), - "lineage_id": np.arange(5), - "area": np.array([20, 41, 42, 776, 21]), - }, - ) - return invalid_store, invalid_memory_geff - - -@pytest.fixture -def valid_segmentation(): - shape = (6, 600, 200) - seg = np.zeros(shape, dtype=int) - - times = [1, 2, 3, 4, 5] - x = [1.0, 0.775, 0.55, 0.325, 0.1] - y = [100, 200, 300, 400, 500] - scale = [1, 1, 100] - seg_ids = np.array([10, 20, 30, 40, 50]) - - for t, y_val, x_f, seg_id in zip(times, y, x, seg_ids, strict=False): - x = int(x_f * scale[2]) - seg[t, y_val, x] = seg_id - return seg - - -def test_import_graph_from_geff_renames_keys_to_standard(valid_geff): - """Test that import_graph_from_geff renames custom GEFF keys to standard keys. - - This is a key architectural requirement: import_graph_from_geff should return - an InMemoryGeff where all node_props keys have been renamed from custom GEFF - property names to standard funtracks keys, using the provided node_name_map. - """ - store, original_geff = valid_geff - - # Define node_name_map: standard_key -> custom_geff_key - node_name_map = { - "time": "t", # standard key "time" maps to GEFF key "t" - "y": "y", # standard key "y" maps to GEFF key "y" - "x": "x", # standard key "x" maps to GEFF key "x" - "track_id": "track_id", # standard key "track_id" maps to GEFF key "track_id" - "seg_id": "seg_id", # standard key "seg_id" maps to GEFF key "seg_id" - "circularity": "circ", # standard key "circularity" maps to GEFF key "circ" - } - - # Call import_graph_from_geff - in_memory_geff, position_attr, ndims = import_graph_from_geff(store, node_name_map) - - # Assert the InMemoryGeff has standard keys, NOT custom GEFF keys - node_props = in_memory_geff["node_props"] - - # Standard keys should be present - assert "time" in node_props, "Standard key 'time' should be present" - assert "y" in node_props, "Standard key 'y' should be present" - assert "x" in node_props, "Standard key 'x' should be present" - assert "track_id" in node_props, "Standard key 'track_id' should be present" - assert "seg_id" in node_props, "Standard key 'seg_id' should be present" - assert "circularity" in node_props, "Standard key 'circularity' should be present" - - # Custom GEFF keys should NOT be present - assert "t" not in node_props, "Custom GEFF key 't' should have been renamed to 'time'" - assert "circ" not in node_props, ( - "Custom GEFF key 'circ' should have been renamed to 'circularity'" - ) - - # Verify data integrity - values should be preserved - assert len(node_props["time"]["values"]) == 5, "Should have 5 time values" - assert len(node_props["track_id"]["values"]) == 5, "Should have 5 track_id values" - np.testing.assert_array_equal( - node_props["track_id"]["values"][:], - np.arange(5), - err_msg="track_id values should be preserved after renaming", - ) - np.testing.assert_array_almost_equal( - node_props["circularity"]["values"][:], - np.array([0.2, 0.1, 0.5, 0.3, 0.45]), - err_msg="circularity values should be preserved after renaming from 'circ'", - ) - - # Verify return values - assert position_attr == ["y", "x"], "Should return standard position keys" - assert ndims == 3, "Should be 3D (time + 2 spatial dims)" - - -def test_import_graph_from_geff_loads_custom_features(valid_geff): - """Test that custom features can be loaded by including them in node_name_map. - - Custom features (not in the standard set) should be loaded when included - in the node_name_map. The key should remain as the standard key (which equals - the GEFF key in this case). - """ - store, original_geff = valid_geff - - # Include custom features in node_name_map - node_name_map = { - "time": "t", - "y": "y", - "x": "x", - "random_feature": "random_feature", # Custom feature: maps to itself - "random_feature2": "random_feature2", # Another custom feature - } - - # Call import_graph_from_geff - in_memory_geff, position_attr, ndims = import_graph_from_geff(store, node_name_map) - - node_props = in_memory_geff["node_props"] - - # Custom features should be present with standard keys - assert "random_feature" in node_props, ( - "Custom feature 'random_feature' should be loaded" - ) - assert "random_feature2" in node_props, ( - "Custom feature 'random_feature2' should be loaded" - ) - - # Verify data integrity - np.testing.assert_array_equal( - node_props["random_feature"]["values"][:], - np.array(["a", "b", "c", "d", "e"]), - err_msg="random_feature values should be preserved", - ) - np.testing.assert_array_equal( - node_props["random_feature2"]["values"][:], - np.array(["a", "b", "c", "d", "e"]), - err_msg="random_feature2 values should be preserved", - ) - - -def test_import_graph_from_geff_custom_feature_with_different_name(valid_geff): - """Test that custom features can be renamed using node_name_map. - - A custom feature with a GEFF name can be renamed to a different standard key - using the node_name_map. - """ - store, original_geff = valid_geff - - # Rename "circ" to "my_custom_circularity" - node_name_map = { - "time": "t", - "y": "y", - "x": "x", - "my_custom_circularity": "circ", # Rename circ to custom name - } - - # Call import_graph_from_geff - in_memory_geff, position_attr, ndims = import_graph_from_geff(store, node_name_map) - - node_props = in_memory_geff["node_props"] - - # The custom key should be present - assert "my_custom_circularity" in node_props, ( - "Custom renamed feature should be present" - ) - - # The original GEFF key should NOT be present - assert "circ" not in node_props, "Original GEFF key should be renamed" - - # Verify data integrity - np.testing.assert_array_almost_equal( - node_props["my_custom_circularity"]["values"][:], - np.array([0.2, 0.1, 0.5, 0.3, 0.45]), - err_msg="Values should be preserved after custom renaming", - ) - - -def test_import_graph_from_geff_edge_name_map_none(valid_geff): - """Test that edge_name_map=None loads all edge properties. - - When edge_name_map is None, all edge properties should be loaded with their - original GEFF names (no renaming). - """ - store, original_geff = valid_geff - - # Define node name map - node_name_map = { - "time": "t", - "y": "y", - "x": "x", - } - - # Call import_graph_from_geff without edge_name_map (defaults to None) - in_memory_geff, position_attr, ndims = import_graph_from_geff( - store, node_name_map, edge_name_map=None - ) - - # Should load successfully - assert "node_props" in in_memory_geff - assert "edge_props" in in_memory_geff - # The fixture has no edge properties, so edge_props should be empty - assert in_memory_geff["edge_props"] == {} - - -def test_none_in_name_map(valid_geff): - """Test that None values in required t/y/x attributes are caught""" - - store, _ = valid_geff - # None value for required field should raise error - name_map = {"time": None, "pos": ["y", "x"]} - with pytest.raises(ValueError, match="None values"): - import_from_geff(store, name_map) - - -def test_duplicate_values_in_name_map(valid_geff): - """Test that duplicate values in name_map are allowed.""" - store, _ = valid_geff - - # Duplicate values should be allowed - each standard key gets a copy of the data - node_name_map = {"time": "t", "pos": ["y", "x"], "seg_id": "t"} - - # Should not raise - seg_id maps to same source as time - tracks = import_from_geff(store, node_name_map) - - # Both time and seg_id should be present with same values - for node_id in tracks.graph.node_ids(): - assert tracks.get_node_attr(node_id, "seg_id") == tracks.get_node_attr( - node_id, "t" - ) - - -def test_segmentation_axes_mismatch(valid_geff, tmp_path): - """Test checking if number of dimensions match and if coordinates are within - bounds.""" - - store, _ = valid_geff - name_map = {"time": "t", "pos": ["y", "x"], "seg_id": "seg_id"} - - # Provide a segmentation with wrong shape - wrong_seg = np.zeros((2, 20, 200), dtype=np.uint16) - seg_path = tmp_path / "wrong_seg.npy" - tifffile.imwrite(seg_path, wrong_seg) - with pytest.raises(ValueError, match="out of bounds"): - import_from_geff(store, name_map, segmentation_path=seg_path) - - # Provide a segmentation with a different number of dimensions than the graph. - # The error is caught during validation because pos has spatial_dims=True - wrong_seg = np.zeros((2, 20, 200, 200), dtype=np.uint16) - seg_path = tmp_path / "wrong_seg2.npy" - tifffile.imwrite(seg_path, wrong_seg) - with pytest.raises(ValueError, match="pos.*has 2 values.*3 spatial dimensions"): - import_from_geff(store, name_map, segmentation_path=seg_path) - - -def test_tracks_with_segmentation(valid_geff, invalid_geff, valid_segmentation, tmp_path): - """Test relabeling of the segmentation from seg_id to node_id.""" - - store, _ = valid_geff - name_map = {"time": "t", "pos": ["y", "x"], "seg_id": "seg_id"} - valid_segmentation_path = tmp_path / "segmentation.tif" - tifffile.imwrite(valid_segmentation_path, valid_segmentation) - - # Test that a tracks object is produced and that the seg_id has been relabeled. - scale = [1, 1, (1 / 100)] - name_map_with_features = { - **name_map, - "area": "area", - "random_feature": "random_feature", - } - - tracks = import_from_geff( - store, - name_map_with_features, - segmentation_path=valid_segmentation_path, - scale=scale, - ) - assert hasattr(tracks, "segmentation") - assert tracks.segmentation.shape == valid_segmentation.shape - # Get last node by ID (don't rely on iteration order) - last_node = max(tracks.graph.node_ids()) - # With composite pos, position is stored as an array - pos = tracks.graph.nodes[last_node]["pos"] - coords = [ - tracks.graph.nodes[last_node]["t"], - pos[0], # y - pos[1], # x - ] - coords = tuple(int(c * 1 / s) for c, s in zip(coords, scale, strict=True)) - assert ( - valid_segmentation[tuple(coords)] == 50 - ) # in original segmentation, the pixel value is equal to seg_id - assert ( - np.asarray(tracks.segmentation)[tuple(coords)] == last_node - ) # test that the seg id has been relabeled - - # Check that only requested features are present and area is loaded from geff - data = tracks.graph.nodes[last_node] - assert "random_feature" in tracks.graph.node_attr_keys() - assert "random_feature2" not in tracks.graph.node_attr_keys() - assert "area" in tracks.graph.node_attr_keys() - assert data["area"] == 21 # loaded directly from geff, not recomputed - - # Test that import fails with ValueError when invalid seg_ids are provided. - store, _ = invalid_geff - with pytest.raises(ValueError): - tracks = import_from_geff( - store, name_map, segmentation_path=valid_segmentation_path, scale=scale - ) - - -@pytest.mark.parametrize("segmentation_format", ["single_tif", "tif_folder", "zarr"]) -def test_segmentation_loading_formats( - segmentation_format, valid_geff, valid_segmentation, tmp_path -): - """Test loading segmentation from different formats using magic_imread.""" - store, _ = valid_geff - name_map = {"time": "t", "pos": ["y", "x"], "seg_id": "seg_id"} - scale = [1, 1, 1 / 100] - seg = valid_segmentation - - if segmentation_format == "single_tif": - path = tmp_path / "segmentation.tif" - tifffile.imwrite(path, seg) - - elif segmentation_format == "tif_folder": - path = tmp_path / "tif_series" - path.mkdir() - for i, frame in enumerate(seg): - tifffile.imwrite(path / f"seg_{i:03}.tif", frame) - - elif segmentation_format == "zarr": - path = tmp_path / "segmentation.zarr" - da.from_array(seg, chunks=(1, *seg.shape[1:])).to_zarr(path) - - else: - raise ValueError(f"Unknown format: {segmentation_format}") - - name_map_with_features = { - **name_map, - "area": "area", - "random_feature": "random_feature", - } - - tracks = import_from_geff( - store, - name_map_with_features, - segmentation_path=path, - scale=scale, - ) - - assert hasattr(tracks, "segmentation") - assert np.array(tracks.segmentation).shape == seg.shape - - -def test_features_loaded_from_name_map(valid_geff, valid_segmentation, tmp_path): - """Test that features included in node_name_map are loaded and registered.""" - store, _ = valid_geff - node_name_map = { - "time": "t", - "pos": ["y", "x"], - "seg_id": "seg_id", - "circularity": "circ", # Map standard key to GEFF property name - "area": "area", # Load area from geff - "random_feature": "random_feature", # Static feature - load from geff - } - scale = [1, 1, 1 / 100] - valid_segmentation_path = tmp_path / "segmentation.tif" - tifffile.imwrite(valid_segmentation_path, valid_segmentation) - - tracks = import_from_geff( - store, - node_name_map, - segmentation_path=valid_segmentation_path, - scale=scale, - ) - - feature_keys = ["area", "random_feature", "circularity"] - for key in feature_keys: - assert key in tracks.features - - # Get last node by ID (don't rely on iteration order) - max_node_id = max(tracks.graph.node_ids()) - data = tracks.graph.nodes[max_node_id] - - # All requested features should be present and loaded from geff - for key in feature_keys: - assert data[key] is not None - - assert data["area"] == 21 # loaded from geff - assert data["circularity"] == 0.45 # loaded from geff (renamed from "circ") - assert data["random_feature"] == "e" # static feature loaded from geff - - -def test_nonexistent_property_in_name_map(valid_geff): - """Test that mapping to a non-existent GEFF property raises an error.""" - store, _ = valid_geff - node_name_map = { - "time": "t", - "pos": ["y", "x"], - "area": "nonexistent_column", # This property doesn't exist in the GEFF - } - - with pytest.raises(ValueError): - import_from_geff(store, node_name_map) - - -def _make_mask(bbox): - """Create a Mask from a bbox list [y_min, x_min, y_max, x_max].""" - from tracksdata.nodes import Mask - - ndim = len(bbox) // 2 - shape = tuple(bbox[i + ndim] - bbox[i] for i in range(ndim)) - return Mask(np.ones(shape, dtype=bool), bbox=np.array(bbox, dtype=np.int64)) - - -def test_import_from_geff_roundtrip_auto_axes(tmp_path): - """Round-trip export_to_geff / import_from_geff for a graph with mask/bbox node - attributes but no accompanying segmentation array. - - This is the typical shape of a motile-tracker candidate graph: each node carries - a per-node Mask (local boolean array + bounding box) without a full dense - segmentation volume being stored. - - Checks: - - The time and spatial axes are inferred correctly from the geff axes metadata, - not from fuzzy string matching (which would misassign 'pos' -> ['bbox']). - - import_from_geff succeeds and sets segmentation=None when the geff file was - saved without a segmentation array (i.e. no segmentation_shape in metadata). - - Positions and scalar attributes are preserved through the round-trip. - - The mask attribute comes back as a Mask object, not a raw numpy array. - - The bbox values are preserved correctly through the round-trip. - """ - import tracksdata as td - from tracksdata.nodes import Mask - - graph = create_empty_graphview_graph( - node_attributes=[ - "pos", - "area", - "track_id", - "lineage_id", - td.DEFAULT_ATTR_KEYS.MASK, - td.DEFAULT_ATTR_KEYS.BBOX, - ], - edge_attributes=["iou"], - ndim=3, - ) - bbox = [30, 30, 71, 71] - graph.bulk_add_nodes( - nodes=[ - { - "t": 0, - "pos": np.array([50.0, 50.0]), - "area": 1681.0, - "track_id": 1, - "lineage_id": 1, - "solution": True, - td.DEFAULT_ATTR_KEYS.MASK: _make_mask(bbox), - td.DEFAULT_ATTR_KEYS.BBOX: np.array(bbox, dtype=np.int64), - } - ], - indices=[1], - ) - # The graph carries segmentation_shape in its metadata (set by motile-tracker), - # but no dense segmentation array is attached to the SolutionTracks object. - graph._update_metadata(segmentation_shape=(5, 100, 100)) - - run_dir = tmp_path / "run" - run_dir.mkdir() - - st = SolutionTracks(graph, ndim=3, time_attr="t") - export_to_geff(st, run_dir) - - tracks_path = run_dir / "tracks.geff" - - # The geff file contains typed axis metadata (type="time" / type="space"). - # The builder should use that directly instead of fuzzy string matching, - # which would assign pos -> ['bbox'] and leave 'y'/'x' unmapped. - builder = GeffTracksBuilder() - builder.prepare(tracks_path) - - assert "time" in builder.node_name_map - assert builder.node_name_map["time"] == "t" - assert "pos" in builder.node_name_map - pos_mapping = builder.node_name_map["pos"] - assert isinstance(pos_mapping, list), ( - f"pos should map to a list of axis names, got {pos_mapping!r}" - ) - assert pos_mapping == ["y", "x"], f"pos should map to ['y', 'x'], got {pos_mapping}" - - # export_to_geff writes segmentation_shape as an extra zarr attribute when the - # graph carries mask/bbox node attributes. Verify it is present in the zarr. - import zarr as _zarr - - z = _zarr.open(str(tracks_path), mode="r") - zarr_attrs = dict(z.attrs) - assert "segmentation_shape" in zarr_attrs, ( - "export_to_geff should write segmentation_shape to zarr attrs when masks present" - ) - assert tuple(zarr_attrs["segmentation_shape"]) == (5, 100, 100) - - # import_from_geff must read segmentation_shape back from zarr attrs and - # reconstruct a segmentation (GraphArrayView) — not return segmentation=None. - tracks = import_from_geff(tracks_path) - assert tracks.graph.num_nodes() == 1 - assert tracks.segmentation is not None, ( - "segmentation should be reconstructed from masks after round-trip" - ) - assert tracks.segmentation.shape == (5, 100, 100) - - node1 = tracks.graph.nodes[1] - - assert node1["pos"] is not None - np.testing.assert_array_almost_equal(node1["pos"], [50.0, 50.0]) - assert node1["area"] == pytest.approx(1681.0) - - # Regression test: RegionpropsAnnotator must be present and active after - # round-trip import of a GEFF with embedded segmentation. Without the fix, - # segmentation=None during Tracks.__init__ so the annotator was never - # created, causing newly painted cells to get position (0, 0) and area 0. - from funtracks.annotators import RegionpropsAnnotator - - assert any(isinstance(a, RegionpropsAnnotator) for a in tracks.annotators), ( - "RegionpropsAnnotator should be in the annotator registry after importing " - "a GEFF with embedded segmentation (mask + bbox + segmentation_shape)" - ) - regionprops = next( - a for a in tracks.annotators if isinstance(a, RegionpropsAnnotator) - ) - assert "pos" in regionprops.features, ( - "'pos' should be activated in RegionpropsAnnotator so that newly " - "painted cells get their position computed correctly" - ) - - # The geff format stores mask data as a plain numeric array; funtracks must - # reconstruct the Mask wrapper from the raw array + bbox after loading. - loaded_mask = node1[td.DEFAULT_ATTR_KEYS.MASK] - loaded_bbox = node1[td.DEFAULT_ATTR_KEYS.BBOX] - - assert isinstance(loaded_mask, Mask), ( - f"mask should be a Mask object after round-trip, got {type(loaded_mask)}" - ) - # bbox is stored as an array column in the SQL-backed graph so it comes back - # as a polars Series rather than a numpy array; check values only. - np.testing.assert_array_equal( - np.array(loaded_bbox, dtype=np.int64), np.array(bbox, dtype=np.int64) - ) - - -@pytest.mark.skip( - reason="storage location intentionally changed: the segmentation shape is no " - "longer written as a top-level 'segmentation_shape' zarr attr; it now travels " - "in the geff metadata extras. Covered by the equivalent test in tests/." -) -def test_import_from_geff_warns_missing_segmentation_shape(tmp_path): - """import_from_geff should warn when masks/bboxes are present but - segmentation_shape is absent from the zarr attributes. - - This simulates a GEFF written by an older version of funtracks (before the - export fix) or by an external tool that stores per-node masks without writing - segmentation_shape. The import must still succeed, return segmentation=None, - and emit a UserWarning so the user knows the segmentation cannot be shown. - """ - import warnings - - import tracksdata as td - import zarr as _zarr - - graph = create_empty_graphview_graph( - node_attributes=[ - "pos", - td.DEFAULT_ATTR_KEYS.MASK, - td.DEFAULT_ATTR_KEYS.BBOX, - ], - ndim=3, - ) - bbox = [30, 30, 71, 71] - graph.bulk_add_nodes( - nodes=[ - { - "t": 0, - "pos": np.array([50.0, 50.0]), - "solution": True, - td.DEFAULT_ATTR_KEYS.MASK: _make_mask(bbox), - td.DEFAULT_ATTR_KEYS.BBOX: np.array(bbox, dtype=np.int64), - } - ], - indices=[1], - ) - graph._update_metadata(segmentation_shape=(5, 100, 100)) - - run_dir = tmp_path / "run" - run_dir.mkdir() - st = SolutionTracks(graph, ndim=3, time_attr="t") - export_to_geff(st, run_dir) - - tracks_path = run_dir / "tracks.geff" - - # Simulate old funtracks / external tool: remove segmentation_shape from zarr attrs. - # Use put() (full replacement) rather than update() (merge) so the key is truly gone. - z = _zarr.open(str(tracks_path), mode="a") - attrs = dict(z.attrs) - attrs.pop("segmentation_shape", None) - z.attrs.put(attrs) - - # import_from_geff must warn and still succeed (segmentation=None) - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - tracks = import_from_geff(tracks_path) - - warning_messages = [ - str(w.message) for w in caught if issubclass(w.category, UserWarning) - ] - assert any("segmentation_shape" in msg for msg in warning_messages), ( - f"Expected a UserWarning mentioning segmentation_shape, got: {warning_messages}" - ) - assert tracks.segmentation is None - - -def test_get_time_works_after_import(valid_geff): - """Regression test: tracks.get_time() must work on a SolutionTracks returned by - import_from_geff(). - - Previously, TracksBuilder.build() stored time as "t" in the graph (tracksdata - convention) but created SolutionTracks(time_attr=TIME_ATTR) where TIME_ATTR="time". - This caused features.time_key="time" while the graph only had attribute "t", - making get_time() raise KeyError: 'time'. - """ - store, _ = valid_geff - name_map = {"time": "t", "pos": ["y", "x"]} - tracks = import_from_geff(store, name_map) - - for node_id in tracks.graph.node_ids(): - # This must not raise KeyError: 'time' - t = tracks.get_time(node_id) - assert isinstance(t, int), f"get_time() should return int, got {type(t)}" - - # get_times() on all nodes must also work - all_node_ids = list(tracks.graph.node_ids()) - times = tracks.get_times(all_node_ids) - assert len(times) == len(all_node_ids) - - -@pytest.fixture -def geff_with_bool_prop(): - """A minimal GEFF store that contains a np.bool_ node property.""" - store, _ = create_mock_geff( - node_id_dtype="uint", - node_axis_dtypes={"position": "float64", "time": "int64"}, - directed=True, - num_nodes=5, - num_edges=2, - include_t=True, - include_z=False, - include_y=True, - include_x=True, - extra_node_props={ - "is_dividing": np.array([True, False, True, False, True], dtype=np.bool_), - }, - ) - return store - - -def test_bool_node_property_schema(geff_with_bool_prop): - """Fix 1: np.bool_ columns must produce a pl.Boolean schema, not pl.Int64. - - In numpy 2.x, np.bool_ is no longer a subtype of np.integer. Before the - fix, construct_graph() fell through to ``else: default_value = 0`` (Python - int), making polars infer a pl.Int64 schema. Building a pl.Series of type - Int64 from np.bool_ values then raised: - TypeError: unexpected value while building Series of type Int64; - found value of type Float64: 1.0 - """ - import polars as pl - - name_map = {"time": "t", "pos": ["y", "x"], "is_dividing": "is_dividing"} - tracks = import_from_geff(geff_with_bool_prop, name_map) - - df = tracks.graph.node_attrs(attr_keys=["is_dividing"]) - assert df["is_dividing"].dtype == pl.Boolean, ( - f"Expected pl.Boolean schema for 'is_dividing', got {df['is_dividing'].dtype}. " - "Likely cause: np.bool_ default_value fell through to int in construct_graph()." - ) - - -def test_bool_node_property_values(geff_with_bool_prop): - """Fix 2: np.bool_ values must be converted to Python bool in construct_graph. - - Even when the polars schema is correctly pl.Boolean (Fix 1), individual - np.bool_ values in the node-attrs dict must be explicitly cast to Python - bool. Without the cast, the value stored in the graph node dict is an - np.bool_ object, which is not an instance of Python bool and can cause - type errors in downstream code that calls isinstance(val, bool). - """ - name_map = {"time": "t", "pos": ["y", "x"], "is_dividing": "is_dividing"} - tracks = import_from_geff(geff_with_bool_prop, name_map) - - node_ids = sorted(tracks.graph.node_ids()) - expected = [True, False, True, False, True] - for node_id, exp in zip(node_ids, expected, strict=True): - val = tracks.graph.nodes[node_id]["is_dividing"] - assert type(val) is bool, ( - f"Expected Python bool for 'is_dividing', got {type(val)} for node {node_id}" - "Likely cause: np.bool_ value not cast to bool in construct_graph()." - ) - assert val == exp, f"Wrong value for node {node_id}: expected {exp}, got {val}" - - -def test_3d_pos_survives_sql_roundtrip(tmp_path): - """Regression test: 3D pos (z, y, x) must keep Array dtype through SQL roundtrip. - - The construct_graph() method must pass the correct ndim to - create_empty_graphview_graph() so the pos schema is Array(Float64, 3) not - Array(Float64, 2). A schema mismatch causes SQLGraph.from_other() to - downgrade the column to List(Float64), which breaks downstream callers that - rely on to_numpy() returning a 2D float64 array. - """ - import polars as pl - import tracksdata as td - - store, _ = create_mock_geff( - node_id_dtype="uint", - node_axis_dtypes={"position": "float64", "time": "int64"}, - directed=True, - num_nodes=5, - num_edges=2, - include_t=True, - include_z=True, - include_y=True, - include_x=True, - extra_node_props={"track_id": np.arange(5)}, - ) - name_map = {"time": "t", "pos": ["z", "y", "x"]} - tracks = import_from_geff(store, name_map) - - # Verify the RX graph has correct Array dtype for 3D pos - df_rx = tracks.graph.node_attrs(attr_keys=["pos"]) - assert df_rx["pos"].dtype == pl.Array(pl.Float64, 3), ( - f"RX graph pos should be Array(Float64, 3), got {df_rx['pos'].dtype}" - ) - - # Convert to SQL and reload — this is where the schema mismatch used to surface - db_path = str(tmp_path / "test.db") - td.graph.SQLGraph.from_other(tracks.graph, drivername="sqlite", database=db_path) - sql_graph2 = td.graph.SQLGraph("sqlite", db_path) - - df_sql = sql_graph2.node_attrs(attr_keys=["pos"]) - assert df_sql["pos"].dtype == pl.Array(pl.Float64, 3), ( - f"Reloaded SQL pos should be Array(Float64, 3), got {df_sql['pos'].dtype}. " - "This likely means construct_graph() used the wrong ndim when registering " - "the pos schema, causing a mismatch with the actual 3-element data." - ) - - -def test_geff_legacy_track_id_preserves_tracklet_ids(): - """A GEFF with the legacy 'track_id' property imported via name_map - renaming to 'tracklet_id' must preserve the original tracklet IDs - instead of silently recomputing them.""" - track_id_values = np.array([42, 42, 42, 99, 100, 101]) - store, memory_geff = create_mock_geff( - node_id_dtype="uint", - node_axis_dtypes={"position": "float64", "time": "int64"}, - directed=True, - num_nodes=6, - num_edges=2, - include_t=True, - include_z=False, - include_y=True, - include_x=True, - extra_node_props={"track_id": track_id_values}, - ) - # create_mock_geff produces 0-based node IDs; import offsets them by +1 - # because node_id 0 collides with the segmentation background label. - node_ids = memory_geff["node_ids"] - expected = { - int(nid) + 1: int(t) for nid, t in zip(node_ids, track_id_values, strict=True) - } - - name_map = { - "time": "t", - "pos": ["y", "x"], - "tracklet_id": "track_id", - } - tracks = import_from_geff(store, name_map) - - for nid, exp_id in expected.items(): - assert tracks.get_track_id(nid) == exp_id, ( - f"node {nid}: expected tracklet {exp_id}, " - f"got {tracks.get_track_id(nid)} (silent recompute?)" - ) - - -def test_geff_roundtrip_preserves_tracklet_ids(get_tracks, tmp_path): - """End-to-end round-trip: export then import should preserve tracklet IDs.""" - tracks_in = get_tracks(ndim=3, with_seg=False, is_solution=True) - expected = { - int(nid): tracks_in.get_track_id(int(nid)) for nid in tracks_in.graph.node_ids() - } - - export_dir = tmp_path / "export" - export_dir.mkdir() - export_to_geff(tracks_in, export_dir, save_segmentation=False) - - name_map = { - "time": "t", - "pos": ["y", "x"], - "tracklet_id": "track_id", - "lineage_id": "lineage_id", - } - tracks_out = import_from_geff(export_dir / "tracks.geff", name_map) - - for nid, exp_id in expected.items(): - assert tracks_out.get_track_id(nid) == exp_id, ( - f"node {nid}: expected tracklet {exp_id}, got {tracks_out.get_track_id(nid)}" - ) - - -def test_embedded_seg_ellipse_axis_radii_feature_metadata(tmp_path): - """Regression: ellipse_axis_radii must have correct Feature metadata after - round-tripping through a GEFF with embedded segmentation. - - TracksBuilder.enable_features() hardcodes num_values=1 for static features. - When the RegionpropsAnnotator is absent at build step 8 (embedded-seg case), - ellipse_axis_radii is registered as a static feature instead of an - annotator-managed one, producing wrong metadata: num_values=1 instead of 2, - no spatial_dims flag, and no value_names. - """ - import tracksdata as td - - from funtracks.annotators import RegionpropsAnnotator - - node_attributes = [ - "pos", - "area", - "ellipse_axis_radii", - "track_id", - "lineage_id", - td.DEFAULT_ATTR_KEYS.MASK, - td.DEFAULT_ATTR_KEYS.BBOX, - ] - # node_default_values must align with node_attributes by index. - # pos/mask/bbox are handled by special cases in create_empty_graphview_graph - # and their slot values here are never accessed; only area, ellipse_axis_radii, - # track_id, and lineage_id go through the general loop. - node_default_values = [ - None, # pos — special-cased, slot unused - 0.0, # area - np.array([0.0, 0.0]), # ellipse_axis_radii — must be Array(Float64, 2) - -1, # track_id - -1, # lineage_id - None, # mask — special-cased, slot unused - None, # bbox — special-cased, slot unused - ] - graph = create_empty_graphview_graph( - node_attributes=node_attributes, - node_default_values=node_default_values, - edge_attributes=[], - ndim=3, - ) - bbox = [20, 20, 50, 60] - graph.bulk_add_nodes( - nodes=[ - { - "t": 0, - "pos": np.array([35.0, 40.0]), - "area": 900.0, - "ellipse_axis_radii": np.array([20.0, 15.0]), - "track_id": 1, - "lineage_id": 1, - "solution": True, - td.DEFAULT_ATTR_KEYS.MASK: _make_mask(bbox), - td.DEFAULT_ATTR_KEYS.BBOX: np.array(bbox, dtype=np.int64), - } - ], - indices=[1], - ) - graph._update_metadata(segmentation_shape=(3, 100, 100)) - - run_dir = tmp_path / "run" - run_dir.mkdir() - - st = SolutionTracks(graph, ndim=3, time_attr="t") - export_to_geff(st, run_dir) - - # Remove FeatureDict from GEFF metadata to simulate old/external GEFF - # This tests that enable_features() correctly auto-detects features - import zarr - - z = zarr.open(str(run_dir / "tracks.geff"), mode="r+") - attrs = dict(z.attrs) - if "geff" in attrs and "extra" in attrs["geff"]: - geff_attrs = dict(attrs["geff"]) - if "funtracks" in geff_attrs["extra"]: - extra = dict(geff_attrs["extra"]) - if "features" in extra.get("funtracks", {}): - funtracks = dict(extra["funtracks"]) - del funtracks["features"] - if not funtracks: - del extra["funtracks"] - else: - extra["funtracks"] = funtracks - if not extra: - del geff_attrs["extra"] - else: - geff_attrs["extra"] = extra - attrs["geff"] = geff_attrs - z.attrs.clear() - z.attrs.update(attrs) - - imported = import_from_geff(run_dir / "tracks.geff") - - assert any(isinstance(a, RegionpropsAnnotator) for a in imported.annotators), ( - "RegionpropsAnnotator should be present after import of an embedded-seg " - "GEFF. Without it, regionprops features get wrong metadata via the " - "static-feature fallback in TracksBuilder.enable_features()." - ) - - assert "ellipse_axis_radii" in imported.features, ( - "ellipse_axis_radii should be registered as a feature after import" - ) - feat = imported.features["ellipse_axis_radii"] - assert feat["num_values"] == 2, ( - f"ellipse_axis_radii num_values should be 2, got {feat['num_values']}. " - "Likely cause: registered as a static feature (hardcoded num_values=1) " - "because RegionpropsAnnotator was absent when enable_features() ran at " - "build step 8 of GeffTracksBuilder.build()." - ) - assert feat.get("spatial_dims") is True, ( - f"ellipse_axis_radii spatial_dims should be True, got {feat.get('spatial_dims')}" - ) - assert feat.get("value_names") == ["major_axis", "minor_axis"], ( - f"ellipse_axis_radii value_names should be ['major_axis', 'minor_axis'], " - f"got {feat.get('value_names')}" - ) - - -def test_featuredict_survives_geff_roundtrip(tmp_path): - """The FeatureDict stored in GEFF extra metadata must be loaded on import, - not re-derived by auto-detection. - - Proves the loaded FeatureDict is the source of truth by customizing a - feature's display_name before export — auto-detection would reset it to the - default, so a surviving custom value can only come from the stored dict. - """ - import tracksdata as td - - node_attributes = [ - "pos", - "area", - "ellipse_axis_radii", - "track_id", - "lineage_id", - "solution", - td.DEFAULT_ATTR_KEYS.MASK, - td.DEFAULT_ATTR_KEYS.BBOX, - ] - node_default_values = [ - None, # pos — special-cased, slot unused - 0.0, # area - np.array([0.0, 0.0]), # ellipse_axis_radii — must be Array(Float64, 2) - -1, # track_id - -1, # lineage_id - True, # solution — always registered as Boolean - None, # mask — special-cased, slot unused - None, # bbox — special-cased, slot unused - ] - graph = create_empty_graphview_graph( - node_attributes=node_attributes, - node_default_values=node_default_values, - edge_attributes=[], - ndim=3, - ) - bbox = [20, 20, 50, 60] - graph.bulk_add_nodes( - nodes=[ - { - "t": 0, - "pos": np.array([35.0, 40.0]), - "area": 900.0, - "ellipse_axis_radii": np.array([20.0, 15.0]), - "track_id": 1, - "lineage_id": 1, - "solution": True, - td.DEFAULT_ATTR_KEYS.MASK: _make_mask(bbox), - td.DEFAULT_ATTR_KEYS.BBOX: np.array(bbox, dtype=np.int64), - } - ], - indices=[1], - ) - graph._update_metadata(segmentation_shape=(3, 100, 100)) - - run_dir = tmp_path / "run" - run_dir.mkdir() - - st = SolutionTracks(graph, ndim=3, time_attr="t") - # Customize metadata that auto-detection cannot reproduce. - pos_key = st.features.position_key - assert isinstance(pos_key, str) - st.features[pos_key]["display_name"] = "Custom Position Name" - export_to_geff(st, run_dir) - - # Import WITHOUT stripping the FeatureDict (the positive path). - imported = import_from_geff(run_dir / "tracks.geff") - - assert imported.features[pos_key]["display_name"] == "Custom Position Name", ( - "Custom display_name should survive the GEFF round-trip, proving the " - "stored FeatureDict was loaded rather than re-derived by auto-detection." - ) - # Tracked keys must round-trip too. - assert imported.features.time_key == st.features.time_key - assert imported.features.position_key == st.features.position_key - assert imported.features.tracklet_key == st.features.tracklet_key - assert imported.features.lineage_key == st.features.lineage_key - - -def test_invalid_featuredict_in_geff_falls_back_to_autodetect(get_tracks, tmp_path): - """If the stored FeatureDict JSON is corrupted or invalid, - import_from_geff should silently fall back to auto-detection - instead of raising an exception. - """ - tracks = get_tracks(ndim=3, with_seg=False, is_solution=True) - run_dir = tmp_path / "run" - run_dir.mkdir() - export_to_geff(tracks, run_dir, save_segmentation=False) - - # Corrupt the FeatureDict in GEFF metadata - geff_path = run_dir / "tracks.geff" - z = zarr.open(str(geff_path), mode="r+") - attrs = dict(z.attrs) - geff_attrs = dict(attrs["geff"]) - extra = dict(geff_attrs["extra"]) - # Replace with garbage that will cause FeatureDict.from_json to raise KeyError - extra["funtracks"] = {"features": {"not_a_valid_featuredict": True}} - geff_attrs["extra"] = extra - attrs["geff"] = geff_attrs - z.attrs.clear() - z.attrs.update(attrs) - - # Should not raise — falls back to auto-detection - imported = import_from_geff(geff_path) - - # Verify the import produced a working SolutionTracks with auto-detected features - assert imported.features.time_key is not None - assert imported.features.position_key is not None - assert imported.features.tracklet_key is not None - assert set(tracks.graph.node_ids()) == set(imported.graph.node_ids()) - - -def test_subgroup_export_omits_featuredict_and_recomputes_on_import(get_tracks, tmp_path): - """Exporting a subgroup (node_ids provided) should omit the FeatureDict - from GEFF metadata, so that reimport recomputes tracklet/lineage IDs - for the subgraph topology instead of using stale IDs from the original. - - Regression test for https://github.com/funkelab/funtracks/issues/239: - Previously, the FeatureDict was always exported, even for subgroups. - On reimport, validation would strip the now-invalid tracklet_id but the - FeatureDict still referenced it, causing KeyError: 'tracklet_id'. - """ - tracks = get_tracks(ndim=3, with_seg=False, is_solution=True) - - # Export only a subset: nodes 1, 3, 4, 5 (one branch of the division). - # filter_graph_with_ancestors will include node 1 as ancestor of 3. - run_dir = tmp_path / "run" - run_dir.mkdir() - export_to_geff(tracks, run_dir, node_ids={3, 4, 5}, save_segmentation=False) - - geff_path = run_dir / "tracks.geff" - - # Verify no FeatureDict in the GEFF metadata - from geff_spec import GeffMetadata - - meta = GeffMetadata.read(geff_path) - has_features = ( - meta.extra is not None - and "funtracks" in meta.extra - and "features" in meta.extra["funtracks"] - ) - assert not has_features, ( - "Subgroup export should not include a FeatureDict in GEFF metadata" - ) - - # Import should succeed and recompute IDs for the new subgraph topology. - # The source graph used "track_id" (not "tracklet_id") as the column name; - # the axes-based auto-inference identity-maps it, so we provide the correct - # mapping explicitly. - imported = import_from_geff( - geff_path, - node_name_map={ - "time": "t", - "pos": ["y", "x"], - "tracklet_id": "track_id", - "lineage_id": "lineage_id", - "solution": "solution", - }, - ) - - # persistent-graph: import now returns a Tracks (SolutionTracks is a Tracks - # subclass), so the old isinstance(SolutionTracks) check no longer holds. - # assert isinstance(imported, SolutionTracks) - assert imported.features.tracklet_key is not None - - # The subgraph is a linear chain (1→3→4→5, no divisions), so all nodes - # should share a single tracklet_id and a single lineage_id. - track_ids = {imported.get_track_id(nid) for nid in imported.graph.node_ids()} - assert len(track_ids) == 1, ( - f"Linear chain should have one tracklet_id, got {track_ids}" - ) - lineage_ids = {imported.get_lineage_id(nid) for nid in imported.graph.node_ids()} - assert len(lineage_ids) == 1, ( - f"Linear chain should have one lineage_id, got {lineage_ids}" - ) - - -def test_import_from_geff_respects_external_solution_column(tmp_path): - """A geff produced externally (e.g. by a tracksdata solver) may carry a - 'solution' column with mixed True/False values on nodes. Funtracks must - honor those values and filter out nodes with solution=False, not silently - default everything to True. - - Reproduces the asymmetric `internal_attrs` filter bug in - GeffTracksBuilder.infer_node_name_map: the axes-branch drops 'solution' - from the node name map, so the column is never loaded, and every node - inherits the default True. - """ - # Separate y/x columns (not a 2-D 'pos' array) so tracksdata's to_geff - # registers them as space-typed axes — required to exercise the - # axes-branch of GeffTracksBuilder.infer_node_name_map. - graph = create_empty_graphview_graph( - node_attributes=["y", "x"], position_attrs=["y", "x"], ndim=3 - ) - graph.bulk_add_nodes( - nodes=[ - {"t": 0, "y": 10.0, "x": 10.0, "solution": True}, - {"t": 0, "y": 20.0, "x": 20.0, "solution": False}, - {"t": 1, "y": 11.0, "x": 11.0, "solution": True}, - ], - indices=[1, 2, 3], - ) - - # Export the root graph, not the filtered solution-only view, so the - # solution=False row survives into the geff (mimicking a solver-produced - # geff with rejected nodes). - tracks_path = tmp_path / "tracks.geff" - graph._root.to_geff(geff_store=tracks_path, zarr_format=2) - - tracks = import_from_geff(tracks_path) - - node_ids = set(tracks.graph.node_ids()) - assert node_ids == {1, 3}, ( - "Node 2 has solution=False in the geff file and should be filtered out " - f"by import_from_geff. Got node_ids={node_ids}." - ) diff --git a/tests_old/import_export/test_import_segmentation.py b/tests_old/import_export/test_import_segmentation.py deleted file mode 100644 index fcbe8b5f..00000000 --- a/tests_old/import_export/test_import_segmentation.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Tests for _import_segmentation module.""" - -import numpy as np -import tifffile - -from funtracks.import_export._import_segmentation import ( - load_segmentation, - relabel_segmentation, -) -from funtracks.utils.tracksdata_utils import create_empty_graphview_graph - - -class TestLoadSegmentation: - """Tests for load_segmentation function.""" - - def test_load_from_path(self, tmp_path): - """Test loading segmentation from a tif file.""" - # Create test segmentation - seg = np.array([[[1, 0], [0, 2]], [[3, 0], [0, 4]]], dtype=np.uint16) - seg_path = tmp_path / "test_seg.tif" - tifffile.imwrite(seg_path, seg) - - # Load and verify - result = load_segmentation(seg_path) - np.testing.assert_array_equal(result.compute(), seg) - - def test_load_from_array(self): - """Test wrapping a numpy array in dask.""" - seg = np.array([[[1, 0], [0, 2]], [[3, 0], [0, 4]]], dtype=np.uint16) - - result = load_segmentation(seg) - - # Should be a dask array - assert hasattr(result, "compute") - np.testing.assert_array_equal(result.compute(), seg) - - -class TestRelabelSegmentation: - """Tests for relabel_segmentation function.""" - - def test_basic_relabeling(self): - """Test basic seg_id to node_id relabeling.""" - # Create segmentation with seg_ids 10, 20 - seg = np.zeros((2, 5, 5), dtype=np.uint16) - seg[0, 1, 1] = 10 # seg_id 10 at t=0 - seg[1, 2, 2] = 20 # seg_id 20 at t=1 - - # Create graph with node_ids 1, 2 - graph = create_empty_graphview_graph() - graph.add_node(index=1, attrs={"t": 0, "solution": True}) - graph.add_node(index=2, attrs={"t": 1, "solution": True}) - - node_ids = np.array([1, 2]) - seg_ids = np.array([10, 20]) - time_values = np.array([0, 1]) - - result, graph = relabel_segmentation(seg, graph, node_ids, seg_ids, time_values) - - # seg_id 10 -> node_id 1, seg_id 20 -> node_id 2 - assert result[0, 1, 1] == 1 - assert result[1, 2, 2] == 2 - # Background should remain 0 - assert result[0, 0, 0] == 0 - - def test_relabeling_with_node_id_zero(self): - """Test that node_id 0 is handled by offsetting all IDs.""" - # Create segmentation with seg_ids 10, 20 - seg = np.zeros((2, 5, 5), dtype=np.uint16) - seg[0, 1, 1] = 10 # seg_id 10 at t=0 - seg[1, 2, 2] = 20 # seg_id 20 at t=1 - - # Create graph with node_ids 0, 1 (includes 0!) - graph = create_empty_graphview_graph() - graph.add_node(index=0, attrs={"t": 0, "solution": True}) - graph.add_node(index=1, attrs={"t": 1, "solution": True}) - - node_ids = np.array([0, 1]) - seg_ids = np.array([10, 20]) - time_values = np.array([0, 1]) - - result, graph = relabel_segmentation(seg, graph, node_ids, seg_ids, time_values) - - # node_ids should be offset by 1: 0->1, 1->2 - # seg_id 10 -> node_id 1 (was 0), seg_id 20 -> node_id 2 (was 1) - assert result[0, 1, 1] == 1 - assert result[1, 2, 2] == 2 - - # Graph should also be relabeled - assert graph.has_node(1) - assert graph.has_node(2) - assert not graph.has_node(0) - - def test_no_relabeling_needed_same_ids(self): - """Test when seg_ids equal node_ids (relabeling still applies mapping).""" - # Create segmentation with seg_ids 1, 2 - seg = np.zeros((2, 5, 5), dtype=np.uint16) - seg[0, 1, 1] = 1 - seg[1, 2, 2] = 2 - - graph = create_empty_graphview_graph() - graph.add_node(index=1, attrs={"t": 0, "solution": True}) - graph.add_node(index=2, attrs={"t": 1, "solution": True}) - - node_ids = np.array([1, 2]) - seg_ids = np.array([1, 2]) # Same as node_ids - time_values = np.array([0, 1]) - - result, graph = relabel_segmentation(seg, graph, node_ids, seg_ids, time_values) - - # Should still produce valid output (identity mapping) - assert result[0, 1, 1] == 1 - assert result[1, 2, 2] == 2 - - def test_multiple_nodes_same_timepoint(self): - """Test relabeling with multiple nodes at the same timepoint.""" - # Create segmentation with seg_ids 10, 20, 30 at t=0 - seg = np.zeros((1, 5, 5), dtype=np.uint16) - seg[0, 1, 1] = 10 - seg[0, 2, 2] = 20 - seg[0, 3, 3] = 30 - - graph = create_empty_graphview_graph() - graph.add_node(index=1, attrs={"t": 0, "solution": True}) - graph.add_node(index=2, attrs={"t": 0, "solution": True}) - graph.add_node(index=3, attrs={"t": 0, "solution": True}) - - node_ids = np.array([1, 2, 3]) - seg_ids = np.array([10, 20, 30]) - time_values = np.array([0, 0, 0]) - - result, graph = relabel_segmentation(seg, graph, node_ids, seg_ids, time_values) - - assert result[0, 1, 1] == 1 - assert result[0, 2, 2] == 2 - assert result[0, 3, 3] == 3 diff --git a/tests_old/import_export/test_internal_format.py b/tests_old/import_export/test_internal_format.py deleted file mode 100644 index 8112cc60..00000000 --- a/tests_old/import_export/test_internal_format.py +++ /dev/null @@ -1,134 +0,0 @@ -import json -import shutil -from collections.abc import Sequence -from pathlib import Path - -import pytest -from numpy.testing import assert_array_almost_equal - -from funtracks.import_export._v1_format import ( - delete_tracks, - load_v1_tracks, -) - - -@pytest.mark.parametrize("with_seg", [True, False]) -@pytest.mark.parametrize("ndim", [3, 4]) -@pytest.mark.parametrize("is_solution", [True, False]) -def test_save_load( - get_tracks, - with_seg, - ndim, - is_solution, -): - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=is_solution) - - data_path = Path( - f"tests/data/format_v1/test_save_load_{is_solution}_{ndim}_{with_seg}_0" - ) - - loaded = load_v1_tracks(data_path, solution=is_solution) - assert loaded.ndim == ndim - # Check feature keys and important properties match (allow tuple vs list diff) - assert loaded.features.time_key == tracks.features.time_key - assert loaded.features.position_key == tracks.features.position_key - - # Check that features dictionaries have same keys - assert set(loaded.features.keys()) == set(tracks.features.keys()) - - # Check that each feature has matching values - for key in tracks.features: - loaded_feature = loaded.features[key] - tracks_feature = tracks.features[key] - - for attr_name, attr_value in tracks_feature.items(): - loaded_attr_value = loaded_feature[attr_name] - - # For sequence attributes, cast to list to compare (handles tuple vs list) - if isinstance(attr_value, Sequence) and not isinstance(attr_value, str): - assert list(loaded_attr_value) == list(attr_value), ( - f"Feature '{key}' attribute '{attr_name}' mismatch: " - f"{loaded_attr_value} != {attr_value}" - ) - # For non-sequence attributes, direct equality - else: - assert loaded_attr_value == attr_value, ( - f"Feature '{key}' attribute '{attr_name}' mismatch: " - f"{loaded_attr_value} != {attr_value}" - ) - - assert loaded.scale == tracks.scale - assert loaded.ndim == tracks.ndim - - if is_solution: - loaded_annotator = loaded.track_annotator - tracks_annotator = tracks.track_annotator - assert ( - loaded_annotator.tracklet_id_to_nodes == tracks_annotator.tracklet_id_to_nodes - ) - - if with_seg: - assert_array_almost_equal(loaded.segmentation, tracks.segmentation) - else: - assert loaded.segmentation is None - - # graphs_equal doesn't exist for TracksData, so we check properties - assert set(loaded.graph.node_attr_keys()) == set(tracks.graph.node_attr_keys()) - assert set(loaded.graph.edge_attr_keys()) == set(tracks.graph.edge_attr_keys()) - assert loaded.graph.num_nodes() == tracks.graph.num_nodes() - assert loaded.graph.num_edges() == tracks.graph.num_edges() - assert set(loaded.graph.node_ids()) == set(tracks.graph.node_ids()) - # edge_ids dont matter, only the actual edges: - assert sorted(loaded.graph.edge_list()) == sorted(tracks.graph.edge_list()) - - -@pytest.mark.parametrize("with_seg", [True, False]) -@pytest.mark.parametrize("ndim", [3, 4]) -@pytest.mark.parametrize("is_solution", [True, False]) -def test_delete( - get_tracks, - with_seg, - ndim, - is_solution, - tmp_path, -): - reference_path = Path( - f"tests/data/format_v1/test_save_load_{is_solution}_{ndim}_{with_seg}_0" - ) - - # Copy reference data to temporary location - tracks_path = tmp_path / "test_tracks" - shutil.copytree(reference_path, tracks_path) - - # Delete the copy - delete_tracks(tracks_path) - with pytest.raises(StopIteration): - next(tmp_path.iterdir()) - - -# for backward compatibility -def test_load_without_features(tmp_path, graph_2d_with_segmentation): - reference_path = Path(f"tests/data/format_v1/test_save_load_{True}_{3}_{True}_0") - - # Copy reference data to temporary location - tracks_path = tmp_path / "test_tracks" - shutil.copytree(reference_path, tracks_path) - - # Load the original data first to verify it loads correctly - load_v1_tracks(tracks_path, solution=True) - - # Modify the copy to test backward compatibility - attrs_path = tracks_path / "attrs.json" - with open(attrs_path) as f: - attrs = json.load(f) - - del attrs["features"] - attrs["time_attr"] = "time" - attrs["pos_attr"] = "pos" - with open(attrs_path, "w") as f: - json.dump(attrs, f) - - # Load the modified data to test old format compatibility - imported_tracks = load_v1_tracks(tracks_path) - assert imported_tracks.features.time_key == "time" - assert imported_tracks.features.position_key == "pos" diff --git a/tests_old/import_export/test_solution_roundtrip.py b/tests_old/import_export/test_solution_roundtrip.py deleted file mode 100644 index babf3183..00000000 --- a/tests_old/import_export/test_solution_roundtrip.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Regression tests for the solution-flag corruption found via motile_tracker. - -Covers: -- AddEdge marks new edges solution=True even when the schema default is wrong. -- geff round-trip preserves the solution schema as Boolean with default True. -""" - -import numpy as np -import polars as pl - -from funtracks.actions.add_delete_edge import AddEdge -from funtracks.actions.add_delete_node import AddNode -from funtracks.import_export import export_to_geff, import_from_geff - - -def _roundtrip(tracks, tmp_path, name="rt.geff"): - out = tmp_path / name - export_to_geff(tracks, out, overwrite=True) - return import_from_geff(out / "tracks.geff") - - -def test_geff_roundtrip_preserves_solution_schema(get_tracks, tmp_path): - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - loaded = _roundtrip(tracks, tmp_path) - - edge_schema = loaded.graph._edge_attr_schemas()["solution"] - node_schema = loaded.graph._node_attr_schemas()["solution"] - - assert edge_schema.dtype == pl.Boolean - assert edge_schema.default_value is True - assert node_schema.dtype == pl.Boolean - assert node_schema.default_value is True - - -def test_add_edge_is_solution_true_after_geff_roundtrip(get_tracks, tmp_path): - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - loaded = _roundtrip(tracks, tmp_path) - g = loaded.graph - - # find any source at frame t and target at t+1 with no edge between them - rows = list(g.node_attrs(attr_keys=["node_id", "t"]).sort("t").iter_rows(named=True)) - by_t: dict[int, list[int]] = {} - for r in rows: - by_t.setdefault(r["t"], []).append(r["node_id"]) - src = tgt = None - for t in sorted(by_t): - if t + 1 in by_t: - for s in by_t[t]: - for d in by_t[t + 1]: - if not g.has_edge(s, d): - src, tgt = s, d - break - if src is not None: - break - if src is not None: - break - assert src is not None, "fixture has no addable edge" - - AddEdge(loaded, (src, tgt)) - assert loaded.get_edge_attr((src, tgt), "solution") is True - - -def test_add_node_is_solution_true_after_geff_roundtrip(get_tracks, tmp_path): - tracks = get_tracks(ndim=3, with_seg=False, is_solution=True) - loaded = _roundtrip(tracks, tmp_path) - - new_id = max(loaded.graph.node_ids()) + 1 - AddNode( - loaded, - new_id, - { - "t": 0, - "track_id": 999, - "lineage_id": 999, - "pos": np.array([1.0, 2.0]), - }, - ) - assert loaded.get_node_attr(new_id, "solution") is True diff --git a/tests_old/import_export/test_utils.py b/tests_old/import_export/test_utils.py deleted file mode 100644 index cbfa1bcc..00000000 --- a/tests_old/import_export/test_utils.py +++ /dev/null @@ -1,43 +0,0 @@ -from funtracks.import_export._utils import rename_feature - - -def test_rename_feature_basic(get_tracks): - """Test that rename_feature renames a feature in annotators and features dict.""" - tracks = get_tracks(ndim=3, with_seg=True, is_solution=False) - - # Rename area feature to custom name - rename_feature(tracks, "area", "my_area") - - # Check that feature was renamed in annotators - assert "my_area" in tracks.annotators.all_features - assert "area" not in tracks.annotators.all_features - - # Check that feature was renamed in features dict - assert "my_area" in tracks.features - assert "area" not in tracks.features - - -def test_rename_feature_updates_position_key(get_tracks): - """Test that renaming position feature updates position_key in FeatureDict.""" - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - - original_pos_key = tracks.features.position_key - new_key = "custom_position" - - rename_feature(tracks, original_pos_key, new_key) - - assert tracks.features.position_key == new_key - assert new_key in tracks.features - - -def test_rename_feature_updates_tracklet_key(get_tracks): - """Test that renaming tracklet feature updates tracklet_key in FeatureDict.""" - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - - original_track_key = tracks.features.tracklet_key - new_key = "custom_track" - - rename_feature(tracks, original_track_key, new_key) - - assert tracks.features.tracklet_key == new_key - assert new_key in tracks.features diff --git a/tests_old/user_actions/__init__.py b/tests_old/user_actions/__init__.py deleted file mode 100644 index 5c7fde60..00000000 --- a/tests_old/user_actions/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# This file makes the tests/user_actions directory a Python package -# to support relative imports diff --git a/tests_old/user_actions/test_user_actions_force.py b/tests_old/user_actions/test_user_actions_force.py deleted file mode 100644 index 1096b958..00000000 --- a/tests_old/user_actions/test_user_actions_force.py +++ /dev/null @@ -1,48 +0,0 @@ -import pytest - -from funtracks.user_actions import UserAddNode - - -def test_user_force_add_downstream(get_tracks): - """Test force adding a node of which the track id has an upstream division event. - Should break the edges of the division event to allow this new edge.""" - - tracks = get_tracks(ndim=3, with_seg=False, is_solution=True) - - # upstream division, with force - attrs = {"t": 2, "track_id": 1, "pos": [3, 4]} - UserAddNode(tracks, node=7, attributes=attrs, force=True) - assert tracks.get_track_id(7) == 1 - assert [1, 2] not in tracks.graph.edge_list() - assert [1, 3] not in tracks.graph.edge_list() - assert [1, 7] in tracks.graph.edge_list() - - -def test_user_force_add_upstream(get_tracks): - """Test force adding a node upstream, of which the track id co-exists with the parent - track id. Should break the edge with the parent track to allow this new edge.""" - - tracks = get_tracks(ndim=3, with_seg=False, is_solution=True) - - # downstream parent division, with force - attrs = {"t": 0, "track_id": 3, "pos": [3, 4]} - UserAddNode(tracks, node=7, attributes=attrs, force=True) - assert tracks.get_track_id(7) == 3 - assert [1, 2] in tracks.graph.edge_list() # still there - assert [1, 3] not in tracks.graph.edge_list() # should be removed - assert [7, 3] in tracks.graph.edge_list() # new forced edge - - -def test_auto_assign_new_track_id(get_tracks): - """Test that adding a node with a track id that already exists at the current time - point raises a warning and auto-assigns a new track id instead.""" - - tracks = get_tracks(ndim=3, with_seg=False, is_solution=True) - - # existing track id at current time --> allowed, with warning - with pytest.warns(UserWarning, match="Starting a new track, because track id"): - attrs = {"t": 1, "track_id": 2, "pos": [3, 4]} # combination exists already - UserAddNode(tracks, node=7, attributes=attrs) - - assert tracks.graph.has_node(7) - assert tracks.get_track_id(7) == 6 # new assigned track id diff --git a/tests_old/user_actions/test_user_add_delete_edge.py b/tests_old/user_actions/test_user_add_delete_edge.py deleted file mode 100644 index 921f3e1d..00000000 --- a/tests_old/user_actions/test_user_add_delete_edge.py +++ /dev/null @@ -1,138 +0,0 @@ -import pytest - -from funtracks.exceptions import InvalidActionError -from funtracks.user_actions import UserAddEdge, UserDeleteEdge - - -@pytest.mark.parametrize("ndim", [3, 4]) -@pytest.mark.parametrize("with_seg", [True, False]) -class TestUserAddDeleteEdge: - def test_user_add_edge(self, get_tracks, ndim, with_seg): - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - # add an edge from 4 to 6 (will make 4 a division and 5 will need to relabel - # track id) - edge = (4, 6) - old_child = 5 - old_track_id = tracks.get_track_id(old_child) - assert not tracks.graph.has_edge(*edge) - action = UserAddEdge(tracks, edge) - assert tracks.graph.has_edge(*edge) - assert tracks.get_track_id(old_child) != old_track_id - - inverse = action.inverse() - assert not tracks.graph.has_edge(*edge) - assert tracks.get_track_id(old_child) == old_track_id - - inverse.inverse() - assert tracks.graph.has_edge(*edge) - assert tracks.get_track_id(old_child) != old_track_id - - def test_user_add_merge_edge(self, get_tracks, ndim, with_seg): - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - # add an edge from 2 to 4 (there is already an edge from 3 to 4) - edge = (2, 4) - old_edge = (3, 4) - assert not tracks.graph.has_edge(*edge) - assert tracks.graph.has_edge(*old_edge) - with pytest.raises( - InvalidActionError, match="Cannot make a merge edge in a tracking solution" - ): - UserAddEdge(tracks, edge) - with pytest.warns( - UserWarning, - match="Removing edge .* to add new edge without merging.", - ): - action = UserAddEdge(tracks, edge, force=True) - assert tracks.graph.has_edge(*edge) - assert not tracks.graph.has_edge(*old_edge) - - inverse = action.inverse() - assert not tracks.graph.has_edge(*edge) - assert tracks.graph.has_edge(*old_edge) - - inverse.inverse() - assert tracks.graph.has_edge(*edge) - assert not tracks.graph.has_edge(*old_edge) - - def test_user_delete_edge(self, get_tracks, ndim, with_seg): - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - # delete edge (1, 3). (1,2) is now not a division anymore - edge = (1, 3) - old_child = 2 - - old_track_id = tracks.get_track_id(old_child) - new_track_id = tracks.get_track_id(1) - assert tracks.graph.has_edge(*edge) - - action = UserDeleteEdge(tracks, edge) - assert not tracks.graph.has_edge(*edge) - assert tracks.get_track_id(old_child) == new_track_id - - inverse = action.inverse() - assert tracks.graph.has_edge(*edge) - assert tracks.get_track_id(old_child) == old_track_id - - double_inv = inverse.inverse() - assert not tracks.graph.has_edge(*edge) - assert tracks.get_track_id(old_child) == new_track_id - - # TODO: error if edge doesn't exist? - double_inv.inverse() - - # delete edge (3, 4). 4 and 5 should get new track id - edge = (3, 4) - old_child = 5 - - old_track_id = tracks.get_track_id(old_child) - assert tracks.graph.has_edge(*edge) - - action = UserDeleteEdge(tracks, edge) - assert not tracks.graph.has_edge(*edge) - assert tracks.get_track_id(old_child) != old_track_id - - inverse = action.inverse() - assert tracks.graph.has_edge(*edge) - assert tracks.get_track_id(old_child) == old_track_id - - inverse.inverse() - assert not tracks.graph.has_edge(*edge) - assert tracks.get_track_id(old_child) != old_track_id - - -def test_add_edge_missing_node(get_tracks): - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - with pytest.raises(InvalidActionError, match="Source node .* not in solution yet"): - UserAddEdge(tracks, (10, 11)) - with pytest.raises(InvalidActionError, match="Target node .* not in solution yet"): - UserAddEdge(tracks, (1, 11)) - - -def test_add_edge_triple_div(get_tracks): - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - with pytest.raises( - InvalidActionError, match="Expected degree of 0 or 1 before adding edge" - ): - UserAddEdge(tracks, (1, 6)) - - -def test_delete_missing_edge(get_tracks): - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - with pytest.raises(InvalidActionError, match="Edge .* not in solution"): - UserDeleteEdge(tracks, (10, 11)) - - -def test_delete_edge_triple_div(get_tracks): - tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - attrs = {} - attrs["solution"] = True - attrs["iou"] = 0.9 - - tracks.graph.add_edge( - source_id=1, - target_id=6, - attrs=attrs, - ) - with pytest.raises( - InvalidActionError, match="Expected degree of 0 or 1 after removing edge, got 2" - ): - UserDeleteEdge(tracks, (1, 6)) diff --git a/tests_old/user_actions/test_user_add_delete_node.py b/tests_old/user_actions/test_user_add_delete_node.py deleted file mode 100644 index 5524f021..00000000 --- a/tests_old/user_actions/test_user_add_delete_node.py +++ /dev/null @@ -1,195 +0,0 @@ -import numpy as np -import pytest - -from funtracks.exceptions import InvalidActionError -from funtracks.user_actions import UserAddNode, UserDeleteNode, UserDeleteNodes - - -@pytest.mark.parametrize("ndim", [3, 4]) -@pytest.mark.parametrize("with_seg", [True, False]) -class TestUserAddDeleteNode: - def test_user_add_invalid_node(self, get_tracks, ndim, with_seg): - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - # duplicate node - with pytest.raises(InvalidActionError, match="Node .* already exists"): - attrs = {"t": 5, "track_id": 1} - UserAddNode(tracks, node=1, attributes=attrs) - - # no time - with pytest.raises(InvalidActionError, match="Cannot add node without time"): - attrs = {"track_id": 1} - UserAddNode(tracks, node=7, attributes=attrs) - - # no track_id - with pytest.raises(InvalidActionError, match="Cannot add node without track id"): - attrs = {"t": 1} - UserAddNode(tracks, node=7, attributes=attrs) - - # upstream division - with pytest.raises( - InvalidActionError, - match="Cannot add node here - upstream division event detected", - ): - attrs = {"t": 2, "track_id": 1} - UserAddNode(tracks, node=7, attributes=attrs) - - def test_user_add_node(self, get_tracks, ndim, with_seg): - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - # add a node to replace a skip edge between node 4 in time 2 and node 5 in time 4 - node_id = 7 - track_id = 3 - time = 3 - position = [50, 50, 50] if ndim == 4 else [50, 50] - attributes = { - "track_id": track_id, - "pos": position, - "t": time, - } - if with_seg: - seg_copy = np.asarray(tracks.segmentation).copy() - if ndim == 3: - seg_copy[time, position[0], position[1]] = node_id - else: - seg_copy[time, position[0], position[1], position[2]] = node_id - pixels = np.nonzero(seg_copy == node_id) - del attributes["pos"] - else: - pixels = None - graph = tracks.graph - assert not graph.has_node(node_id) - assert graph.has_edge(4, 5) - action = UserAddNode(tracks, node_id, attributes, pixels=pixels) - assert graph.has_node(node_id) - assert not graph.has_edge(4, 5) - assert graph.has_edge(4, node_id) - assert graph.has_edge(node_id, 5) - assert tracks.get_position(node_id) == position - assert tracks.get_track_id(node_id) == track_id - if with_seg: - assert tracks.get_node_attr(node_id, "area") == 1 - - inverse = action.inverse() - assert not graph.has_node(node_id) - assert graph.has_edge(4, 5) - - inverse.inverse() - assert graph.has_node(node_id) - assert not graph.has_edge(4, 5) - assert graph.has_edge(4, node_id) - assert graph.has_edge(node_id, 5) - assert tracks.get_position(node_id) == position - assert tracks.get_track_id(node_id) == track_id - if with_seg: - assert tracks.get_node_attr(node_id, "area") == 1 - # TODO: error if node already exists? - - def test_user_delete_node(self, get_tracks, ndim, with_seg): - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - # delete node in middle of track. Should skip-connect 3 and 5 with span 3 - node_id = 4 - - graph = tracks.graph - assert graph.has_node(node_id) - assert graph.has_edge(3, node_id) - assert graph.has_edge(node_id, 5) - assert not graph.has_edge(3, 5) - - action = UserDeleteNode(tracks, node_id) - assert not graph.has_node(node_id) - assert not graph.has_edge(3, node_id) - assert not graph.has_edge(node_id, 5) - assert graph.has_edge(3, 5) - - inverse = action.inverse() - assert graph.has_node(node_id) - assert graph.has_edge(3, node_id) - assert graph.has_edge(node_id, 5) - assert not graph.has_edge(3, 5) - - inverse.inverse() - assert not graph.has_node(node_id) - assert not graph.has_edge(3, node_id) - assert not graph.has_edge(node_id, 5) - assert graph.has_edge(3, 5) - - # Regression: calling action.inverse() a second time (undo after redo after undo) - action.inverse() - assert graph.has_node(node_id) - assert graph.has_edge(3, node_id) - assert graph.has_edge(node_id, 5) - assert not graph.has_edge(3, 5) - # TODO: error if node doesn't exist? - - def test_user_delete_node_after_division(self, get_tracks, ndim, with_seg): - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - # delete first node after division. Should relabel the other child - # to be the same track as parent - parent_node = 1 - node_id = 2 - sib = 3 - - graph = tracks.graph - assert graph.has_node(node_id) - assert graph.has_edge(parent_node, node_id) - parent_track_id = tracks.get_track_id(parent_node) - node_track_id = tracks.get_track_id(node_id) - sib_track_id = tracks.get_track_id(sib) - assert parent_track_id != node_track_id - assert parent_track_id != sib_track_id - assert node_track_id != sib_track_id - - action = UserDeleteNode(tracks, node_id) - assert not graph.has_node(node_id) - assert graph.has_edge(parent_node, sib) - assert tracks.get_track_id(sib) == parent_track_id - - inverse = action.inverse() - assert graph.has_node(node_id) - assert graph.has_edge(parent_node, node_id) - assert tracks.get_track_id(parent_node) == parent_track_id - assert tracks.get_track_id(node_id) == node_track_id - assert tracks.get_track_id(sib) == sib_track_id - - inverse.inverse() - assert not graph.has_node(node_id) - assert graph.has_edge(parent_node, sib) - assert tracks.get_track_id(sib) == parent_track_id - - def test_user_delete_nodes(self, get_tracks, ndim, with_seg): - """Test bulk deletion of multiple nodes in a single action.""" - # Graph structure: 1 → 2, 1 → 3 → 4 → 5, and 6 (separate) - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - graph = tracks.graph - - # Save original state - original_nodes = set(graph.node_ids()) - original_edges = set(graph.edge_ids()) - original_track_ids = {n: tracks.get_track_id(n) for n in original_nodes} - - # Delete nodes 4 and 6 (mid-track node and isolated node) - nodes_to_delete = [4, 6] - action = UserDeleteNodes(tracks, nodes_to_delete) - - # Both nodes removed - assert not graph.has_node(4) - assert not graph.has_node(6) - # Track reconnected: 3 → 5 (skip edge replacing 3 → 4 → 5) - assert graph.has_edge(3, 5) - assert not graph.has_edge(3, 4) - assert not graph.has_edge(4, 5) - - # Single history entry - assert tracks.action_history.undo_stack[-1] is action - - # Undo restores all nodes and edges - inverse = action.inverse() - assert set(graph.node_ids()) == original_nodes - assert set(graph.edge_ids()) == original_edges - for node in original_nodes: - assert tracks.get_track_id(node) == original_track_ids[node] - - # Redo re-deletes - inverse.inverse() - assert not graph.has_node(4) - assert not graph.has_node(6) - assert graph.has_edge(3, 5) diff --git a/tests_old/user_actions/test_user_swap_predecessors.py b/tests_old/user_actions/test_user_swap_predecessors.py deleted file mode 100644 index d33faeb3..00000000 --- a/tests_old/user_actions/test_user_swap_predecessors.py +++ /dev/null @@ -1,116 +0,0 @@ -import pytest - -from funtracks.exceptions import InvalidActionError -from funtracks.user_actions import UserAddEdge, UserDeleteEdge, UserSwapPredecessors - - -@pytest.mark.parametrize("ndim", [3, 4]) -@pytest.mark.parametrize("with_seg", [True, False]) -class TestUserSwapPredecessors: - @pytest.mark.parametrize("order", [(5, 6), (6, 5)]) - def test_one_predecessor(self, get_tracks, ndim, with_seg, order): - """Test swapping when one node has a predecessor and one doesn't.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - # Node 5 (t=4) has pred 4, node 6 (t=4) has no pred - assert tracks.graph.has_edge(4, 5) - assert list(tracks.graph.predecessors(6)) == [] - old_track_id_5 = tracks.get_track_id(5) - old_track_id_6 = tracks.get_track_id(6) - - action = UserSwapPredecessors(tracks, order) - - assert tracks.graph.has_edge(4, 6) - assert not tracks.graph.has_edge(4, 5) - assert tracks.get_track_id(6) == old_track_id_5 - assert tracks.get_track_id(5) != old_track_id_5 - - action.inverse() - assert tracks.graph.has_edge(4, 5) - assert not tracks.graph.has_edge(4, 6) - assert tracks.get_track_id(5) == old_track_id_5 - assert tracks.get_track_id(6) == old_track_id_6 - - def test_same_predecessor_raises(self, get_tracks, ndim, with_seg): - """Test error when both nodes have the same predecessor.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - # Nodes 2 and 3 both have predecessor 1 - with pytest.raises(InvalidActionError, match="same predecessor"): - UserSwapPredecessors(tracks, (2, 3)) - - def test_different_predecessors(self, get_tracks, ndim, with_seg): - """Test swapping when both nodes have different predecessors.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - UserAddEdge(tracks, (2, 6)) - - # Node 5 has pred 4, node 6 has pred 2 - old_track_id_5 = tracks.get_track_id(5) - old_track_id_6 = tracks.get_track_id(6) - - action = UserSwapPredecessors(tracks, (5, 6)) - - assert tracks.graph.has_edge(4, 6) - assert tracks.graph.has_edge(2, 5) - assert not tracks.graph.has_edge(4, 5) - assert not tracks.graph.has_edge(2, 6) - - action.inverse() - assert tracks.graph.has_edge(4, 5) - assert tracks.graph.has_edge(2, 6) - assert tracks.get_track_id(5) == old_track_id_5 - assert tracks.get_track_id(6) == old_track_id_6 - - def test_different_times_valid(self, get_tracks, ndim, with_seg): - """Test swapping nodes at different times when predecessors are valid.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - # Add edge 2->6 so node 6 (t=4) has pred 2 (t=1) - # Node 4 (t=2) has pred 3 (t=1) - # Both preds at t=1 are before both nodes (t=2 and t=4) - UserAddEdge(tracks, (2, 6)) - - action = UserSwapPredecessors(tracks, (4, 6)) - - assert tracks.graph.has_edge(3, 6) - assert tracks.graph.has_edge(2, 4) - assert not tracks.graph.has_edge(3, 4) - assert not tracks.graph.has_edge(2, 6) - - action.inverse() - assert tracks.graph.has_edge(3, 4) - assert tracks.graph.has_edge(2, 6) - - def test_different_times_invalid_raises(self, get_tracks, ndim, with_seg): - """Test error when predecessor would not be before swapped node.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - # Node 3 (t=1) has pred 1 (t=0), node 4 (t=2) has pred 3 (t=1) - # pred of 4 (t=1) is not before node 3 (t=1) - with pytest.raises(InvalidActionError, match="Cannot swap: predecessor"): - UserSwapPredecessors(tracks, (3, 4)) - - def test_wrong_count_raises(self, get_tracks, ndim, with_seg): - """Test error when not exactly two nodes provided.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - with pytest.raises( - InvalidActionError, match="You can only swap a pair of two nodes" - ): - UserSwapPredecessors(tracks, (1,)) # type: ignore[arg-type] - - with pytest.raises( - InvalidActionError, match="You can only swap a pair of two nodes" - ): - UserSwapPredecessors(tracks, (1, 2, 3)) # type: ignore[arg-type] - - def test_no_predecessors_raises(self, get_tracks, ndim, with_seg): - """Test error when neither node has a predecessor.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - # Delete edge so node 5 has no predecessor like node 6 - UserDeleteEdge(tracks, (4, 5)) - - with pytest.raises(InvalidActionError, match="neither node has a predecessor"): - UserSwapPredecessors(tracks, (5, 6)) diff --git a/tests_old/user_actions/test_user_update_node_attrs.py b/tests_old/user_actions/test_user_update_node_attrs.py deleted file mode 100644 index 5e85a03b..00000000 --- a/tests_old/user_actions/test_user_update_node_attrs.py +++ /dev/null @@ -1,143 +0,0 @@ -import polars as pl -import pytest - -from funtracks.user_actions import UserUpdateNodeAttrs - - -@pytest.mark.parametrize("ndim", [3, 4]) -@pytest.mark.parametrize("with_seg", [True, False]) -class TestUserUpdateNodeAttrs: - def test_user_update_node_attrs(self, get_tracks, ndim, with_seg): - """Test basic node attribute update functionality.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - tracks.graph.add_node_attr_key("label", default_value=None, dtype=pl.Object) - tracks.graph.add_node_attr_key("confidence", default_value=0, dtype=pl.Float64) - tracks.graph.add_node_attr_key("validated", default_value=False, dtype=pl.Boolean) - - # Add custom attributes to update - custom_attrs = {"label": "my_label", "confidence": 0.95, "validated": True} - - # Update node 1 with custom attributes - action = UserUpdateNodeAttrs(tracks, node=1, attrs=custom_attrs) - - # Verify attributes were updated - assert tracks.get_node_attr(1, "label") == "my_label" - assert tracks.get_node_attr(1, "confidence") == 0.95 - assert tracks.get_node_attr(1, "validated") is True - - # Verify action was added to history - assert len(tracks.action_history.undo_stack) == 1 - - # Test undo - should restore to pre-update defaults - inverse = action.inverse() - assert tracks.get_node_attr(1, "label") is None - assert tracks.get_node_attr(1, "confidence") == 0 - assert not tracks.get_node_attr(1, "validated") - - # Test redo - inverse.inverse() - assert tracks.get_node_attr(1, "label") == "my_label" - assert tracks.get_node_attr(1, "confidence") == 0.95 - assert tracks.get_node_attr(1, "validated") is True - - def test_user_update_existing_attrs(self, get_tracks, ndim, with_seg): - """Test updating attributes that already exist.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - tracks.graph.add_node_attr_key("label", default_value=None, dtype=pl.Object) - tracks.graph.add_node_attr_key("score", default_value=None, dtype=pl.Float64) - - # Set initial custom attributes - tracks._set_node_attr(1, "label", "old_label") - tracks._set_node_attr(1, "score", 0.5) - - # Update to new values - new_attrs = {"label": "new_label", "score": 0.9} - action = UserUpdateNodeAttrs(tracks, node=1, attrs=new_attrs) - - # Verify new values - assert tracks.get_node_attr(1, "label") == "new_label" - assert tracks.get_node_attr(1, "score") == 0.9 - - # Test undo restores old values - action.inverse() - assert tracks.get_node_attr(1, "label") == "old_label" - assert tracks.get_node_attr(1, "score") == 0.5 - - def test_protected_time_attr(self, get_tracks, ndim, with_seg): - """Test that time attribute cannot be updated.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - time_key = tracks.features.time_key - - with pytest.raises(ValueError, match="Cannot update attribute"): - UserUpdateNodeAttrs(tracks, node=1, attrs={time_key: 999}) - - def test_protected_track_id_attr(self, get_tracks, ndim, with_seg): - """Test that track_id attribute cannot be updated.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - with pytest.raises(ValueError, match="Cannot update attribute"): - UserUpdateNodeAttrs(tracks, node=1, attrs={"track_id": 999}) - - def test_protected_area_attr(self, get_tracks, ndim, with_seg): - """Test that area attribute (managed by annotator) cannot be updated.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - if with_seg: # area only exists when segmentation is present - with pytest.raises(ValueError, match="Cannot update attribute"): - UserUpdateNodeAttrs(tracks, node=1, attrs={"area": 999}) - - def test_protected_pos_attr(self, get_tracks, ndim, with_seg): - """Test that position attribute (managed by annotator) cannot be updated.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - if with_seg: # pos is managed by RegionpropsAnnotator when seg exists - with pytest.raises(ValueError, match="Cannot update attribute"): - UserUpdateNodeAttrs(tracks, node=1, attrs={"pos": [0, 0]}) - - def test_action_history_integration(self, get_tracks, ndim, with_seg): - """Test that action integrates properly with action history.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - tracks.graph.add_node_attr_key("label", default_value=None, dtype=pl.Object) - - # Initially empty - assert len(tracks.action_history.undo_stack) == 0 - assert len(tracks.action_history.redo_stack) == 0 - - # Add first update - UserUpdateNodeAttrs(tracks, node=1, attrs={"label": "first"}) - assert len(tracks.action_history.undo_stack) == 1 - assert len(tracks.action_history.redo_stack) == 0 - assert tracks.get_node_attr(1, "label") == "first" - - # Add second update - UserUpdateNodeAttrs(tracks, node=2, attrs={"label": "second"}) - assert len(tracks.action_history.undo_stack) == 2 - assert len(tracks.action_history.redo_stack) == 0 - assert tracks.get_node_attr(2, "label") == "second" - - # Undo second update - tracks.action_history.undo() - assert len(tracks.action_history.undo_stack) == 2 # Actions stay in undo_stack - assert len(tracks.action_history.redo_stack) == 1 # Inverse added to redo_stack - assert tracks.get_node_attr(2, "label") is None - assert tracks.get_node_attr(1, "label") == "first" - - # Undo first update - tracks.action_history.undo() - assert len(tracks.action_history.undo_stack) == 2 - assert len(tracks.action_history.redo_stack) == 2 - assert tracks.get_node_attr(1, "label") is None - - # Redo first update - tracks.action_history.redo() - assert len(tracks.action_history.undo_stack) == 2 - assert len(tracks.action_history.redo_stack) == 1 - assert tracks.get_node_attr(1, "label") == "first" - - # Redo second update - tracks.action_history.redo() - assert len(tracks.action_history.undo_stack) == 2 - assert len(tracks.action_history.redo_stack) == 0 - assert tracks.get_node_attr(2, "label") == "second" diff --git a/tests_old/user_actions/test_user_update_nodes_attrs.py b/tests_old/user_actions/test_user_update_nodes_attrs.py deleted file mode 100644 index bb853887..00000000 --- a/tests_old/user_actions/test_user_update_nodes_attrs.py +++ /dev/null @@ -1,102 +0,0 @@ -import numpy as np -import polars as pl -import pytest - -from funtracks.user_actions import UserUpdateNodesAttrs - - -@pytest.mark.parametrize("ndim", [3, 4]) -@pytest.mark.parametrize("with_seg", [True, False]) -class TestUserUpdateNodesAttrs: - def test_user_update_nodes_attrs(self, get_tracks, ndim, with_seg): - """Test basic bulk node attribute update functionality.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - tracks.graph.add_node_attr_key("label", default_value=None, dtype=pl.Object) - tracks.graph.add_node_attr_key("confidence", default_value=0, dtype=pl.Float64) - - attrs = {"label": ["my_label", "my_label"], "confidence": [0.95, 0.95]} - UserUpdateNodesAttrs(tracks, nodes=[1, 2], attrs=attrs) - - for node in [1, 2]: - assert tracks.get_node_attr(node, "label") == "my_label" - assert tracks.get_node_attr(node, "confidence") == 0.95 - - def test_single_history_entry(self, get_tracks, ndim, with_seg): - """Updating multiple nodes creates only one history entry.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - tracks.graph.add_node_attr_key("label", default_value=None, dtype=pl.Object) - - action = UserUpdateNodesAttrs( - tracks, nodes=[1, 2, 3], attrs={"label": ["x", "x", "x"]} - ) - - assert len(tracks.action_history.undo_stack) == 1 - assert tracks.action_history.undo_stack[-1] is action - - def test_undo_redo(self, get_tracks, ndim, with_seg): - """Undo restores all nodes' attrs to defaults; redo re-applies them.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - tracks.graph.add_node_attr_key("score", default_value=0, dtype=pl.Float64) - - action = UserUpdateNodesAttrs(tracks, nodes=[1, 2], attrs={"score": [0.9, 0.9]}) - - for node in [1, 2]: - assert tracks.get_node_attr(node, "score") == 0.9 - - inverse = action.inverse() - - for node in [1, 2]: - assert tracks.get_node_attr(node, "score") == 0 - - inverse.inverse() - - for node in [1, 2]: - assert tracks.get_node_attr(node, "score") == 0.9 - - def test_per_node_attrs(self, get_tracks, ndim, with_seg): - """Test bulk update with different values per node.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - tracks.graph.add_node_attr_key("score", default_value=0, dtype=pl.Float64) - - UserUpdateNodesAttrs(tracks, nodes=[1, 2], attrs={"score": [0.1, 0.9]}) - - assert tracks.get_node_attr(1, "score") == 0.1 - assert tracks.get_node_attr(2, "score") == 0.9 - - def test_array_attr(self, get_tracks, ndim, with_seg): - """Test bulk update with array-valued attributes.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - spatial_dims = ndim - 1 - - tracks.graph.add_node_attr_key( - "custom_pos", default_value=None, dtype=pl.Array(pl.Float64, spatial_dims) - ) - - positions = [[float(i)] * spatial_dims for i in range(2)] - UserUpdateNodesAttrs(tracks, nodes=[1, 2], attrs={"custom_pos": positions}) - - assert np.all(np.asarray(tracks.get_node_attr(1, "custom_pos")) == positions[0]) - assert np.all(np.asarray(tracks.get_node_attr(2, "custom_pos")) == positions[1]) - - def test_values_not_list_raises(self, get_tracks, ndim, with_seg): - """Non-list values raise ValueError.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - with pytest.raises(ValueError, match="must be a list"): - UserUpdateNodesAttrs(tracks, nodes=[1, 2], attrs={"score": 0.9}) - - def test_values_length_mismatch_raises(self, get_tracks, ndim, with_seg): - """List length not matching nodes length raises ValueError.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - - with pytest.raises(ValueError, match="length"): - UserUpdateNodesAttrs(tracks, nodes=[1, 2], attrs={"score": [0.1]}) - - def test_protected_attr_raises(self, get_tracks, ndim, with_seg): - """Passing a protected attribute raises ValueError.""" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=True) - time_key = tracks.features.time_key - - with pytest.raises(ValueError, match="Cannot update attribute"): - UserUpdateNodesAttrs(tracks, nodes=[1, 2], attrs={time_key: [0, 1]}) diff --git a/tests_old/user_actions/test_user_update_segmentation.py b/tests_old/user_actions/test_user_update_segmentation.py deleted file mode 100644 index d2d67a12..00000000 --- a/tests_old/user_actions/test_user_update_segmentation.py +++ /dev/null @@ -1,291 +0,0 @@ -from collections import Counter - -import numpy as np -import pytest - -from funtracks.exceptions import InvalidActionError -from funtracks.user_actions import UserUpdateSegmentation -from funtracks.utils.tracksdata_utils import td_mask_to_pixels - -iou_key = "iou" -area_key = "area" - - -# TODO: add area to the 4d testing graph -@pytest.mark.parametrize( - "ndim", - [3], -) -class TestUpdateNodeSeg: - def pixels_equal_mask(self, pixels, tracks, node_id): - mask_pixels = td_mask_to_pixels( - tracks.get_mask(node_id), tracks.get_time(node_id), ndim=tracks.ndim - ) - return Counter(zip(*pixels, strict=True)) == Counter( - zip(*mask_pixels, strict=True) - ) - - def test_user_update_seg_smaller(self, get_tracks, ndim): - tracks = get_tracks(ndim=ndim, with_seg=True, is_solution=True) - node_id = 3 - edge = (1, 3) - - orig_pixels = td_mask_to_pixels( - tracks.get_mask(node_id), tracks.get_time(node_id), ndim=tracks.ndim - ) - orig_position = tracks.get_position(node_id) - orig_area = tracks.get_node_attr(node_id, area_key) - orig_iou = tracks.get_edge_attr(edge, iou_key) - - # remove all but one pixel - pixels_to_remove = tuple(orig_pixels[d][1:] for d in range(len(orig_pixels))) - remaining_loc = tuple(orig_pixels[d][0] for d in range(len(orig_pixels))) - new_position = [remaining_loc[1].item(), remaining_loc[2].item()] - remaining_pixels = tuple( - np.array([remaining_loc[d]]) for d in range(len(orig_pixels)) - ) - - action = UserUpdateSegmentation( - tracks, - new_value=0, - updated_pixels=[(pixels_to_remove, node_id)], - current_track_id=1, - ) - assert tracks.graph.has_node(node_id) - assert self.pixels_equal_mask(remaining_pixels, tracks, node_id) - assert tracks.get_position(node_id) == new_position - assert tracks.get_node_attr(node_id, "area") == 1 - assert tracks.get_edge_attr(edge, iou_key) == pytest.approx(0.0, abs=0.01) - - inverse = action.inverse() - assert tracks.graph.has_node(node_id) - assert self.pixels_equal_mask(orig_pixels, tracks, node_id) - assert tracks.get_position(node_id) == orig_position - assert tracks.get_node_attr(node_id, "area") == orig_area - assert tracks.get_edge_attr(edge, iou_key) == pytest.approx(orig_iou, abs=0.01) - - inverse.inverse() - assert self.pixels_equal_mask(remaining_pixels, tracks, node_id) - assert tracks.get_position(node_id) == new_position - assert tracks.get_node_attr(node_id, "area") == 1 - assert tracks.get_edge_attr(edge, iou_key) == pytest.approx(0.0, abs=0.01) - - def test_user_update_seg_bigger(self, get_tracks, ndim): - tracks = get_tracks(ndim=ndim, with_seg=True, is_solution=True) - node_id = 3 - edge = (1, 3) - - orig_pixels = td_mask_to_pixels( - tracks.get_mask(node_id), tracks.get_time(node_id), ndim=tracks.ndim - ) - orig_position = tracks.get_position(node_id) - orig_area = tracks.get_node_attr(node_id, "area") - orig_iou = tracks.get_edge_attr(edge, iou_key) - - # add one pixel - pixels_to_add = tuple( - np.array([orig_pixels[d][0]]) for d in range(len(orig_pixels)) - ) - new_x_val = 10 - pixels_to_add = (*pixels_to_add[:-1], np.array([new_x_val])) - all_pixels = tuple( - np.concat([orig_pixels[d], pixels_to_add[d]]) for d in range(len(orig_pixels)) - ) - - action = UserUpdateSegmentation( - tracks, new_value=3, updated_pixels=[(pixels_to_add, 0)], current_track_id=1 - ) - assert tracks.graph.has_node(node_id) - assert self.pixels_equal_mask(all_pixels, tracks, node_id) - assert tracks.get_node_attr(node_id, "area") == orig_area + 1 - assert tracks.get_edge_attr(edge, iou_key) != orig_iou - - inverse = action.inverse() - assert tracks.graph.has_node(node_id) - assert self.pixels_equal_mask(orig_pixels, tracks, node_id) - assert tracks.get_position(node_id) == orig_position - assert tracks.get_node_attr(node_id, "area") == orig_area - assert tracks.get_edge_attr(edge, iou_key) == pytest.approx(orig_iou, abs=0.01) - - inverse.inverse() - assert tracks.graph.has_node(node_id) - assert self.pixels_equal_mask(all_pixels, tracks, node_id) - assert tracks.get_node_attr(node_id, "area") == orig_area + 1 - assert tracks.get_edge_attr(edge, iou_key) != orig_iou - - def test_invalid_action_with_segmentation(self, get_tracks, ndim): - tracks = get_tracks(ndim=ndim, with_seg=True, is_solution=True) - node_id = 1 - - # Paint on top of node 1 with track id 3: because of the downstream division, this - # should raise an invalid action error. - orig_pixels = td_mask_to_pixels( - tracks.get_mask(node_id), tracks.get_time(node_id), ndim=tracks.ndim - ) - - pixels_to_add = tuple( - np.array([orig_pixels[d][0]]) for d in range(len(orig_pixels)) - ) - new_value = 7 - - # assert InvalidActionError is raised - with pytest.raises( - InvalidActionError, - match="Cannot add node here - downstream division of parent detected.", - ): - UserUpdateSegmentation( - tracks, - new_value=new_value, - updated_pixels=[(pixels_to_add, node_id)], - current_track_id=3, - ) - # because the existing nodes are only updated after the UserAddNode action is - # applied (which does not happen if caught by the error), the original - # segmentation should be unchanged. - t, y, x = (a.item() for a in pixels_to_add) - assert np.asarray(tracks.segmentation[t, y, x]) == node_id - - # If the action is forced, the segmentation for node 1 should be updated, and the - # new node should be added. - update_seg_action = UserUpdateSegmentation( - tracks, - new_value=new_value, - updated_pixels=[(pixels_to_add, node_id)], - current_track_id=3, - force=True, - ) - - # assert that the segmentation now has the new value - assert np.asarray(tracks.segmentation[t, y, x]) == new_value - assert tracks.graph.has_node(new_value) - assert len(update_seg_action.actions) == 2 # one for adding a node, - # and one for updating existing node 1 - - def test_user_erase_seg(self, get_tracks, ndim): - tracks = get_tracks(ndim=ndim, with_seg=True, is_solution=True) - node_id = 3 - edge = (1, 3) - - orig_pixels = td_mask_to_pixels( - tracks.get_mask(node_id), tracks.get_time(node_id), ndim=tracks.ndim - ) - orig_position = tracks.get_position(node_id) - orig_area = tracks.get_node_attr(node_id, "area") - orig_iou = tracks.get_edge_attr(edge, iou_key) - - # remove all pixels - pixels_to_remove = orig_pixels - # setting of pixels no longer necessary, done in UpdateNodeSeg - action = UserUpdateSegmentation( - tracks, - new_value=0, - updated_pixels=[(pixels_to_remove, node_id)], - current_track_id=1, - ) - assert not tracks.graph.has_node(node_id) - - inverse = action.inverse() - assert tracks.graph.has_node(node_id) - self.pixels_equal_mask(orig_pixels, tracks, node_id) - assert tracks.get_position(node_id) == orig_position - assert tracks.get_node_attr(node_id, "area") == orig_area - assert tracks.get_edge_attr(edge, iou_key) == pytest.approx(orig_iou, abs=0.01) - - inverse.inverse() - assert not tracks.graph.has_node(node_id) - - def test_user_erase_seg_history_size(self, get_tracks, ndim): - """An erase via UserUpdateSegmentation must add exactly one history - entry. Regression test for a bug where the nested UserDeleteNode - also registered itself, leaving two entries per fill and corrupting - undo behavior.""" - tracks = get_tracks(ndim=ndim, with_seg=True, is_solution=True) - node_id = 6 - pixels = td_mask_to_pixels( - tracks.get_mask(node_id), tracks.get_time(node_id), ndim=tracks.ndim - ) - UserUpdateSegmentation( - tracks, - new_value=0, - updated_pixels=[(pixels, node_id)], - current_track_id=1, - ) - assert len(tracks.action_history.undo_stack) == 1 - - def test_user_two_erases_then_two_undos(self, get_tracks, ndim): - """Two consecutive erases must both be reversible via - tracks.action_history.undo(). Reproduces bug_paint_undo: the second - undo crashed because the buggy history had a duplicate UserDeleteNode - entry that tried to re-add an already-restored node.""" - tracks = get_tracks(ndim=ndim, with_seg=True, is_solution=True) - pixels_5 = td_mask_to_pixels( - tracks.get_mask(5), tracks.get_time(5), ndim=tracks.ndim - ) - pixels_6 = td_mask_to_pixels( - tracks.get_mask(6), tracks.get_time(6), ndim=tracks.ndim - ) - - UserUpdateSegmentation( - tracks, new_value=0, updated_pixels=[(pixels_5, 5)], current_track_id=1 - ) - assert not tracks.graph.has_node(5) - - UserUpdateSegmentation( - tracks, new_value=0, updated_pixels=[(pixels_6, 6)], current_track_id=1 - ) - assert not tracks.graph.has_node(6) - - assert tracks.action_history.undo() is True - assert tracks.graph.has_node(6) - assert not tracks.graph.has_node(5) - - assert tracks.action_history.undo() is True - assert tracks.graph.has_node(5) - assert tracks.graph.has_node(6) - - def test_user_add_seg(self, get_tracks, ndim): - tracks = get_tracks(ndim=ndim, with_seg=True, is_solution=True) - # draw a new node just like node 6 but in time 3 (instead of 4) - old_node_id = 6 - node_id = 7 - time = 3 - - pixels_to_add = td_mask_to_pixels( - tracks.get_mask(old_node_id), tracks.get_time(old_node_id), ndim=tracks.ndim - ) - pixels_to_add = ( - np.ones(shape=(pixels_to_add[0].shape), dtype=np.uint32) * time, - *pixels_to_add[1:], - ) - position = tracks.get_position(old_node_id) - area = tracks.get_node_attr(old_node_id, "area") - - assert not tracks.graph.has_node(node_id) - - assert np.sum(tracks.segmentation == node_id) == 0 - action = UserUpdateSegmentation( - tracks, - new_value=node_id, - updated_pixels=[(pixels_to_add, 0)], - current_track_id=10, - ) - assert np.sum(np.asarray(tracks.segmentation) == node_id) == len(pixels_to_add[0]) - assert tracks.graph.has_node(node_id) - assert tracks.get_position(node_id) == position - assert tracks.get_node_attr(node_id, "area") == area - assert tracks.get_track_id(node_id) == 10 - - inverse = action.inverse() - assert not tracks.graph.has_node(node_id) - - inverse.inverse() - assert tracks.graph.has_node(node_id) - assert tracks.get_position(node_id) == position - assert tracks.get_node_attr(node_id, "area") == area - assert tracks.get_track_id(node_id) == 10 - - -def test_missing_seg(get_tracks): - tracks = get_tracks(ndim=3, with_seg=False, is_solution=True) - with pytest.raises(ValueError, match="Cannot update non-existing segmentation"): - UserUpdateSegmentation(tracks, 0, [], 1) diff --git a/tests_old/utils/__init__.py b/tests_old/utils/__init__.py deleted file mode 100644 index 9782ccf2..00000000 --- a/tests_old/utils/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# This file makes the tests/utils directory a Python package -# to support relative imports diff --git a/tests_old/utils/test_tracksdata_utils.py b/tests_old/utils/test_tracksdata_utils.py deleted file mode 100644 index 32231579..00000000 --- a/tests_old/utils/test_tracksdata_utils.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Tests for tracksdata utility functions.""" - -import threading - -import numpy as np -import pytest - -from funtracks.utils.tracksdata_utils import ( - create_empty_graphview_graph, - pixels_to_td_mask, - td_mask_to_pixels, -) - -# Import from conftest -from ..conftest import ( - make_2d_disk_mask, - make_2d_square_mask, - make_3d_cube_mask, - make_3d_sphere_mask, -) - - -@pytest.mark.parametrize( - "mask_func,ndim", - [ - (lambda: make_2d_disk_mask(center=(50, 50), radius=20), 3), - (lambda: make_2d_disk_mask(center=(25, 75), radius=10), 3), - (lambda: make_2d_square_mask(start_corner=(10, 10), width=5), 3), - (lambda: make_3d_sphere_mask(center=(50, 50, 50), radius=20), 4), - (lambda: make_3d_sphere_mask(center=(25, 75, 30), radius=15), 4), - (lambda: make_3d_cube_mask(start_corner=(10, 10, 10), width=5), 4), - ], -) -def test_mask_pixels_roundtrip(mask_func, ndim): - """Test that mask -> pixels -> mask roundtrip preserves the mask.""" - # Create original mask - original_mask = mask_func() - time = 5 # Arbitrary time point - - # Convert mask to pixels - pixels = td_mask_to_pixels(original_mask, time=time, ndim=ndim) - - # Verify pixel format - assert len(pixels) == ndim # Should have ndim arrays - assert len(pixels[0]) == len(pixels[1]) # All arrays same length - assert np.all(pixels[0] == time) # Time should be constant - - # Convert pixels back to mask - reconstructed_mask, area = pixels_to_td_mask( - pixels, ndim=ndim, scale=[1 for _ in range(ndim)], include_area=True - ) - - # Verify the reconstructed mask matches the original - assert np.array_equal(reconstructed_mask.bbox, original_mask.bbox), ( - "Bounding boxes should match" - ) - assert np.array_equal(reconstructed_mask.mask, original_mask.mask), ( - "Mask arrays should match" - ) - assert area == np.sum(original_mask.mask), "Area should match pixel count" - - -@pytest.mark.parametrize("ndim", [3, 4]) -def test_mask_pixels_roundtrip_with_scale(ndim): - """Test mask->pixels->mask roundtrip with scale factors.""" - # Create mask - if ndim == 3: - mask = make_2d_disk_mask(center=(40, 60), radius=15) - scale = [1.0, 2.0, 3.0] # time, y, x scales - else: - mask = make_3d_sphere_mask(center=(40, 60, 30), radius=12) - scale = [1.0, 2.0, 3.0, 4.0] # time, z, y, x scales - - time = 3 - - # Convert mask to pixels - pixels = td_mask_to_pixels(mask, time=time, ndim=ndim) - - # Convert back with scale - reconstructed_mask, scaled_area = pixels_to_td_mask( - pixels, ndim=ndim, scale=scale, include_area=True - ) - - # Verify mask structure is preserved - assert np.array_equal(reconstructed_mask.bbox, mask.bbox) - assert np.array_equal(reconstructed_mask.mask, mask.mask) - - # Verify area is scaled correctly - expected_area = np.sum(mask.mask) * np.prod(scale[1:]) - assert np.isclose(scaled_area, expected_area), ( - f"Scaled area {scaled_area} should match expected {expected_area}" - ) - - -def test_td_mask_to_pixels_empty_mask(): - """Test converting an empty mask to pixels.""" - from tracksdata.nodes import Mask - - # Create a truly empty mask (all False) - empty_mask_array = np.zeros((2, 2), dtype=bool) - empty_bbox = np.array([10, 10, 12, 12]) - empty_mask = Mask(empty_mask_array, bbox=empty_bbox) - - pixels = td_mask_to_pixels(empty_mask, time=1, ndim=3) - - # Should return empty arrays - assert len(pixels) == 3 - assert len(pixels[0]) == 0 # No pixels - assert len(pixels[1]) == 0 - assert len(pixels[2]) == 0 - - -@pytest.mark.parametrize("ndim", [3, 4]) -def test_pixels_coordinate_offset(ndim): - """Test that bbox offset is correctly applied in pixel coordinates.""" - # Create a mask at a non-zero position - if ndim == 3: - mask = make_2d_square_mask(start_corner=(20, 30), width=3) - expected_bbox = np.array([20, 30, 23, 33]) - else: - mask = make_3d_cube_mask(start_corner=(20, 30, 40), width=3) - expected_bbox = np.array([20, 30, 40, 23, 33, 43]) - - assert np.array_equal(mask.bbox, expected_bbox) - - # Convert to pixels - pixels = td_mask_to_pixels(mask, time=7, ndim=ndim) - - # Verify pixel coordinates are in global space (not local) - if ndim == 3: - assert np.min(pixels[1]) == 20 # min y - assert np.max(pixels[1]) == 22 # max y - assert np.min(pixels[2]) == 30 # min x - assert np.max(pixels[2]) == 32 # max x - else: - assert np.min(pixels[1]) == 20 # min z - assert np.max(pixels[1]) == 22 # max z - assert np.min(pixels[2]) == 30 # min y - assert np.max(pixels[2]) == 32 # max y - assert np.min(pixels[3]) == 40 # min x - assert np.max(pixels[3]) == 42 # max x - - -def test_memory_graph_survives_thread_boundary(): - """A GraphView created in a worker thread must remain accessible from the main thread. - - Regression test: nodes_from_segmentation previously used database=':memory:', - which caused 'no such table: Metadata' when the graph crossed a thread boundary - (SQLite in-memory DBs are connection-scoped; a new thread gets a fresh empty DB). - Fix: use the default temp-file database instead of ':memory:'. - """ - result = {} - - def worker(): - graph = create_empty_graphview_graph( - node_attributes=["pos"], - ndim=3, - ) - graph.bulk_add_nodes([{"t": 0, "pos": [1.0, 2.0], "solution": True}], indices=[1]) - result["graph"] = graph - - t = threading.Thread(target=worker) - t.start() - t.join() - - graph = result["graph"] - - # This calls graph.metadata internally via BaseGraph.from_other(). - # With :memory: + default connection pool it opens a new empty DB → crash. - detached = graph.detach() - - assert detached.num_nodes() == 1 - - -def test_create_empty_graphview_graph_with_solution_attr(): - """Test that passing solution as a node/edge attribute does not raise. - - Regression test: create_empty_graphview_graph unconditionally added the - solution attribute at the end, even when it was already added via the - node_attributes / edge_attributes loop, causing a ValueError. - """ - # Should not raise ValueError even though solution is listed explicitly - graph = create_empty_graphview_graph( - node_attributes=["solution"], - edge_attributes=["solution"], - node_default_values=[True], - edge_default_values=[True], - ) - - assert graph is not None From 7e4594e88ab7a7efc5f65125fe73c2f43139279f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:21:37 -0700 Subject: [PATCH 16/20] [pre-commit.ci] pre-commit autoupdate (#279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.16.4 → v0.16.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.16.4...v0.16.5) - [github.com/adhtruong/mirrors-typos: v1.49.0 → v1.50.0](https://github.com/adhtruong/mirrors-typos/compare/v1.49.0...v1.50.0) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Teun Huijben <45037215+TeunHuijben@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- docs/features.md | 1 + docs/import-flow.md | 13 +++++-------- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b9968cb5..220b0d3f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: - id: check-yaml # checks for correct yaml syntax for github actions ex. args: [--unsafe] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.4 + rev: v0.16.5 hooks: - id: ruff args: [--fix] @@ -22,6 +22,6 @@ repos: - id: mypy - repo: https://github.com/adhtruong/mirrors-typos - rev: v1.49.0 + rev: v1.50.0 hooks: - id: typos diff --git a/docs/features.md b/docs/features.md index ddfeb217..911017dc 100644 --- a/docs/features.md +++ b/docs/features.md @@ -338,6 +338,7 @@ tracks.disable_features(["area"]) ```python from funtracks.annotators import GraphAnnotator + class MyCustomAnnotator(GraphAnnotator): @classmethod def can_annotate(cls, tracks): diff --git a/docs/import-flow.md b/docs/import-flow.md index 5710248b..bcc77b81 100644 --- a/docs/import-flow.md +++ b/docs/import-flow.md @@ -181,7 +181,7 @@ tracks = import_from_geff( name_map=None, # Auto-infer column mappings segmentation_path=Path("seg.tif"), scale=[1.0, 1.0, 1.0], - node_features={"area": True} + node_features={"area": True}, ) ``` @@ -194,14 +194,14 @@ from funtracks.import_export import tracks_from_df df = pd.read_csv("tracks.csv") # Load segmentation array -seg = ... # Load your segmentation array (e.g., from tiff, zarr) +seg = ... # Load your segmentation array (e.g., from tiff, zarr) # Import tracks tracks = tracks_from_df( df=df, segmentation=seg, # Pre-loaded segmentation array scale=[1.0, 1.0, 1.0], - features={"Area": "area"} # Load area from 'area' column + features={"Area": "area"}, # Load area from 'area' column ) ``` @@ -224,7 +224,7 @@ tracks = builder.build( source_path=Path("data.zarr"), segmentation_path=Path("seg.tif"), scale=[1.0, 1.0, 1.0], - node_features={"area": True} + node_features={"area": True}, ) ``` @@ -239,8 +239,5 @@ builder.prepare("data.csv") # Auto-infer name_map print(builder.name_map) builder.name_map["time"] = "frame_number" -tracks = builder.build( - source_path="data.csv", - segmentation_path="seg.tif" -) +tracks = builder.build(source_path="data.csv", segmentation_path="seg.tif") ``` From dd7c345c1e543014163361ba48b11e66842c749d Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Tue, 1 Sep 2026 11:45:06 -0700 Subject: [PATCH 17/20] latest tracksdata (merged td main into CMM's graph-views branch) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index deeec85e..a29c6617 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ dev = [ # edge-revive edge_id, missing-attr-key KeyError). Replace with a released version pin once # those land in royerlab/tracksdata. See .claude/plans/sql.md. [tool.uv.sources] -tracksdata = { git = "https://github.com/cmalinmayor/tracksdata.git", rev = "a3adcd5" } +tracksdata = { git = "https://github.com/cmalinmayor/tracksdata.git", rev = "19d1a8f4325b3f0a46e0d6022b8fcf5bb3fc0a41" } [tool.setuptools_scm] From 6d90bd5bd6fde541b7d258aa369b10f718b6f39f Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Thu, 3 Sep 2026 16:32:50 -0700 Subject: [PATCH 18/20] newer graph-views td branch --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a29c6617..3fd2b44a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ dev = [ # edge-revive edge_id, missing-attr-key KeyError). Replace with a released version pin once # those land in royerlab/tracksdata. See .claude/plans/sql.md. [tool.uv.sources] -tracksdata = { git = "https://github.com/cmalinmayor/tracksdata.git", rev = "19d1a8f4325b3f0a46e0d6022b8fcf5bb3fc0a41" } +tracksdata = { git = "https://github.com/cmalinmayor/tracksdata.git", rev = "fbbdcd6d96b2620843672cd5b266a4d8afe8eb0f" } [tool.setuptools_scm] From 91853f2d4ecff77dc892597d9f0429353c627765 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Thu, 10 Sep 2026 15:49:51 -0700 Subject: [PATCH 19/20] pin td rc10 (with graph-views merged) --- pyproject.toml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3fd2b44a..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 @@ -72,13 +72,6 @@ dev = [ {include-group = "docs"}, ] -# TEMPORARY: pin tracksdata to the `cmalinmayor/graph-views` branch, which merges the -# SQL-backend fixes (live-update views #325, numpy-scalar BLOB, pl.Array read truncation, -# edge-revive edge_id, missing-attr-key KeyError). Replace with a released version pin once -# those land in royerlab/tracksdata. See .claude/plans/sql.md. -[tool.uv.sources] -tracksdata = { git = "https://github.com/cmalinmayor/tracksdata.git", rev = "fbbdcd6d96b2620843672cd5b266a4d8afe8eb0f" } - [tool.setuptools_scm] [tool.pytest.ini_options] From e9f9d6928e2e752eb1763fdf6277ee8a23adc50e Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Thu, 10 Sep 2026 16:27:02 -0700 Subject: [PATCH 20/20] tracks() uses graph.filter().subgraph(mode=LIVE) to have root and views always be in sync, in both directions --- src/funtracks/data_model/tracks.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index 6f3ddeee..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