From 437d08c5cb2d9addc800d3fb49430add87717ff7 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Fri, 5 Dec 2025 13:02:47 -0800 Subject: [PATCH 01/44] replaced nx in conftest and worked on test regarding adding/deleting nodes/edges --- pyproject.toml | 1 + src/funtracks/actions/add_delete_node.py | 109 ++++++++- src/funtracks/annotators/_track_annotator.py | 4 +- src/funtracks/data_model/tracks.py | 25 +- src/funtracks/utils/tracksdata_utils.py | 242 +++++++++++++++++++ tests/actions/test_action_history.py | 8 +- tests/actions/test_add_delete_nodes.py | 65 +++-- tests/conftest.py | 112 +++++---- tests/data_model/test_solution_tracks.py | 8 +- tests/data_model/test_tracks.py | 6 + tests/import_export/test_csv_export.py | 2 +- tests/import_export/test_csv_import.py | 14 +- 12 files changed, 501 insertions(+), 95 deletions(-) create mode 100644 src/funtracks/utils/tracksdata_utils.py diff --git a/pyproject.toml b/pyproject.toml index c1e434e1..b321d731 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies =[ "geff>=1.1.3,<2", "dask>=2025.5.0", "pandas>=2.3.3", + "tracksdata@git+https://github.com/royerlab/tracksdata", ] [project.urls] diff --git a/src/funtracks/actions/add_delete_node.py b/src/funtracks/actions/add_delete_node.py index bec4dc0d..45666b20 100644 --- a/src/funtracks/actions/add_delete_node.py +++ b/src/funtracks/actions/add_delete_node.py @@ -10,6 +10,14 @@ from funtracks.data_model import SolutionTracks from funtracks.data_model.tracks import Node, SegMask +import numpy as np +import tracksdata as td + +from funtracks.utils.tracksdata_utils import ( + compute_node_attrs_from_masks, + compute_node_attrs_from_pixels, +) + class AddNode(BasicAction): """Action for adding new nodes. If a segmentation should also be added, the @@ -78,14 +86,105 @@ def inverse(self) -> BasicAction: def _apply(self) -> None: """Apply the action, and set segmentation if provided in self.pixels""" + attrs = self.attributes + + final_pos: np.ndarray + if self.tracks.segmentation is not None: + if self.pixels is not None: + computed_attrs = compute_node_attrs_from_pixels( + [self.pixels], self.tracks.ndim, self.tracks.scale + ) + # Extract single values from lists (since we passed one pixel set) + computed_attrs = {key: value[0] for key, value in computed_attrs.items()} + elif "mask" in attrs: + computed_attrs = compute_node_attrs_from_masks( + attrs["mask"], self.tracks.ndim, self.tracks.scale + ) + # Extract single values from lists (since we passed one mask) + computed_attrs = {key: value[0] for key, value in computed_attrs.items()} + # Handle position_key safely using the same pattern as in tracks.py + if isinstance(self.tracks.features.position_key, list): + # Multi-axis position keys - check if any are missing from attrs + missing_keys = [ + k for k in self.tracks.features.position_key if k not in attrs + ] + if missing_keys: + # Use computed position from segmentation + final_pos = np.array(computed_attrs["pos"]) + # Set individual components in attrs + for i, key in enumerate(self.tracks.features.position_key): + attrs[key] = ( + final_pos[i] if final_pos.ndim == 1 else final_pos[:, i] + ) + else: + # All position components provided, combine them + final_pos = np.stack( + [attrs[key] for key in self.tracks.features.position_key], axis=0 + ) + else: + # Single position key + pos_key = self.tracks.features.position_key + if pos_key is not None and pos_key not in attrs: + final_pos = np.array(computed_attrs["pos"]) + attrs[pos_key] = final_pos + elif pos_key is not None: + final_pos = np.array(attrs[pos_key]) + else: + raise ValueError("Position key is None") + # Set area using string literal since FeatureDict doesn't have area_key + attrs["area"] = computed_attrs["area"] + else: + # No segmentation - handle position_key safely + if isinstance(self.tracks.features.position_key, list): + # Multi-axis position keys - check if any are missing + missing_keys = [ + k for k in self.tracks.features.position_key if k not in attrs + ] + if missing_keys: + raise ValueError( + f"Must provide positions {missing_keys} or segmentation" + ) + # All position components provided, combine them + final_pos = np.stack( + [attrs[key] for key in self.tracks.features.position_key], axis=0 + ) + else: + # Single position key + if ( + self.tracks.features.position_key is None + or self.tracks.features.position_key not in attrs + ): + raise ValueError("Must provide positions or segmentation and ids") + final_pos = np.array(attrs[self.tracks.features.position_key]) + + # Position is already set in attrs above + # Add nodes to td graph + required_attrs = self.tracks.graph.node_attr_keys.copy() + if td.DEFAULT_ATTR_KEYS.NODE_ID in required_attrs: + required_attrs.remove(td.DEFAULT_ATTR_KEYS.NODE_ID) + if td.DEFAULT_ATTR_KEYS.SOLUTION not in attrs: + attrs[td.DEFAULT_ATTR_KEYS.SOLUTION] = 1 + for attr in required_attrs: + if attr not in attrs: + attrs[attr] = None + + node_dict = { + attr: np.array(values) if attr == "pos" else values + for attr, values in attrs.items() + } + + self.tracks.graph.add_node(attrs=node_dict, index=self.node) + if self.pixels is not None: self.tracks.set_pixels(self.pixels, self.node) - attrs = self.attributes - self.tracks.graph.add_node(self.node) - # set all user provided attributes including time and position - for attr, value in attrs.items(): - self.tracks._set_node_attr(self.node, attr, value) + if type(self.tracks).__name__ == "SolutionTracks": + tracklet_key = self.tracks.features.tracklet_key + if tracklet_key is not None and tracklet_key in attrs: + track_id = attrs[tracklet_key] + if track_id not in self.tracks.track_id_to_node: + self.tracks.track_id_to_node[track_id] = [] + self.tracks.track_id_to_node[track_id].append(self.node) # Always notify annotators - they will check their own preconditions self.tracks.notify_annotators(self) diff --git a/src/funtracks/annotators/_track_annotator.py b/src/funtracks/annotators/_track_annotator.py index 8ce639bb..f9b0be5d 100644 --- a/src/funtracks/annotators/_track_annotator.py +++ b/src/funtracks/annotators/_track_annotator.py @@ -110,13 +110,13 @@ def __init__( self.max_lineage_id = 0 # Initialize tracklet bookkeeping if track IDs already exist in the graph - if tracks.graph.number_of_nodes() > 0: + if tracks.graph.num_nodes > 0: max_id, id_to_nodes = self._get_max_id_and_map(self.tracklet_key) self.max_tracklet_id = max_id self.tracklet_id_to_nodes = id_to_nodes # Initialize lineage bookkeeping if lineage IDs already exist - if lineage_key is not None and tracks.graph.number_of_nodes() > 0: + if lineage_key is not None and tracks.graph.num_nodes > 0: max_id, id_to_nodes = self._get_max_id_and_map(self.lineage_key) self.max_lineage_id = max_id self.lineage_id_to_nodes = id_to_nodes diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index abe0d5f6..3bea1700 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -279,7 +279,7 @@ def _check_existing_feature(self, key: str) -> bool: bool: True if the key is on the first sampled node or there are no nodes, and False if missing from the first node. """ - if self.graph.number_of_nodes() == 0: + if self.graph.num_nodes == 0: return True # Get a sample node to check which attributes exist @@ -323,10 +323,10 @@ def _setup_core_computed_features(self) -> None: self.enable_features([key]) def nodes(self): - return np.array(self.graph.nodes()) + return np.array(self.graph.node_ids()) def edges(self): - return np.array(self.graph.edges()) + return np.array(self.graph.edge_ids()) def in_degree(self, nodes: np.ndarray | None = None) -> np.ndarray: if nodes is not None: @@ -585,9 +585,9 @@ def _set_node_attributes(self, node: Node, attributes: dict[str, Any]) -> None: node (Node): The node to set the attributes for attributes (dict[str, Any]): A mapping from attribute name to value """ - if node in self.graph: + if node in self.graph.node_ids(): for key, value in attributes.items(): - self.graph.nodes[node][key] = value + self.graph.update_node_attrs(attrs={key: value}, node_ids=[node]) else: logger.info("Node %d not found in the graph.", node) @@ -635,19 +635,18 @@ def _compute_ndim( def _set_node_attr(self, node: Node, attr: str, value: Any): if isinstance(value, np.ndarray): value = list(value) - self.graph.nodes[node][attr] = value + self.graph.update_node_attrs(attrs={attr: [value]}, node_ids=[node]) def _set_nodes_attr(self, nodes: Iterable[Node], attr: str, values: Iterable[Any]): for node, value in zip(nodes, values, strict=False): - if isinstance(value, np.ndarray): - value = list(value) - self.graph.nodes[node][attr] = value + self.graph.update_node_attrs(attrs={attr: [value]}, node_ids=[node]) def get_node_attr(self, node: Node, attr: str, required: bool = False): - if required: - return self.graph.nodes[node][attr] - else: - return self.graph.nodes[node].get(attr, None) + if attr not in self.graph.node_attr_keys: + if required: + raise KeyError(attr) + return None + return self.graph[int(node)][attr] def _get_node_attr(self, node, attr, required=False): warnings.warn( diff --git a/src/funtracks/utils/tracksdata_utils.py b/src/funtracks/utils/tracksdata_utils.py new file mode 100644 index 00000000..d956e5e3 --- /dev/null +++ b/src/funtracks/utils/tracksdata_utils.py @@ -0,0 +1,242 @@ +from typing import Any + +import numpy as np +import polars as pl +import tracksdata as td + +from polars.testing import assert_frame_equal +from skimage import measure +from tracksdata.nodes._mask import Mask + + +def create_empty_graphview_graph( + with_pos: bool = False, + with_track_id: bool = False, + with_area: bool = False, + with_iou: bool = False, + database: str = ":memory:", + position_attrs: list[str] = ["pos"], +) -> td.graph.GraphView: + """ + Create an empty tracksdata GraphView with standard node and edge attributes. + Parameters + ---------- + with_pos : bool + Whether to include position attributes. + with_track_id : bool + Whether to include track ID attribute. + with_area : bool + Whether to include area attribute. + with_iou : bool + Whether to include IOU attribute. + database : str + Path to the SQLite database file, e.g. ':memory:' for in-memory database. + position_attrs : list[str] + List of position attribute names, e.g. ['pos'] or ['x', 'y', 'z']. + + Returns + ------- + td.graph.GraphView + An empty tracksdata GraphView with standard node and edge attributes. + """ + kwargs = { + "drivername": "sqlite", + "database": database, + "overwrite": True, + } + graph_sql = td.graph.SQLGraph(**kwargs) + + if with_pos: + if "pos" in position_attrs: + graph_sql.add_node_attr_key("pos", default_value=None) + else: + if "x" in position_attrs: + graph_sql.add_node_attr_key("x", default_value=0) + if "y" in position_attrs: + graph_sql.add_node_attr_key("y", default_value=0) + if "z" in position_attrs: + graph_sql.add_node_attr_key("z", default_value=0) + if with_area: + graph_sql.add_node_attr_key("area", default_value=0.0) + if with_track_id: + graph_sql.add_node_attr_key("track_id", default_value=0) + graph_sql.add_node_attr_key(td.DEFAULT_ATTR_KEYS.SOLUTION, default_value=1) + #TODO: segmentation + # graph_sql.add_node_attr_key(td.DEFAULT_ATTR_KEYS.MASK, default_value=None) + # graph_sql.add_node_attr_key(td.DEFAULT_ATTR_KEYS.BBOX, default_value=None) + if with_iou: + graph_sql.add_edge_attr_key("iou", default_value=0) + graph_sql.add_edge_attr_key(td.DEFAULT_ATTR_KEYS.SOLUTION, default_value=1) + + graph_td_sub = graph_sql.filter( + td.NodeAttr(td.DEFAULT_ATTR_KEYS.SOLUTION) == 1, + td.EdgeAttr(td.DEFAULT_ATTR_KEYS.SOLUTION) == 1, + ).subgraph() + + return graph_td_sub + + + +def assert_node_attrs_equal_with_masks( + object1, object2, check_column_order: bool = False, check_row_order: bool = False +): + """ + Fully compare the content of two graphs (node attributes and Masks) + """ + + if isinstance(object1, td.graph.GraphView) and ( + isinstance(object2, td.graph.GraphView) + ): + node_attrs1 = object1.node_attrs() + node_attrs2 = object2.node_attrs() + elif isinstance(object1, pl.DataFrame) and isinstance(object2, pl.DataFrame): + node_attrs1 = object1 + node_attrs2 = object2 + else: + raise ValueError( + "Both objects must be either tracksdata graphs or polars DataFrames" + ) + + #TODO Teun: enable this when segmentation/masks are part of node_attrs + # assert_frame_equal( + # node_attrs1.drop("mask"), + # node_attrs2.drop("mask"), + # check_column_order=check_column_order, + # check_row_order=check_row_order, + # ) + # for node in node_attrs1["node_id"]: + # mask1 = node_attrs1.filter(pl.col("node_id") == node)["mask"].item() + # mask2 = node_attrs2.filter(pl.col("node_id") == node)["mask"].item() + # assert np.array_equal(mask1.bbox, mask2.bbox) + # assert np.array_equal(mask1.mask, mask2.mask) + assert_frame_equal( + node_attrs1, + node_attrs2, + check_column_order=check_column_order, + check_row_order=check_row_order, + ) + +def compute_node_attrs_from_masks( + masks: list[Mask], ndim: int, scale: list[float] | None +) -> dict[str, list[Any]]: + """ + Compute node attributes (area and pos) from a tracksdata Mask object. + + Parameters + ---------- + masks : list[Mask] + A list of tracksdata Mask objects containing the mask and bounding box. + ndim : int + Number of dimensions (2D or 3D). + scale : list[float] | None + Scale factors for each dimension. + + Returns + ------- + dict[str, Any] + A dictionary containing the computed node attributes ('area' and 'pos'). + """ + if not masks: + return {} + + area_list = [] + pos_list = [] + for mask in masks: + seg_crop = mask.mask + seg_bbox = mask.bbox + + pos_scale = scale[1:] if scale is not None else np.ones(ndim - 1) + area = np.sum(seg_crop) + if pos_scale is not None: + area *= np.prod(pos_scale) + area_list.append(float(area)) + + # Calculate position - use centroid if area > 0, otherwise use bbox center + if area > 0: + pos = measure.centroid(seg_crop, spacing=pos_scale) # type: ignore + pos += seg_bbox[: ndim - 1] * (pos_scale if pos_scale is not None else 1) + else: + # Use bbox center when area is 0 + pos = np.array( + [(seg_bbox[d] + seg_bbox[d + ndim - 1]) / 2 for d in range(ndim - 1)] + ) + pos_list.append(pos) + + return {"area": area_list, "pos": pos_list} + + +def compute_node_attrs_from_pixels( + pixels: list[tuple[np.ndarray, ...]] | None, ndim: int, scale: list[float] | None +) -> dict[str, list[Any]]: + """ + Compute node attributes (area and pos) from pixel coordinates. + Parameters + ---------- + pixels : list[tuple[np.ndarray, ...]] + List of pixel coordinates for each node. + ndim : int + Number of dimensions (2D or 3D). + scale : list[float] | None + Scale factors for each dimension. + + Returns + ------- + dict[str, list[Any]] + A dictionary containing the computed node attributes ('area' and 'pos'). + """ + if pixels is None: + return {} + + # Convert pixels to masks first + masks = [] + for pix in pixels: + mask, _ = pixels_to_td_mask(pix, ndim, scale) + masks.append(mask) + + # Reuse the from_masks function to compute attributes + return compute_node_attrs_from_masks(masks, ndim, scale) + + +def pixels_to_td_mask( + pix: tuple[np.ndarray, ...], ndim: int, scale: list[float] | None +) -> tuple[Mask, float]: + """ + Convert pixel coordinates to tracksdata mask format. + + Args: + pix: Pixel coordinates for 1 node! + ndim: Number of dimensions (2D or 3D). + scale: Scale factors for each dimension, used for area calculation + + Returns: + Tuple[td.Mask, np.ndarray]: A tuple containing the + tracksdata mask and the mask array. + """ + + spatial_dims = ndim - 1 # Handle both 2D and 3D + + # Calculate position and bounding box more efficiently + bbox = np.zeros(2 * spatial_dims, dtype=int) + + # Calculate bbox and shape in one pass + for dim in range(spatial_dims): + pix_dim = dim + 1 + min_val = np.min(pix[pix_dim]) + max_val = np.max(pix[pix_dim]) + bbox[dim] = min_val + bbox[dim + spatial_dims] = max_val + 1 + + # Calculate mask shape from bbox + mask_shape = bbox[spatial_dims:] - bbox[:spatial_dims] + + # Convert coordinates to mask-local coordinates + local_coords = [pix[dim + 1] - bbox[dim] for dim in range(spatial_dims)] + mask_array = np.zeros(mask_shape, dtype=bool) + mask_array[tuple(local_coords)] = True + + area = np.sum(mask_array) + if scale is not None: + area *= np.prod(scale[1:]) + + mask = Mask(mask_array, bbox=bbox) + return mask, area diff --git a/tests/actions/test_action_history.py b/tests/actions/test_action_history.py index 7e4575d3..62b889b5 100644 --- a/tests/actions/test_action_history.py +++ b/tests/actions/test_action_history.py @@ -21,7 +21,7 @@ def test_action_history(): history.add_new_action(action1) # undo the action assert history.undo() - assert tracks.graph.number_of_nodes() == 0 + assert tracks.graph.num_nodes == 0 assert len(history.undo_stack) == 1 assert len(history.redo_stack) == 1 assert history._undo_pointer == -1 @@ -31,7 +31,7 @@ def test_action_history(): # redo the action assert history.redo() - assert tracks.graph.number_of_nodes() == 1 + assert tracks.graph.num_nodes == 1 assert len(history.undo_stack) == 1 assert len(history.redo_stack) == 0 assert history._undo_pointer == 0 @@ -43,7 +43,7 @@ def test_action_history(): assert history.undo() action2 = AddNode(tracks, node=10, attributes={"time": 10, "pos": pos, "track_id": 2}) history.add_new_action(action2) - assert tracks.graph.number_of_nodes() == 1 + 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 @@ -52,7 +52,7 @@ def test_action_history(): # undo back to after action 1 assert history.undo() assert history.undo() - assert tracks.graph.number_of_nodes() == 1 + assert tracks.graph.num_nodes == 1 assert len(history.undo_stack) == 3 assert len(history.redo_stack) == 2 diff --git a/tests/actions/test_add_delete_nodes.py b/tests/actions/test_add_delete_nodes.py index fe47ce01..8d7437f3 100644 --- a/tests/actions/test_add_delete_nodes.py +++ b/tests/actions/test_add_delete_nodes.py @@ -1,14 +1,18 @@ import copy -import networkx as nx 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, AddNode, ) +from funtracks.utils.tracksdata_utils import ( + assert_node_attrs_equal_with_masks, + create_empty_graphview_graph, +) @pytest.mark.parametrize("ndim", [3, 4]) @@ -20,42 +24,73 @@ def test_add_delete_nodes(get_tracks, ndim, with_seg): reference_seg = copy.deepcopy(tracks.segmentation) # Start with an empty Tracks - empty_graph = nx.DiGraph() + empty_graph = create_empty_graphview_graph( + with_pos=True, with_track_id=True, with_area=with_seg, with_iou=with_seg + ) empty_seg = np.zeros_like(tracks.segmentation) if with_seg else None tracks.graph = empty_graph if with_seg: tracks.segmentation = empty_seg - nodes = list(reference_graph.nodes()) + # add all the nodes from graph_2d/seg_2d + nodes = list(reference_graph.node_ids()) + actions = [] for node in nodes: pixels = np.nonzero(reference_seg == node) if with_seg else None - actions.append( - AddNode(tracks, node, dict(reference_graph.nodes[node]), pixels=pixels) - ) + + attrs = {} + attrs[tracks.features.time_key] = reference_graph[node][tracks.features.time_key] + if tracks.features.position_key == "pos": + attrs[tracks.features.position_key] = reference_graph[node][ + tracks.features.position_key + ].to_list() + else: + attrs[tracks.features.position_key] = reference_graph[node][ + tracks.features.position_key + ] + attrs[tracks.features.tracklet_key] = reference_graph[node][ + tracks.features.tracklet_key + ] + + actions.append(AddNode(tracks, node, attributes=attrs, pixels=pixels)) action = ActionGroup(tracks=tracks, actions=actions) - assert set(tracks.graph.nodes()) == set(reference_graph.nodes()) - for node, data in tracks.graph.nodes(data=True): - reference_data = reference_graph.nodes[node] - assert data == reference_data + 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, + ) # Invert the action to delete all the nodes del_nodes = action.inverse() - assert set(tracks.graph.nodes()) == set(empty_graph.nodes()) + 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.nodes()) == set(reference_graph.nodes()) - for node, data in tracks.graph.nodes(data=True): - reference_data = copy.deepcopy(reference_graph.nodes[node]) - assert data == reference_data + 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, + ) def test_add_node_missing_time(get_tracks): diff --git a/tests/conftest.py b/tests/conftest.py index 0cd926a5..4f4ea87e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,11 +2,15 @@ from collections.abc import Callable from typing import TYPE_CHECKING -import networkx as nx import numpy as np import pytest +import tracksdata as td from skimage.draw import disk +from funtracks.utils.tracksdata_utils import ( + create_empty_graphview_graph, +) + if TYPE_CHECKING: from typing import Any @@ -55,7 +59,7 @@ def _make_graph( with_track_id: bool = False, with_area: bool = False, with_iou: bool = False, -) -> nx.DiGraph: +) -> td.graph.GraphView: """Generate a test graph with configurable features. Args: @@ -68,7 +72,14 @@ def _make_graph( Returns: A graph with the requested features """ - graph = nx.DiGraph() + graph = create_empty_graphview_graph( + with_pos=with_pos, + with_track_id=with_track_id, + with_area=with_area, + # with_iou=with_iou, + database=":memory:", + position_attrs=["pos"], + ) # Base node data (always has time) base_nodes = [ @@ -108,51 +119,64 @@ def _make_graph( track_ids = {1: 1, 2: 2, 3: 3, 4: 3, 5: 3, 6: 5} # Build nodes with requested features - nodes = [] + 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"] = 1 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] if with_area: - node_attrs["area"] = areas[node_id] - nodes.append((node_id, node_attrs)) - - edges = [(1, 2), (1, 3), (3, 4), (4, 5)] + 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 + nodes_id_list.append(node_id) + nodes_attrs_list.append(node_attrs) + + edges = [ + {"source_id": 1, "target_id": 2, "solution": 1}, + {"source_id": 1, "target_id": 3, "solution": 1}, + {"source_id": 3, "target_id": 4, "solution": 1}, + {"source_id": 4, "target_id": 5, "solution": 1}, + ] - graph.add_nodes_from(nodes) - graph.add_edges_from(edges) + graph.bulk_add_nodes(nodes=nodes_attrs_list, indices=nodes_id_list) + graph.bulk_add_edges(edges) # Add IOUs to edges if requested if with_iou: + graph.add_edge_attr_key("iou", default_value=0.0) for edge, iou in ious.items(): - if edge in graph.edges: - graph.edges[edge]["iou"] = iou + 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() -> nx.DiGraph: +def graph_clean() -> td.graph.GraphView: """Base graph with only time - no positions or computed features.""" return _make_graph(ndim=3) @pytest.fixture -def graph_2d_with_position() -> nx.DiGraph: +def graph_2d_with_position() -> td.graph.GraphView: """Graph with 2D positions - for Tracks without segmentation.""" return _make_graph(ndim=3, with_pos=True) @pytest.fixture -def graph_2d_with_track_id() -> nx.DiGraph: +def graph_2d_with_track_id() -> td.graph.GraphView: """Graph with 2D positions and track_id - for SolutionTracks without segmentation.""" return _make_graph(ndim=3, with_pos=True, with_track_id=True) @pytest.fixture -def graph_2d_with_computed_features() -> nx.DiGraph: +def graph_2d_with_computed_features() -> td.graph.GraphView: """Graph with all computed features - for SolutionTracks with segmentation.""" return _make_graph( ndim=3, with_pos=True, with_track_id=True, with_area=True, with_iou=True @@ -160,19 +184,19 @@ def graph_2d_with_computed_features() -> nx.DiGraph: @pytest.fixture -def graph_3d_with_position() -> nx.DiGraph: +def graph_3d_with_position() -> td.graph.GraphView: """Graph with 3D positions - for Tracks without segmentation.""" return _make_graph(ndim=4, with_pos=True) @pytest.fixture -def graph_3d_with_track_id() -> nx.DiGraph: +def graph_3d_with_track_id() -> td.graph.GraphView: """Graph with 3D positions and track_id - for SolutionTracks without segmentation.""" return _make_graph(ndim=4, with_pos=True, with_track_id=True) @pytest.fixture -def graph_3d_with_computed_features() -> nx.DiGraph: +def graph_3d_with_computed_features() -> td.graph.GraphView: """Graph with all computed features - for SolutionTracks with segmentation.""" return _make_graph( ndim=4, with_pos=True, with_track_id=True, with_area=True, with_iou=True @@ -263,31 +287,31 @@ def _make_tracks( @pytest.fixture -def graph_2d_list() -> nx.DiGraph: - graph = nx.DiGraph() +def graph_2d_list() -> td.graph.GraphView: + # graph = nx.DiGraph() + graph = create_empty_graphview_graph() + nodes = [ - ( - 1, - { - "y": 100, - "x": 50, - "t": 0, - "area": 1245, - "track_id": 1, - }, - ), - ( - 2, - { - "y": 20, - "x": 100, - "t": 1, - "area": 500, - "track_id": 2, - }, - ), + { + "y": 100, + "x": 50, + "t": 0, + "area": 1245, + "track_id": 1, + }, + { + "y": 20, + "x": 100, + "t": 1, + "area": 500, + "track_id": 2, + }, ] - graph.add_nodes_from(nodes) + graph.add_node_attr_key("y", default_value=0) + graph.add_node_attr_key("x", default_value=0) + graph.add_node_attr_key("area", default_value=0) + graph.add_node_attr_key("track_id", default_value=0) + graph.bulk_add_nodes(nodes=nodes, indices=[1, 2]) return graph @@ -326,7 +350,7 @@ def segmentation_3d() -> "NDArray[np.int32]": @pytest.fixture -def get_graph(request) -> Callable[..., nx.DiGraph]: +def get_graph(request) -> Callable[..., td.graph.GraphView]: """Factory fixture to get graph by ndim and feature level. Args: @@ -344,7 +368,7 @@ def get_graph(request) -> Callable[..., nx.DiGraph]: graph = get_graph(ndim=3, with_features="track_id") """ - def _get_graph(ndim: int, with_features: str = "clean") -> nx.DiGraph: + def _get_graph(ndim: int, with_features: str = "clean") -> td.graph.GraphView: if with_features == "clean": graph = request.getfixturevalue("graph_clean") elif with_features == "position": diff --git a/tests/data_model/test_solution_tracks.py b/tests/data_model/test_solution_tracks.py index 30c5f409..a0788399 100644 --- a/tests/data_model/test_solution_tracks.py +++ b/tests/data_model/test_solution_tracks.py @@ -93,7 +93,7 @@ def test_export_to_csv( with open(temp_file) as f: lines = f.readlines() - assert len(lines) == tracks.graph.number_of_nodes() + 1 # add header + assert len(lines) == tracks.graph.num_nodes + 1 # add header # Backward compatible format: t, y, x, id, parent_id, track_id header = ["t", "y", "x", "id", "parent_id", "track_id"] @@ -105,7 +105,7 @@ def test_export_to_csv( with open(temp_file) as f: lines = f.readlines() - assert len(lines) == tracks.graph.number_of_nodes() + 1 # add header + assert len(lines) == tracks.graph.num_nodes + 1 # add header # Backward compatible format: t, z, y, x, id, parent_id, track_id header = ["t", "z", "y", "x", "id", "parent_id", "track_id"] @@ -145,7 +145,7 @@ def test_export_to_csv_with_display_names( with open(temp_file) as f: lines = f.readlines() - assert len(lines) == tracks.graph.number_of_nodes() + 1 # add header + assert len(lines) == tracks.graph.num_nodes + 1 # add header # With display names: ID, Parent ID, Time, y, x, Tracklet ID header = ["ID", "Parent ID", "Time", "y", "x", "Tracklet ID"] @@ -158,7 +158,7 @@ def test_export_to_csv_with_display_names( with open(temp_file) as f: lines = f.readlines() - assert len(lines) == tracks.graph.number_of_nodes() + 1 # add header + assert len(lines) == tracks.graph.num_nodes + 1 # add header # With display names: ID, Parent ID, Time, z, y, x, Tracklet ID header = ["ID", "Parent ID", "Time", "z", "y", "x", "Tracklet ID"] diff --git a/tests/data_model/test_tracks.py b/tests/data_model/test_tracks.py index 8476e203..cfab7301 100644 --- a/tests/data_model/test_tracks.py +++ b/tests/data_model/test_tracks.py @@ -244,6 +244,12 @@ def test_set_positions_list(graph_2d_list): def test_set_node_attributes(graph_2d_with_computed_features, caplog): tracks = Tracks(graph_2d_with_computed_features, ndim=3, **track_attrs) + tracks.graph.add_node_attr_key("attr_1", default_value=0) + tracks.graph.add_node_attr_key("attr_2", default_value="") + + # TODO: + # 1) this function is no longer necessary, + # 2) what is the intended purpose of attrs here (1 and 6 values, for only 1 node)? attrs = {"attr_1": 1, "attr_2": ["a", "b", "c", "d", "e", "f"]} tracks._set_node_attributes(1, attrs) assert tracks.get_node_attr(1, "attr_1") == 1 diff --git a/tests/import_export/test_csv_export.py b/tests/import_export/test_csv_export.py index 9786f476..baddb762 100644 --- a/tests/import_export/test_csv_export.py +++ b/tests/import_export/test_csv_export.py @@ -20,7 +20,7 @@ def test_export_solution_to_csv(get_tracks, tmp_path, ndim, expected_header): with open(temp_file) as f: lines = f.readlines() - assert len(lines) == tracks.graph.number_of_nodes() + 1 # add header + assert len(lines) == tracks.graph.num_nodes + 1 # add header assert lines[0].strip().split(",") == expected_header # Check first data line (node 1: t=0, pos=[50, 50] or [50, 50, 50], track_id=1) diff --git a/tests/import_export/test_csv_import.py b/tests/import_export/test_csv_import.py index 636e1c6c..552dd5c4 100644 --- a/tests/import_export/test_csv_import.py +++ b/tests/import_export/test_csv_import.py @@ -43,7 +43,7 @@ def test_import_2d(self, simple_df_2d): tracks = tracks_from_df(simple_df_2d) assert isinstance(tracks, SolutionTracks) - assert tracks.graph.number_of_nodes() == 4 + assert tracks.graph.num_nodes == 4 assert tracks.graph.number_of_edges() == 3 assert tracks.ndim == 3 @@ -52,7 +52,7 @@ def test_import_3d(self, df_3d): tracks = tracks_from_df(df_3d) assert tracks.ndim == 4 - assert tracks.graph.number_of_nodes() == 3 + assert tracks.graph.num_nodes == 3 # Check z coordinate pos = tracks.get_position(1) assert len(pos) == 3 # z, y, x @@ -143,7 +143,7 @@ def test_single_node(self): tracks = tracks_from_df(df) - assert tracks.graph.number_of_nodes() == 1 + assert tracks.graph.num_nodes == 1 assert tracks.graph.number_of_edges() == 0 def test_multiple_roots(self): @@ -160,7 +160,7 @@ def test_multiple_roots(self): tracks = tracks_from_df(df) - assert tracks.graph.number_of_nodes() == 4 + assert tracks.graph.num_nodes == 4 assert tracks.graph.number_of_edges() == 2 # Should have two root nodes @@ -181,7 +181,7 @@ def test_division_event(self): tracks = tracks_from_df(df) - assert tracks.graph.number_of_nodes() == 3 + assert tracks.graph.num_nodes == 3 assert tracks.graph.number_of_edges() == 2 # Node 1 should have two children @@ -203,7 +203,7 @@ def test_long_track(self): tracks = tracks_from_df(df) - assert tracks.graph.number_of_nodes() == 10 + assert tracks.graph.num_nodes == 10 assert tracks.graph.number_of_edges() == 9 # Should form a single linear chain @@ -297,7 +297,7 @@ def test_seg_id_same_as_id(self, simple_df_2d): 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.number_of_nodes() == 4 + assert tracks.graph.num_nodes == 4 for node_id in tracks.graph.nodes(): assert tracks.get_node_attr(node_id, "seg_id") == node_id From b4ce277866e5b7c374419e707ab69df76949e074 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Thu, 11 Dec 2025 14:33:05 -0800 Subject: [PATCH 02/44] fixed all tests in Actions --- .gitignore | 3 + docs/features.md | 2 +- src/funtracks/actions/add_delete_edge.py | 25 ++++- src/funtracks/actions/add_delete_node.py | 2 +- src/funtracks/annotators/_edge_annotator.py | 14 ++- src/funtracks/annotators/_track_annotator.py | 49 +++++++-- src/funtracks/data_model/solution_tracks.py | 17 +-- src/funtracks/data_model/tracks.py | 102 ++++++++++-------- src/funtracks/data_model/tracks_controller.py | 2 +- src/funtracks/import_export/_utils.py | 5 +- src/funtracks/import_export/csv/_export.py | 7 +- src/funtracks/user_actions/user_add_edge.py | 4 +- src/funtracks/user_actions/user_add_node.py | 2 +- .../user_actions/user_update_segmentation.py | 2 +- src/funtracks/utils/tracksdata_utils.py | 81 ++++++++++++-- tests/actions/test_action_history.py | 12 ++- tests/actions/test_add_delete_edge.py | 64 +++++++---- tests/actions/test_add_delete_nodes.py | 26 +++-- tests/actions/test_update_node_attrs.py | 14 ++- tests/actions/test_update_node_segs.py | 25 +++-- tests/conftest.py | 55 ++++++---- tests/data_model/test_solution_tracks.py | 14 +-- tests/data_model/test_tracks.py | 102 +++++++----------- tests/data_model/test_tracks_controller.py | 48 ++++----- tests/import_export/test_csv_import.py | 10 +- .../user_actions/test_user_add_delete_node.py | 24 ++--- .../test_user_update_segmentation.py | 24 ++--- 27 files changed, 458 insertions(+), 277 deletions(-) diff --git a/.gitignore b/.gitignore index 81d47d52..7bd29b15 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,6 @@ pixi.lock # uv environments uv.lock + +# Claude +CLAUDE.md diff --git a/docs/features.md b/docs/features.md index 478e3995..c34f500d 100644 --- a/docs/features.md +++ b/docs/features.md @@ -150,7 +150,7 @@ classDiagram } class Tracks { - +graph: nx.DiGraph + +graph: td.graph.GraphView +segmentation: ndarray|None +features: FeatureDict +annotators: AnnotatorRegistry diff --git a/src/funtracks/actions/add_delete_edge.py b/src/funtracks/actions/add_delete_edge.py index 0c678b51..3482a36a 100644 --- a/src/funtracks/actions/add_delete_edge.py +++ b/src/funtracks/actions/add_delete_edge.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from ._base import BasicAction @@ -10,6 +10,8 @@ from funtracks.data_model import Tracks from funtracks.data_model.tracks import Edge +import tracksdata as td + class AddEdge(BasicAction): """Action for adding a new edge. Endpoints must exist already.""" @@ -47,12 +49,29 @@ def _apply(self) -> None: """ # Check that both endpoints exist before computing edge attributes for node in self.edge: - if not self.tracks.graph.has_node(node): + if node not in self.tracks.graph.node_ids(): raise ValueError( f"Cannot add edge {self.edge}: endpoint {node} not in graph yet" ) - self.tracks.graph.add_edge(self.edge[0], self.edge[1], **self.attributes) + if self.tracks.graph.has_edge(*self.edge): + raise ValueError(f"Edge {self.edge} already exists in the graph") + + # Add required solution attribute + attrs = self.attributes + attrs[td.DEFAULT_ATTR_KEYS.SOLUTION] = 1 + + required_attrs = self.tracks.graph.edge_attr_keys + for attr in required_attrs: + if attr not in attrs: + attrs[attr] = None + + # Create edge attributes for this specific edge + self.tracks.graph.add_edge( + source_id=self.edge[0], + target_id=self.edge[1], + attrs=attrs, + ) # Notify annotators to recompute features (will overwrite computed ones) self.tracks.notify_annotators(self) diff --git a/src/funtracks/actions/add_delete_node.py b/src/funtracks/actions/add_delete_node.py index 45666b20..3ef64110 100644 --- a/src/funtracks/actions/add_delete_node.py +++ b/src/funtracks/actions/add_delete_node.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from typing import Any - from funtracks.data_model import SolutionTracks + from funtracks.data_model.solution_tracks import SolutionTracks from funtracks.data_model.tracks import Node, SegMask import numpy as np diff --git a/src/funtracks/annotators/_edge_annotator.py b/src/funtracks/annotators/_edge_annotator.py index fdd241e5..ddfb1179 100644 --- a/src/funtracks/annotators/_edge_annotator.py +++ b/src/funtracks/annotators/_edge_annotator.py @@ -86,7 +86,7 @@ def compute(self, feature_keys: list[str] | None = None) -> None: # TODO: add skip edges if self.iou_key in keys_to_compute: nodes_by_frame = defaultdict(list) - for n in self.tracks.nodes(): + for n in self.tracks.graph.node_ids(): nodes_by_frame[self.tracks.get_time(n)].append(n) for t in range(seg.shape[0] - 1): @@ -146,9 +146,15 @@ def update(self, action: BasicAction): else: # UpdateNodeSeg # Get all incident edges to the modified node node = action.node - edges_to_update = list(self.tracks.graph.in_edges(node)) + list( - self.tracks.graph.out_edges(node) - ) + + edges_to_update = [] + for node in self.tracks.graph.node_ids(): + # Add edges from predecessors + for pred in self.tracks.graph.predecessors(node): + edges_to_update.append((pred, node)) + # Add edges from successors + for succ in self.tracks.graph.successors(node): + edges_to_update.append((node, succ)) # Update IoU for each edge for edge in edges_to_update: diff --git a/src/funtracks/annotators/_track_annotator.py b/src/funtracks/annotators/_track_annotator.py index f9b0be5d..f1049690 100644 --- a/src/funtracks/annotators/_track_annotator.py +++ b/src/funtracks/annotators/_track_annotator.py @@ -4,10 +4,14 @@ from typing import TYPE_CHECKING import networkx as nx +import rustworkx as rx +import tracksdata as td from funtracks.actions import AddNode, DeleteNode, UpdateTrackID from funtracks.data_model import SolutionTracks +from funtracks.data_model.graph_attributes import NodeAttr from funtracks.features import LineageID, TrackletID +from funtracks.utils.tracksdata_utils import td_graph_edge_list from ._graph_annotator import GraphAnnotator @@ -206,19 +210,46 @@ def _assign_tracklet_ids(self) -> None: After removing division edges, each connected component will get a unique ID, and the relevant class attributes will be updated. """ - graph_copy = self.tracks.graph.copy() - parents = [node for node, degree in self.tracks.graph.out_degree() if degree >= 2] + graph_copy = td.graph.IndexedRXGraph.from_other(self.tracks.graph) + + parents = [ + node + for node, degree in zip( + self.tracks.graph.node_ids(), self.tracks.graph.out_degree(), strict=True + ) + if degree >= 2 + ] + intertrack_edges = [] # Remove all intertrack edges from a copy of the original graph for parent in parents: - daughters = self.tracks.successors(parent) - for daughter in daughters: - graph_copy.remove_edge(parent, daughter) + all_edges = td_graph_edge_list(self.tracks.graph) + daughters = [edge[1] for edge in all_edges if edge[0] == parent] - tracklets = nx.weakly_connected_components(graph_copy) - max_id, ids_to_nodes = self._assign_ids(tracklets, self.tracklet_key) - self.max_tracklet_id = max_id - self.tracklet_id_to_nodes = ids_to_nodes + for daughter in daughters: + # remove edge from graph, by setting solution to 0 + subgraphing + edge_id = graph_copy.edge_id(parent, daughter) + graph_copy.update_edge_attrs( + edge_ids=[edge_id], attrs={td.DEFAULT_ATTR_KEYS.SOLUTION: [0]} + ) + graph_copy = graph_copy.filter( + td.NodeAttr(td.DEFAULT_ATTR_KEYS.SOLUTION) == 1, + td.EdgeAttr(td.DEFAULT_ATTR_KEYS.SOLUTION) == 1, + ).subgraph() + + intertrack_edges.append((parent, daughter)) + + track_id = 1 + for tracklet in rx.weakly_connected_components(graph_copy.rx_graph): + node_ids_internal = list(tracklet) + node_ids_external = [graph_copy.node_ids()[nid] for nid in node_ids_internal] + self.tracks.graph.update_node_attrs( + attrs={NodeAttr.TRACK_ID.value: [track_id] * len(node_ids_external)}, + node_ids=node_ids_external, + ) + self.tracks.track_id_to_node[track_id] = node_ids_external + track_id += 1 + self.max_track_id = track_id - 1 def update(self, action: BasicAction) -> None: """Update track-level features based on the action. diff --git a/src/funtracks/data_model/solution_tracks.py b/src/funtracks/data_model/solution_tracks.py index 181d968d..a7e66e8f 100644 --- a/src/funtracks/data_model/solution_tracks.py +++ b/src/funtracks/data_model/solution_tracks.py @@ -6,6 +6,7 @@ import networkx as nx import numpy as np +from funtracks.data_model.graph_attributes import NodeAttr from funtracks.features import FeatureDict from .tracks import Tracks @@ -110,13 +111,12 @@ def _get_track_annotator(self) -> TrackAnnotator: @classmethod def from_tracks(cls, tracks: Tracks): force_recompute = False - if (tracklet_key := tracks.features.tracklet_key) is not None: - # Check if all nodes have track_id before trusting existing track IDs - # Short circuit on first missing track_id - for node in tracks.graph.nodes(): - if tracks.get_node_attr(node, tracklet_key) is None: - force_recompute = True - break + # Check if all nodes have track_id before trusting existing track IDs + if tracks.features.tracklet_key is not None and any( + value is None + for value in tracks.graph.node_attrs(attr_keys=tracks.features.tracklet_key) + ): + force_recompute = True soln_tracks = cls( tracks.graph, segmentation=tracks.segmentation, @@ -144,7 +144,8 @@ def node_id_to_track_id(self) -> dict[Node, int]: DeprecationWarning, stacklevel=2, ) - return nx.get_node_attributes(self.graph, self.features.tracklet_key) + all_track_ids = self.graph.node_attrs(attr_keys=NodeAttr.TRACK_ID.value) + return dict(zip(self.graph.node_ids(), all_track_ids, strict=True)) def get_next_track_id(self) -> int: """Return the next available track_id and update max_tracklet_id in TrackAnnotator diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index 3bea1700..24db86fd 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -10,16 +10,18 @@ ) from warnings import warn -import networkx as nx import numpy as np from psygnal import Signal from skimage import measure from funtracks.features import Feature, FeatureDict, Position, Time +from funtracks.utils.tracksdata_utils import td_get_single_attr_from_edge if TYPE_CHECKING: from pathlib import Path + import tracksdata as td + from funtracks.actions import BasicAction from funtracks.annotators import AnnotatorRegistry, GraphAnnotator @@ -40,7 +42,7 @@ class Tracks: position attribute. Edges in the graph represent links across time. Attributes: - graph (nx.DiGraph): A graph with nodes representing detections and + graph (td.graph.GraphView): A graph with nodes representing detections and and edges representing links across time. segmentation (np.ndarray | None): An optional segmentation that accompanies the tracking graph. If a segmentation is provided, @@ -55,7 +57,7 @@ class Tracks: def __init__( self, - graph: nx.DiGraph, + graph: td.graph.GraphView, segmentation: np.ndarray | None = None, time_attr: str | None = None, pos_attr: str | tuple[str, ...] | list[str] | None = None, @@ -67,8 +69,8 @@ def __init__( """Initialize a Tracks object. Args: - graph (nx.DiGraph): NetworkX directed graph with nodes as detections and - edges as links. + graph (td.graph.GraphView): NetworkX directed graph with nodes as detections + and edges as links. segmentation (np.ndarray | None): Optional segmentation array where labels match node IDs. Required for computing region properties (area, etc.). time_attr (str | None): Graph attribute name for time. Defaults to "time" @@ -283,8 +285,7 @@ def _check_existing_feature(self, key: str) -> bool: return True # Get a sample node to check which attributes exist - sample_node = next(iter(self.graph.nodes())) - node_attrs = set(self.graph.nodes[sample_node].keys()) + node_attrs = set(self.graph.node_attr_keys) return key in node_attrs def _setup_core_computed_features(self) -> None: @@ -407,6 +408,9 @@ def set_positions( if self.features.position_key is None: raise ValueError("position_key must be set") + if incl_time: + raise ValueError("Cannot set time in tracksdata") + if not isinstance(positions, np.ndarray): positions = np.array(positions) if incl_time: @@ -418,11 +422,13 @@ def set_positions( for idx, key in enumerate(self.features.position_key): self._set_nodes_attr(nodes, key, positions[:, idx].tolist()) else: - self._set_nodes_attr(nodes, self.features.position_key, positions.tolist()) + self._set_nodes_attr(nodes, self.features.position_key, positions) def set_position( self, node: Node, position: list | np.ndarray, incl_time: bool = False ): + if incl_time: + raise ValueError("Cannot set time in tracksdata") self.set_positions( [node], np.expand_dims(np.array(position), axis=0), incl_time=incl_time ) @@ -578,37 +584,6 @@ def set_pixels(self, pixels: tuple[np.ndarray, ...], value: int) -> None: raise ValueError("Cannot set pixels when segmentation is None") self.segmentation[pixels] = value - def _set_node_attributes(self, node: Node, attributes: dict[str, Any]) -> None: - """Set the attributes for the given node - - Args: - node (Node): The node to set the attributes for - attributes (dict[str, Any]): A mapping from attribute name to value - """ - if node in self.graph.node_ids(): - for key, value in attributes.items(): - self.graph.update_node_attrs(attrs={key: value}, node_ids=[node]) - else: - logger.info("Node %d not found in the graph.", node) - - def _set_edge_attributes(self, edge: Edge, attributes: dict[str, Any]) -> None: - """Set the edge attributes for the given edges. Attributes should already exist - (although adding will work in current implementation, they cannot currently be - removed) - - Args: - edges (list[Edge]): A list of edges to set the attributes for - attributes (Attributes): A dictionary of attribute name -> numpy array, - where the length of the arrays matches the number of edges. - Attributes should already exist: this function will only - update the values. - """ - if self.graph.has_edge(*edge): - for key, value in attributes.items(): - self.graph.edges[edge][key] = value - else: - logger.info("Edge %s not found in the graph.", edge) - def _compute_ndim( self, seg: np.ndarray | None, @@ -639,7 +614,7 @@ def _set_node_attr(self, node: Node, attr: str, value: Any): def _set_nodes_attr(self, nodes: Iterable[Node], attr: str, values: Iterable[Any]): for node, value in zip(nodes, values, strict=False): - self.graph.update_node_attrs(attrs={attr: [value]}, node_ids=[node]) + self.graph[node][attr] = [value] def get_node_attr(self, node: Node, attr: str, required: bool = False): if attr not in self.graph.node_attr_keys: @@ -668,17 +643,20 @@ def _get_nodes_attr(self, nodes, attr, required=False): return self.get_nodes_attr(nodes, attr, required=required) def _set_edge_attr(self, edge: Edge, attr: str, value: Any): - self.graph.edges[edge][attr] = value + edge_id = self.graph.edge_id(edge[0], edge[1]) + self.graph.update_edge_attrs(attrs={attr: value}, edge_ids=[edge_id]) def _set_edges_attr(self, edges: Iterable[Edge], attr: str, values: Iterable[Any]): for edge, value in zip(edges, values, strict=False): - self.graph.edges[edge][attr] = value + edge_id = self.graph.edge_id(edge[0], edge[1]) + self.graph.update_edge_attrs(attrs={attr: value}, edge_ids=[edge_id]) def get_edge_attr(self, edge: Edge, attr: str, required: bool = False): - if required: - return self.graph.edges[edge][attr] - else: - return self.graph.edges[edge].get(attr, None) + if attr not in self.graph.edge_attr_keys: + if required: + raise KeyError(attr) + return None + return td_get_single_attr_from_edge(self.graph, edge=edge, attrs=[attr]) def get_edges_attr(self, edges: Iterable[Edge], attr: str, required: bool = False): return [self.get_edge_attr(edge, attr, required=required) for edge in edges] @@ -749,6 +727,38 @@ def disable_features(self, feature_keys: list[str]) -> None: if key in self.features: del self.features[key] + def add_node_feature(self, key: str, feature: Feature) -> None: + """Add a node feature to the features dictionary and perform graph operations. + + This is the preferred way to add new features as it ensures both the + features dictionary is updated and any necessary graph operations are performed. + + Args: + key: The key for the new feature + feature: The Feature object to add + """ + # Add to the features dictionary + self.features[key] = feature + + # Perform custom graph operations when a feature is added + self.graph.add_node_attr_key(key, default_value=feature["default_value"]) + + def add_edge_feature(self, key: str, feature: Feature) -> None: + """Add an edge feature to the features dictionary and perform graph operations. + + This is the preferred way to add new features as it ensures both the + features dictionary is updated and any necessary graph operations are performed. + + Args: + key: The key for the new feature + feature: The Feature object to add + """ + # Add to the features dictionary + self.features[key] = feature + + # Perform custom graph operations when a feature is added + self.graph.add_edge_attr_key(key, default_value=feature["default_value"]) + # ========== Persistence ========== def save(self, directory: Path): diff --git a/src/funtracks/data_model/tracks_controller.py b/src/funtracks/data_model/tracks_controller.py index e8fe95e7..a999a7e7 100644 --- a/src/funtracks/data_model/tracks_controller.py +++ b/src/funtracks/data_model/tracks_controller.py @@ -388,7 +388,7 @@ def _get_new_node_ids(self, n: int) -> list[Node]: ids = [self.node_id_counter + i for i in range(n)] self.node_id_counter += n for idx, _id in enumerate(ids): - while self.tracks.graph.has_node(_id): + while _id in self.tracks.graph.node_ids(): _id = self.node_id_counter self.node_id_counter += 1 ids[idx] = _id diff --git a/src/funtracks/import_export/_utils.py b/src/funtracks/import_export/_utils.py index 18e888d9..3d4ab5b1 100644 --- a/src/funtracks/import_export/_utils.py +++ b/src/funtracks/import_export/_utils.py @@ -6,6 +6,7 @@ import numpy as np from funtracks.data_model.tracks import Tracks +from funtracks.utils.tracksdata_utils import td_get_ancestors if TYPE_CHECKING: from numpy.typing import ArrayLike @@ -84,7 +85,7 @@ def filter_graph_with_ancestors(graph: nx.DiGraph, nodes_to_keep: set[int]) -> l all_nodes_to_keep = set(nodes_to_keep) for node in nodes_to_keep: - ancestors = nx.ancestors(graph, node) + ancestors = td_get_ancestors(graph, node) all_nodes_to_keep.update(ancestors) return list(all_nodes_to_keep) @@ -108,7 +109,7 @@ def rename_feature(tracks: Tracks, old_key: str, new_key: str) -> None: # Register it to the feature dictionary, removing old key if necessary if old_key in tracks.features: tracks.features.pop(old_key) - tracks.features[new_key] = feature_dict + tracks.add_node_feature(new_key, feature_dict) # Update FeatureDict special key attributes if we renamed position or tracklet if tracks.features.position_key == old_key: diff --git a/src/funtracks/import_export/csv/_export.py b/src/funtracks/import_export/csv/_export.py index ad08064e..6abaf3eb 100644 --- a/src/funtracks/import_export/csv/_export.py +++ b/src/funtracks/import_export/csv/_export.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, cast import numpy as np +import polars as pl from .._utils import filter_graph_with_ancestors @@ -77,7 +78,7 @@ def convert_numpy_to_python(value): # Determine which nodes to export if node_ids is None: - node_to_keep = tracks.graph.nodes() + node_to_keep = tracks.graph.node_ids() else: node_to_keep = filter_graph_with_ancestors(tracks.graph, node_ids) @@ -95,6 +96,10 @@ def convert_numpy_to_python(value): feature_value = tracks.get_node_attr(node_id, feature_name) if isinstance(feature_value, list | tuple): features.extend(feature_value) + elif feature_name == "pos" and isinstance( + feature_value, pl.series.Series + ): + features.extend(feature_value.to_list()) else: features.append(feature_value) row = [node_id, parent_id, *features] diff --git a/src/funtracks/user_actions/user_add_edge.py b/src/funtracks/user_actions/user_add_edge.py index 701fcc04..2d82fc9d 100644 --- a/src/funtracks/user_actions/user_add_edge.py +++ b/src/funtracks/user_actions/user_add_edge.py @@ -33,11 +33,11 @@ def __init__( super().__init__(tracks, actions=[]) self.tracks: SolutionTracks # Narrow type from base class source, target = edge - if not tracks.graph.has_node(source): + if source not in tracks.graph.node_ids(): raise InvalidActionError( f"Source node {source} not in solution yet - must be added before edge" ) - if not tracks.graph.has_node(target): + if target not in tracks.graph.node_ids(): raise InvalidActionError( f"Target node {target} not in solution yet - must be added before edge" ) diff --git a/src/funtracks/user_actions/user_add_node.py b/src/funtracks/user_actions/user_add_node.py index 82da2787..48871a1b 100644 --- a/src/funtracks/user_actions/user_add_node.py +++ b/src/funtracks/user_actions/user_add_node.py @@ -70,7 +70,7 @@ def __init__( raise InvalidActionError( f"Cannot add node without track id. Please add {track_id_key} attribute" ) - if self.tracks.graph.has_node(node): + if node in self.tracks.graph.node_ids(): raise InvalidActionError( f"Node {node} already exists in the tracks, cannot add." ) diff --git a/src/funtracks/user_actions/user_update_segmentation.py b/src/funtracks/user_actions/user_update_segmentation.py index ab35337a..a38bf3e2 100644 --- a/src/funtracks/user_actions/user_update_segmentation.py +++ b/src/funtracks/user_actions/user_update_segmentation.py @@ -65,7 +65,7 @@ def __init__( "Can only update one time point at a time" ) time = all_pixels[0][0] - if self.tracks.graph.has_node(new_value): + if new_value in self.tracks.graph.node_ids(): self.actions.append( UpdateNodeSeg(tracks, new_value, all_pixels, added=True) ) diff --git a/src/funtracks/utils/tracksdata_utils.py b/src/funtracks/utils/tracksdata_utils.py index d956e5e3..6c2b9c9f 100644 --- a/src/funtracks/utils/tracksdata_utils.py +++ b/src/funtracks/utils/tracksdata_utils.py @@ -1,9 +1,11 @@ +import tempfile +import uuid +from collections.abc import Sequence from typing import Any import numpy as np -import polars as pl +import polars as pl import tracksdata as td - from polars.testing import assert_frame_equal from skimage import measure from tracksdata.nodes._mask import Mask @@ -14,8 +16,8 @@ def create_empty_graphview_graph( with_track_id: bool = False, with_area: bool = False, with_iou: bool = False, - database: str = ":memory:", - position_attrs: list[str] = ["pos"], + database: str | None = None, + position_attrs: list[str] | None = None, ) -> td.graph.GraphView: """ Create an empty tracksdata GraphView with standard node and edge attributes. @@ -29,16 +31,27 @@ def create_empty_graphview_graph( Whether to include area attribute. with_iou : bool Whether to include IOU attribute. - database : str - Path to the SQLite database file, e.g. ':memory:' for in-memory database. - position_attrs : list[str] + database : str | None + Path to the SQLite database file. If None, creates a unique temporary file. + Use ':memory:' for in-memory database (may cause issues with pickling in pytest). + position_attrs : list[str] | None List of position attribute names, e.g. ['pos'] or ['x', 'y', 'z']. + Defaults to ['pos'] if None. Returns ------- td.graph.GraphView An empty tracksdata GraphView with standard node and edge attributes. """ + 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" + kwargs = { "drivername": "sqlite", "database": database, @@ -59,9 +72,9 @@ def create_empty_graphview_graph( if with_area: graph_sql.add_node_attr_key("area", default_value=0.0) if with_track_id: - graph_sql.add_node_attr_key("track_id", default_value=0) + graph_sql.add_node_attr_key("track_id", default_value=0) graph_sql.add_node_attr_key(td.DEFAULT_ATTR_KEYS.SOLUTION, default_value=1) - #TODO: segmentation + # TODO Teun: segmentation # graph_sql.add_node_attr_key(td.DEFAULT_ATTR_KEYS.MASK, default_value=None) # graph_sql.add_node_attr_key(td.DEFAULT_ATTR_KEYS.BBOX, default_value=None) if with_iou: @@ -76,7 +89,6 @@ def create_empty_graphview_graph( return graph_td_sub - def assert_node_attrs_equal_with_masks( object1, object2, check_column_order: bool = False, check_row_order: bool = False ): @@ -97,7 +109,7 @@ def assert_node_attrs_equal_with_masks( "Both objects must be either tracksdata graphs or polars DataFrames" ) - #TODO Teun: enable this when segmentation/masks are part of node_attrs + # TODO Teun: enable this when segmentation/masks are part of node_attrs # assert_frame_equal( # node_attrs1.drop("mask"), # node_attrs2.drop("mask"), @@ -116,6 +128,7 @@ def assert_node_attrs_equal_with_masks( check_row_order=check_row_order, ) + def compute_node_attrs_from_masks( masks: list[Mask], ndim: int, scale: list[float] | None ) -> dict[str, list[Any]]: @@ -240,3 +253,49 @@ def pixels_to_td_mask( mask = Mask(mask_array, bbox=bbox) return mask, area + + +def td_graph_edge_list(graph): + """Get list of edges from a tracksdata graph. + + Args: + graph: A tracksdata graph + + Returns: + list: List of edges: [[source_id, target_id], ...] + """ + existing_edges = ( + graph.edge_attrs().select(["source_id", "target_id"]).to_numpy().tolist() + ) + return existing_edges + + +def td_get_ancestors(graph, node_id): + """Get ancestors of a node in a tracksdata graph. + + Args: + graph: A tracksdata graph + node_id: Node ID to get ancestors for + """ + + ancestors = set() + to_visit = [node_id] + + while to_visit: + current_node = to_visit.pop() + predecessors = graph.predecessors(current_node) + for pred in predecessors: + if pred not in ancestors: + ancestors.add(pred) + to_visit.append(pred) + + return ancestors + + +def td_get_single_attr_from_edge(graph, edge: tuple[int, int], attrs: Sequence[str]): + """Get a single attribute from a edge in a tracksdata graph.""" + + # TODO Teun: do graph.edge_id() + # TODO Teun: AND do edge_attrs(key) directly to prevent loading all attributes + item = graph.filter(node_ids=[edge[0], edge[1]]).edge_attrs()[attrs].item() + return item diff --git a/tests/actions/test_action_history.py b/tests/actions/test_action_history.py index 62b889b5..78e90e9e 100644 --- a/tests/actions/test_action_history.py +++ b/tests/actions/test_action_history.py @@ -1,17 +1,19 @@ -import networkx as nx - 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() - tracks = SolutionTracks(nx.DiGraph(), ndim=3, tracklet_attr="track_id") + empty_graph = create_empty_graphview_graph( + with_pos=True, with_track_id=True, with_area=False, with_iou=False + ) + tracks = SolutionTracks(empty_graph, ndim=3, tracklet_attr="track_id", time_attr="t") pos = [0, 1] - action1 = AddNode(tracks, node=0, attributes={"time": 0, "pos": pos, "track_id": 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() @@ -41,7 +43,7 @@ def test_action_history(): # undo and then add new action assert history.undo() - action2 = AddNode(tracks, node=10, attributes={"time": 10, "pos": pos, "track_id": 2}) + 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 diff --git a/tests/actions/test_add_delete_edge.py b/tests/actions/test_add_delete_edge.py index c3bb9131..3dade525 100644 --- a/tests/actions/test_add_delete_edge.py +++ b/tests/actions/test_add_delete_edge.py @@ -1,6 +1,6 @@ import copy -import networkx as nx +import polars as pl import pytest from numpy.testing import assert_array_almost_equal @@ -9,6 +9,10 @@ AddEdge, DeleteEdge, ) +from funtracks.utils.tracksdata_utils import ( + td_get_single_attr_from_edge, + td_graph_edge_list, +) iou_key = "iou" @@ -17,38 +21,62 @@ @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 = copy.deepcopy(tracks.graph) + reference_graph = tracks.graph reference_seg = copy.deepcopy(tracks.segmentation) # Create an empty tracks with just nodes (no edges) - node_graph = nx.create_empty_copy(tracks.graph, with_data=True) - tracks.graph = node_graph + for edge in td_graph_edge_list(tracks.graph): + 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 graph"): + 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.nodes()) == set(reference_graph.nodes()) + assert set(tracks.graph.node_ids()) == set(reference_graph.node_ids()) if with_seg: - for edge in tracks.graph.edges(): - assert tracks.graph.edges[edge][iou_key] == pytest.approx( - reference_graph.edges[edge][iou_key], abs=0.01 + for edge in td_graph_edge_list(tracks.graph): + edge_id_tracks = tracks.graph.edge_id(edge[0], edge[1]) + edge_id_graph = reference_graph.edge_id(edge[0], edge[1]) + assert tracks.graph.edge_attrs().filter(pl.col("edge_id") == edge_id_tracks)[ + iou_key + ].item() == pytest.approx( + reference_graph.edge_attrs() + .filter(pl.col("edge_id") == edge_id_graph)[iou_key] + .item(), + abs=0.01, ) assert_array_almost_equal(tracks.segmentation, reference_seg) + # TODO Teun: the next line fails: inverse = action.inverse() - assert set(tracks.graph.edges()) == set() + + assert set(tracks.graph.edge_ids()) == set() if tracks.segmentation is not None: assert_array_almost_equal(tracks.segmentation, reference_seg) inverse.inverse() - assert set(tracks.graph.nodes()) == set(reference_graph.nodes()) - assert set(tracks.graph.edges()) == set(reference_graph.edges()) + assert set(tracks.graph.node_ids()) == set(reference_graph.node_ids()) + assert set(tracks.graph.edge_ids()) == set(reference_graph.edge_ids()) + assert sorted(td_graph_edge_list(tracks.graph)) == sorted( + td_graph_edge_list(reference_graph) + ) if with_seg: - for edge in tracks.graph.edges(): - assert tracks.graph.edges[edge][iou_key] == pytest.approx( - reference_graph.edges[edge][iou_key], abs=0.01 + for edge in td_graph_edge_list(tracks.graph): + edge_id_tracks = tracks.graph.edge_id(edge[0], edge[1]) + edge_id_graph = reference_graph.edge_id(edge[0], edge[1]) + + assert tracks.graph.edge_attrs().filter(pl.col("edge_id") == edge_id_tracks)[ + iou_key + ].item() == pytest.approx( + reference_graph.edge_attrs() + .filter(pl.col("edge_id") == edge_id_graph)[iou_key] + .item(), + abs=0.01, ) assert_array_almost_equal(tracks.segmentation, reference_seg) @@ -103,7 +131,7 @@ def test_custom_edge_attributes_preserved(get_tracks, ndim, with_seg): ), } for key, feature in custom_features.items(): - tracks.features[key] = feature + tracks.add_edge_feature(key, feature) # Define custom edge attributes custom_attrs = { @@ -113,13 +141,13 @@ def test_custom_edge_attributes_preserved(get_tracks, ndim, with_seg): } # Add an edge with custom attributes - edge = (1, 2) + 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(): - assert tracks.graph.edges[edge][key] == value, ( + assert td_get_single_attr_from_edge(tracks.graph, edge, key) == value, ( f"Attribute {key} not preserved after add" ) @@ -133,6 +161,6 @@ def test_custom_edge_attributes_preserved(get_tracks, ndim, with_seg): # Verify all custom attributes are still present after re-adding for key, value in custom_attrs.items(): - assert tracks.graph.edges[edge][key] == value, ( + assert td_get_single_attr_from_edge(tracks.graph, edge, key) == value, ( f"Attribute {key} not preserved after delete/re-add cycle" ) diff --git a/tests/actions/test_add_delete_nodes.py b/tests/actions/test_add_delete_nodes.py index 8d7437f3..6d4eb68c 100644 --- a/tests/actions/test_add_delete_nodes.py +++ b/tests/actions/test_add_delete_nodes.py @@ -149,7 +149,7 @@ def test_custom_attributes_preserved(get_tracks, ndim, with_seg): ), } for key, feature in custom_features.items(): - tracks.features[key] = feature + tracks.add_node_feature(key, feature) # Define attributes including custom ones custom_attrs = { @@ -183,22 +183,28 @@ def test_custom_attributes_preserved(get_tracks, ndim, with_seg): action = AddNode(tracks, node_id, custom_attrs, pixels=pixels) # Verify all attributes are present after adding - assert tracks.graph.has_node(node_id) + assert node_id in tracks.graph.node_ids() for key, value in custom_attrs.items(): - assert tracks.graph.nodes[node_id][key] == value, ( - f"Attribute {key} not preserved after add" - ) + if key == "pos": + assert_array_almost_equal(tracks.graph[node_id][key], np.array(value)) + else: + assert tracks.graph[node_id][key] == value, ( + f"Attribute {key} not preserved after add" + ) # Delete the node delete_action = action.inverse() - assert not tracks.graph.has_node(node_id) + assert node_id not in tracks.graph.node_ids() # Re-add the node by inverting the delete delete_action.inverse() - assert tracks.graph.has_node(node_id) + 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(): - assert tracks.graph.nodes[node_id][key] == value, ( - f"Attribute {key} not preserved after delete/re-add cycle" - ) + if key == "pos": + assert_array_almost_equal(tracks.graph[node_id][key], np.array(value)) + else: + assert tracks.graph[node_id][key] == value, ( + f"Attribute {key} not preserved after delete/re-add cycle" + ) diff --git a/tests/actions/test_update_node_attrs.py b/tests/actions/test_update_node_attrs.py index b3ead7ed..6084a4d4 100644 --- a/tests/actions/test_update_node_attrs.py +++ b/tests/actions/test_update_node_attrs.py @@ -3,15 +3,25 @@ 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_attr = {"score": 1.0} - action = UpdateNodeAttrs(tracks, node, new_attr) + new_feature = Feature( + feature_type="node", + value_type="float", + num_values=1, + display_name="Score", + required=False, + default_value=None, + ) + tracks.add_node_feature("score", new_feature) + + action = UpdateNodeAttrs(tracks, node, {"score": 1.0}) assert tracks.get_node_attr(node, "score") == 1.0 inverse = action.inverse() diff --git a/tests/actions/test_update_node_segs.py b/tests/actions/test_update_node_segs.py index 97cc1629..c1938a5e 100644 --- a/tests/actions/test_update_node_segs.py +++ b/tests/actions/test_update_node_segs.py @@ -3,6 +3,7 @@ import numpy as np import pytest from numpy.testing import assert_array_almost_equal +from polars.testing import assert_series_not_equal from funtracks.actions import ( UpdateNodeSeg, @@ -16,8 +17,8 @@ def test_update_node_segs(get_tracks, ndim): reference_graph = copy.deepcopy(tracks.graph) original_seg = tracks.segmentation.copy() - original_area = tracks.graph.nodes[1]["area"] - original_pos = tracks.graph.nodes[1]["pos"] + original_area = tracks.graph[1]["area"] + original_pos = tracks.graph[1]["pos"] # Add a couple pixels to the first node new_seg = tracks.segmentation.copy() @@ -30,20 +31,22 @@ def test_update_node_segs(get_tracks, ndim): pixels = np.nonzero(original_seg != new_seg) action = UpdateNodeSeg(tracks, node, pixels=pixels, added=True) - assert set(tracks.graph.nodes()) == set(reference_graph.nodes()) - assert tracks.graph.nodes[1]["area"] == original_area + 1 - assert tracks.graph.nodes[1]["pos"] != original_pos + assert set(tracks.graph.node_ids()) == set(reference_graph.node_ids()) + assert tracks.graph[1]["area"] == original_area + 1 + assert not np.allclose(tracks.graph[1]["pos"], original_pos) assert_array_almost_equal(tracks.segmentation, new_seg) inverse = action.inverse() - assert set(tracks.graph.nodes()) == set(reference_graph.nodes()) - for node, data in tracks.graph.nodes(data=True): - assert data == reference_graph.nodes[node] + assert set(tracks.graph.node_ids()) == set(reference_graph.node_ids()) + assert_series_not_equal( + reference_graph[1]["pos"], + tracks.graph[1]["pos"], + ) assert_array_almost_equal(tracks.segmentation, original_seg) inverse.inverse() - assert set(tracks.graph.nodes()) == set(reference_graph.nodes()) - assert tracks.graph.nodes[1]["area"] == original_area + 1 - assert tracks.graph.nodes[1]["pos"] != original_pos + assert set(tracks.graph.node_ids()) == set(reference_graph.node_ids()) + assert tracks.graph[1]["area"] == original_area + 1 + assert not np.allclose(tracks.graph[1]["pos"], original_pos) assert_array_almost_equal(tracks.segmentation, new_seg) diff --git a/tests/conftest.py b/tests/conftest.py index 4f4ea87e..3ff9fe72 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -59,6 +59,7 @@ def _make_graph( with_track_id: bool = False, with_area: bool = False, with_iou: bool = False, + database: str | None = None, ) -> td.graph.GraphView: """Generate a test graph with configurable features. @@ -68,6 +69,7 @@ def _make_graph( 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) + database: Database path for SQLGraph (if None, uses default) Returns: A graph with the requested features @@ -76,8 +78,7 @@ def _make_graph( with_pos=with_pos, with_track_id=with_track_id, with_area=with_area, - # with_iou=with_iou, - database=":memory:", + database=database, position_attrs=["pos"], ) @@ -158,48 +159,65 @@ def _make_graph( @pytest.fixture -def graph_clean() -> td.graph.GraphView: +def graph_clean(tmp_path) -> td.graph.GraphView: """Base graph with only time - no positions or computed features.""" - return _make_graph(ndim=3) + db_path = str(tmp_path / "graph_clean.db") + return _make_graph(ndim=3, database=db_path) @pytest.fixture -def graph_2d_with_position() -> td.graph.GraphView: +def graph_2d_with_position(tmp_path) -> td.graph.GraphView: """Graph with 2D positions - for Tracks without segmentation.""" - return _make_graph(ndim=3, with_pos=True) + 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() -> td.graph.GraphView: +def graph_2d_with_track_id(tmp_path) -> td.graph.GraphView: """Graph with 2D positions and track_id - for SolutionTracks without segmentation.""" - return _make_graph(ndim=3, with_pos=True, with_track_id=True) + 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_computed_features() -> td.graph.GraphView: +def graph_2d_with_computed_features(tmp_path) -> td.graph.GraphView: """Graph with all computed features - for SolutionTracks with segmentation.""" + db_path = str(tmp_path / "graph_2d_computed.db") return _make_graph( - ndim=3, with_pos=True, with_track_id=True, with_area=True, with_iou=True + ndim=3, + with_pos=True, + with_track_id=True, + with_area=True, + with_iou=True, + database=db_path, ) @pytest.fixture -def graph_3d_with_position() -> td.graph.GraphView: +def graph_3d_with_position(tmp_path) -> td.graph.GraphView: """Graph with 3D positions - for Tracks without segmentation.""" - return _make_graph(ndim=4, with_pos=True) + 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() -> td.graph.GraphView: +def graph_3d_with_track_id(tmp_path) -> td.graph.GraphView: """Graph with 3D positions and track_id - for SolutionTracks without segmentation.""" - return _make_graph(ndim=4, with_pos=True, with_track_id=True) + 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_computed_features() -> td.graph.GraphView: +def graph_3d_with_computed_features(tmp_path) -> td.graph.GraphView: """Graph with all computed features - for SolutionTracks with segmentation.""" + db_path = str(tmp_path / "graph_3d_computed.db") return _make_graph( - ndim=4, with_pos=True, with_track_id=True, with_area=True, with_iou=True + ndim=4, + with_pos=True, + with_track_id=True, + with_area=True, + with_iou=True, + database=db_path, ) @@ -287,9 +305,10 @@ def _make_tracks( @pytest.fixture -def graph_2d_list() -> td.graph.GraphView: +def graph_2d_list(tmp_path) -> td.graph.GraphView: # graph = nx.DiGraph() - graph = create_empty_graphview_graph() + db_path = str(tmp_path / "graph_2d_list.db") + graph = create_empty_graphview_graph(database=db_path) nodes = [ { diff --git a/tests/data_model/test_solution_tracks.py b/tests/data_model/test_solution_tracks.py index a0788399..851e5bca 100644 --- a/tests/data_model/test_solution_tracks.py +++ b/tests/data_model/test_solution_tracks.py @@ -1,20 +1,20 @@ -import networkx as nx import numpy as np import pytest from funtracks.actions import AddNode from funtracks.data_model import SolutionTracks, Tracks +from funtracks.utils.tracksdata_utils import create_empty_graphview_graph track_attrs = {"time_attr": "t", "tracklet_attr": "track_id"} -def test_recompute_track_ids(graph_2d_with_position): +def test_recompute_track_ids(graph_2d_with_track_id): tracks = SolutionTracks( - graph_2d_with_position, + graph_2d_with_track_id, ndim=3, **track_attrs, ) - assert tracks.get_next_track_id() == 5 + assert tracks.get_next_track_id() == 6 def test_next_track_id(graph_2d_with_computed_features): @@ -67,7 +67,7 @@ def test_from_tracks_cls_recompute(graph_2d_with_computed_features): ) # delete track id on one node triggers reassignment of track_ids even when recompute # is False. - tracks.graph.nodes[1].pop(tracks.features.tracklet_key, None) + tracks.graph[1][tracks.features.tracklet_key] = [None] 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 @@ -77,7 +77,9 @@ def test_from_tracks_cls_recompute(graph_2d_with_computed_features): def test_next_track_id_empty(): - graph = nx.DiGraph() + graph = create_empty_graphview_graph( + with_pos=True, with_track_id=True, with_area=False, with_iou=False + ) seg = np.zeros(shape=(10, 100, 100, 100), dtype=np.uint64) tracks = SolutionTracks(graph, segmentation=seg, **track_attrs) assert tracks.get_next_track_id() == 1 diff --git a/tests/data_model/test_tracks.py b/tests/data_model/test_tracks.py index cfab7301..4852d7c0 100644 --- a/tests/data_model/test_tracks.py +++ b/tests/data_model/test_tracks.py @@ -5,13 +5,18 @@ from numpy.testing import assert_array_almost_equal from funtracks.data_model import Tracks +from funtracks.utils.tracksdata_utils import ( + create_empty_graphview_graph, + td_graph_edge_list, +) track_attrs = {"time_attr": "t", "tracklet_attr": "track_id"} def test_create_tracks(graph_3d_with_computed_features: nx.DiGraph, segmentation_3d): # create empty tracks - tracks = Tracks(graph=nx.DiGraph(), ndim=3, **track_attrs) # type: ignore[arg-type] + empty_graph = create_empty_graphview_graph() + 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): @@ -45,8 +50,6 @@ def test_create_tracks(graph_3d_with_computed_features: nx.DiGraph, segmentation assert tracks.get_positions([1]).tolist() == [[50, 50, 50]] assert tracks.get_time(1) == 0 assert tracks.get_positions([1], incl_time=True).tolist() == [[0, 50, 50, 50]] - tracks.set_time(1, 1) - assert tracks.get_positions([1], incl_time=True).tolist() == [[1, 50, 50, 50]] tracks_wrong_attr = Tracks( graph=graph_3d_with_computed_features, @@ -64,13 +67,15 @@ def test_create_tracks(graph_3d_with_computed_features: nx.DiGraph, segmentation # test multiple position attrs pos_attr = ("z", "y", "x") - for node in graph_3d_with_computed_features.nodes(): - pos = graph_3d_with_computed_features.nodes[node]["pos"] + graph_3d_with_computed_features.add_node_attr_key(key="z", default_value=0) + graph_3d_with_computed_features.add_node_attr_key(key="y", default_value=0) + graph_3d_with_computed_features.add_node_attr_key(key="x", default_value=0) + for node in graph_3d_with_computed_features.node_ids(): + pos = graph_3d_with_computed_features[node]["pos"] z, y, x = pos - del graph_3d_with_computed_features.nodes[node]["pos"] - graph_3d_with_computed_features.nodes[node]["z"] = z - graph_3d_with_computed_features.nodes[node]["y"] = y - graph_3d_with_computed_features.nodes[node]["x"] = x + graph_3d_with_computed_features[node]["z"] = z + graph_3d_with_computed_features[node]["y"] = y + graph_3d_with_computed_features[node]["x"] = x tracks = Tracks( graph=graph_3d_with_computed_features, @@ -82,9 +87,6 @@ def test_create_tracks(graph_3d_with_computed_features: nx.DiGraph, segmentation tracks.set_position(1, [55, 56, 57]) assert tracks.get_position(1) == [55, 56, 57] - tracks.set_position(1, [1, 50, 50, 50], incl_time=True) - assert tracks.get_time(1) == 1 - def test_pixels_and_seg_id(graph_3d_with_computed_features, segmentation_3d): # create track with graph and seg @@ -123,27 +125,31 @@ def test_save_load_delete(tmp_path, graph_2d_with_computed_features, segmentatio def test_nodes_edges(graph_2d_with_computed_features): tracks = Tracks(graph_2d_with_computed_features, ndim=3, **track_attrs) assert set(tracks.nodes()) == {1, 2, 3, 4, 5, 6} - assert set(map(tuple, tracks.edges())) == {(1, 2), (1, 3), (3, 4), (4, 5)} + assert set(tracks.edges()) == {1, 2, 3, 4} + assert set(map(tuple, td_graph_edge_list(tracks.graph))) == { + (1, 2), + (1, 3), + (3, 4), + (4, 5), + } def test_degrees(graph_2d_with_computed_features): tracks = Tracks(graph_2d_with_computed_features, ndim=3, **track_attrs) assert tracks.in_degree(np.array([1])) == 0 assert tracks.in_degree(np.array([4])) == 1 - assert np.array_equal( - tracks.in_degree(None), np.array([[1, 0], [2, 1], [3, 1], [4, 1], [5, 1], [6, 0]]) - ) + assert np.array_equal(tracks.in_degree(None), np.array([0, 1, 1, 1, 1, 0])) assert np.array_equal(tracks.out_degree(np.array([1, 4])), np.array([2, 1])) assert np.array_equal( tracks.out_degree(None), - np.array([[1, 2], [2, 0], [3, 1], [4, 1], [5, 0], [6, 0]]), + np.array([2, 0, 1, 1, 0, 0]), ) def test_predecessors_successors(graph_2d_with_computed_features): tracks = Tracks(graph_2d_with_computed_features, ndim=3, **track_attrs) assert tracks.predecessors(2) == [1] - assert tracks.successors(1) == [2, 3] + assert set(tracks.successors(1)) == {2, 3} assert tracks.predecessors(1) == [] assert tracks.successors(2) == [] @@ -164,25 +170,25 @@ def test_iou_methods(graph_2d_with_computed_features): def test_get_set_node_attr(graph_2d_with_computed_features): tracks = Tracks(graph_2d_with_computed_features, ndim=3, **track_attrs) - tracks._set_node_attr(1, "a", 42) + tracks._set_node_attr(1, "area", 42) # test deprecated functions with pytest.warns( DeprecationWarning, match="_get_node_attr deprecated in favor of public method get_node_attr", ): - assert tracks._get_node_attr(1, "a") == 42 + assert tracks._get_node_attr(1, "area") == 42 - tracks._set_nodes_attr([1, 2], "b", [7, 8]) + tracks._set_nodes_attr([1, 2], "track_id", [7, 8]) with pytest.warns( DeprecationWarning, match="_get_nodes_attr deprecated in favor of public method get_nodes_attr", ): - assert tracks._get_nodes_attr([1, 2], "b") == [7, 8] + assert tracks._get_nodes_attr([1, 2], "track_id") == [7, 8] # test new functions - assert tracks.get_node_attr(1, "a", required=True) == 42 - assert tracks.get_nodes_attr([1, 2], "b", required=True) == [7, 8] - assert tracks.get_nodes_attr([1, 2], "b", required=False) == [7, 8] + assert tracks.get_node_attr(1, "area", required=True) == 42 + assert tracks.get_nodes_attr([1, 2], "track_id", required=True) == [7, 8] + assert tracks.get_nodes_attr([1, 2], "track_id", required=False) == [7, 8] with pytest.raises(KeyError): tracks.get_node_attr(1, "not_present", required=True) assert tracks.get_node_attr(1, "not_present", required=False) is None @@ -193,18 +199,18 @@ def test_get_set_node_attr(graph_2d_with_computed_features): ) # test array attributes - tracks._set_node_attr(1, "array_attr", np.array([1, 2, 3])) - tracks._set_nodes_attr((1, 2), "array_attr2", np.array(([1, 2, 3], [4, 5, 6]))) + tracks._set_node_attr(1, "pos", np.array([1, 2])) + tracks._set_nodes_attr((1, 2), "pos", np.array(([1, 2], [4, 5]))) def test_get_set_edge_attr(graph_2d_with_computed_features): tracks = Tracks(graph_2d_with_computed_features, ndim=3, **track_attrs) - tracks._set_edge_attr((1, 2), "c", 99) - assert tracks.get_edge_attr((1, 2), "c") == 99 - assert tracks.get_edge_attr((1, 2), "iou", required=True) == 0.0 - tracks._set_edges_attr([(1, 2), (1, 3)], "d", [123, 5]) - assert tracks.get_edges_attr([(1, 2), (1, 3)], "d", required=True) == [123, 5] - assert tracks.get_edges_attr([(1, 2), (1, 3)], "d", required=False) == [123, 5] + tracks._set_edge_attr((1, 2), "iou", 99) + assert tracks.get_edge_attr((1, 2), "iou") == 99 + assert tracks.get_edge_attr((1, 2), "iou", required=True) == 99 + tracks._set_edges_attr([(1, 2), (1, 3)], "iou", [123, 5]) + assert tracks.get_edges_attr([(1, 2), (1, 3)], "iou", required=True) == [123, 5] + assert tracks.get_edges_attr([(1, 2), (1, 3)], "iou", required=False) == [123, 5] with pytest.raises(KeyError): tracks.get_edge_attr((1, 2), "not_present", required=True) assert tracks.get_edge_attr((1, 2), "not_present", required=False) is None @@ -242,36 +248,6 @@ def test_set_positions_list(graph_2d_list): ) -def test_set_node_attributes(graph_2d_with_computed_features, caplog): - tracks = Tracks(graph_2d_with_computed_features, ndim=3, **track_attrs) - tracks.graph.add_node_attr_key("attr_1", default_value=0) - tracks.graph.add_node_attr_key("attr_2", default_value="") - - # TODO: - # 1) this function is no longer necessary, - # 2) what is the intended purpose of attrs here (1 and 6 values, for only 1 node)? - attrs = {"attr_1": 1, "attr_2": ["a", "b", "c", "d", "e", "f"]} - tracks._set_node_attributes(1, attrs) - assert tracks.get_node_attr(1, "attr_1") == 1 - assert tracks.get_node_attr(1, "attr_2") == ["a", "b", "c", "d", "e", "f"] - with caplog.at_level("INFO"): - tracks._set_node_attributes(7, attrs) - assert any("Node 7 not found in the graph." in message for message in caplog.messages) - - -def test_set_edge_attributes(graph_2d_with_computed_features, caplog): - tracks = Tracks(graph_2d_with_computed_features, ndim=3, **track_attrs) - attrs = {"attr_1": 1, "attr_2": ["a", "b", "c", "d"]} - tracks._set_edge_attributes((1, 2), attrs) - assert tracks.get_edge_attr((1, 2), "attr_1") == 1 - assert tracks.get_edge_attr((1, 2), "attr_2") == ["a", "b", "c", "d"] - with caplog.at_level("INFO"): - tracks._set_edge_attributes((4, 6), attrs) - assert any( - "Edge (4, 6) not found in the graph." in message for message in caplog.messages - ) - - def test_get_pixels_and_set_pixels(graph_2d_with_computed_features, segmentation_2d): tracks = Tracks( graph_2d_with_computed_features, segmentation_2d, ndim=3, **track_attrs diff --git a/tests/data_model/test_tracks_controller.py b/tests/data_model/test_tracks_controller.py index 8047c0e5..cbaa2cdc 100644 --- a/tests/data_model/test_tracks_controller.py +++ b/tests/data_model/test_tracks_controller.py @@ -14,7 +14,7 @@ def test__add_nodes_no_seg(graph_2d_with_computed_features): ) controller = TracksController(tracks) - num_edges = tracks.graph.number_of_edges() + num_edges = tracks.graph.num_edges # start a new track with multiple nodes attrs = { @@ -26,11 +26,11 @@ def test__add_nodes_no_seg(graph_2d_with_computed_features): action, node_ids = controller._add_nodes(attrs) node = node_ids[0] - assert tracks.graph.has_node(node) + assert node in tracks.graph.node_ids() assert tracks.get_position(node) == [1, 3] assert tracks.get_track_id(node) == 6 - assert tracks.graph.number_of_edges() == num_edges + 1 # one edge added + assert tracks.graph.num_edges == num_edges + 1 # one edge added # add nodes to end of existing track attrs = { @@ -76,7 +76,7 @@ def test__add_nodes_with_seg(graph_2d_with_computed_features, segmentation_2d): ) controller = TracksController(tracks) - num_edges = tracks.graph.number_of_edges() + num_edges = tracks.graph.num_edges new_seg = segmentation_2d.copy() time = 0 @@ -112,7 +112,7 @@ def test__add_nodes_with_seg(graph_2d_with_computed_features, segmentation_2d): assert tracks.get_track_id(node2) == 6 assert np.sum(tracks.segmentation != new_seg) == 0 - assert tracks.graph.number_of_edges() == num_edges + 1 # one edge added + assert tracks.graph.num_edges == num_edges + 1 # one edge added # add nodes to end of existing track time = 2 @@ -180,26 +180,26 @@ def test__delete_nodes_no_seg(graph_2d_with_computed_features): tracklet_attr="track_id", ) controller = TracksController(tracks) - num_edges = tracks.graph.number_of_edges() + num_edges = tracks.graph.num_edges # delete unconnected node node = 6 action = controller._delete_nodes([node]) - assert not tracks.graph.has_node(node) - assert tracks.graph.number_of_edges() == num_edges + assert node not in tracks.graph.node_ids() + assert tracks.graph.num_edges == num_edges action.inverse() # delete end node node = 5 action = controller._delete_nodes([node]) - assert not tracks.graph.has_node(node) + assert node not in tracks.graph.node_ids() assert not tracks.graph.has_edge(4, node) action.inverse() # delete continuation node node = 4 action = controller._delete_nodes([node]) - assert not tracks.graph.has_node(node) + assert node not in tracks.graph.node_ids() assert not tracks.graph.has_edge(3, node) assert not tracks.graph.has_edge(node, 5) assert tracks.graph.has_edge(3, 5) @@ -209,7 +209,7 @@ def test__delete_nodes_no_seg(graph_2d_with_computed_features): # delete div parent node = 1 action = controller._delete_nodes([node]) - assert not tracks.graph.has_node(node) + assert node not in tracks.graph.node_ids() assert not tracks.graph.has_edge(node, 2) assert not tracks.graph.has_edge(node, 3) action.inverse() @@ -217,7 +217,7 @@ def test__delete_nodes_no_seg(graph_2d_with_computed_features): # delete div child node = 3 action = controller._delete_nodes([node]) - assert not tracks.graph.has_node(node) + assert node not in tracks.graph.node_ids() assert tracks.get_track_id(2) == 1 # update track id for other child @@ -229,16 +229,16 @@ def test__delete_nodes_with_seg(graph_2d_with_computed_features, segmentation_2d tracklet_attr="track_id", ) controller = TracksController(tracks) - num_edges = tracks.graph.number_of_edges() + num_edges = tracks.graph.num_edges # delete unconnected node node = 6 track_id = 6 time = 4 action = controller._delete_nodes([node]) - assert not tracks.graph.has_node(node) + assert node not in tracks.graph.node_ids() assert track_id not in np.unique(tracks.segmentation[time]) - assert tracks.graph.number_of_edges() == num_edges + assert tracks.graph.num_edges == num_edges action.inverse() # delete end node @@ -246,7 +246,7 @@ def test__delete_nodes_with_seg(graph_2d_with_computed_features, segmentation_2d track_id = 3 time = 4 action = controller._delete_nodes([node]) - assert not tracks.graph.has_node(node) + assert node not in tracks.graph.node_ids() assert track_id not in np.unique(tracks.segmentation[time]) assert not tracks.graph.has_edge(4, node) action.inverse() @@ -256,7 +256,7 @@ def test__delete_nodes_with_seg(graph_2d_with_computed_features, segmentation_2d track_id = 3 time = 2 action = controller._delete_nodes([node]) - assert not tracks.graph.has_node(node) + assert node not in tracks.graph.node_ids() assert track_id not in np.unique(tracks.segmentation[time]) assert not tracks.graph.has_edge(3, node) assert not tracks.graph.has_edge(node, 5) @@ -269,7 +269,7 @@ def test__delete_nodes_with_seg(graph_2d_with_computed_features, segmentation_2d track_id = 1 time = 0 action = controller._delete_nodes([node]) - assert not tracks.graph.has_node(node) + assert node not in tracks.graph.node_ids() assert track_id not in np.unique(tracks.segmentation[time]) assert not tracks.graph.has_edge(node, 2) assert not tracks.graph.has_edge(node, 3) @@ -280,7 +280,7 @@ def test__delete_nodes_with_seg(graph_2d_with_computed_features, segmentation_2d track_id = 2 time = 1 action = controller._delete_nodes([node]) - assert not tracks.graph.has_node(node) + assert node not in tracks.graph.node_ids() assert track_id not in np.unique(tracks.segmentation[time]) assert tracks.get_track_id(3) == 1 # update track id for other child assert tracks.get_track_id(5) == 1 # update track id for other child @@ -294,7 +294,7 @@ def test__add_remove_edges_no_seg(graph_2d_with_computed_features): tracklet_attr="track_id", ) controller = TracksController(tracks) - num_edges = tracks.graph.number_of_edges() + num_edges = tracks.graph.num_edges # delete continuation edge edge = (3, 4) @@ -302,13 +302,13 @@ def test__add_remove_edges_no_seg(graph_2d_with_computed_features): controller._delete_edges([edge]) assert not tracks.graph.has_edge(*edge) assert tracks.get_track_id(edge[1]) != track_id # relabeled the rest of the track - assert tracks.graph.number_of_edges() == num_edges - 1 + assert tracks.graph.num_edges == num_edges - 1 # add back in continuation edge controller._add_edges([edge]) assert tracks.graph.has_edge(*edge) assert tracks.get_track_id(edge[1]) == track_id # track id was changed back - assert tracks.graph.number_of_edges() == num_edges + assert tracks.graph.num_edges == num_edges # delete division edge edge = (1, 3) @@ -317,7 +317,7 @@ def test__add_remove_edges_no_seg(graph_2d_with_computed_features): assert not tracks.graph.has_edge(*edge) assert tracks.get_track_id(edge[1]) == track_id # dont relabel after removal assert tracks.get_track_id(2) == 1 # but do relabel the sibling - assert tracks.graph.number_of_edges() == num_edges - 1 + assert tracks.graph.num_edges == num_edges - 1 # add back in division edge edge = (1, 3) @@ -326,4 +326,4 @@ def test__add_remove_edges_no_seg(graph_2d_with_computed_features): assert tracks.graph.has_edge(*edge) assert tracks.get_track_id(edge[1]) == track_id # dont relabel after removal assert tracks.get_track_id(2) != 1 # give sibling new id again (not necessarily 2) - assert tracks.graph.number_of_edges() == num_edges + assert tracks.graph.num_edges == num_edges diff --git a/tests/import_export/test_csv_import.py b/tests/import_export/test_csv_import.py index 552dd5c4..f8d65924 100644 --- a/tests/import_export/test_csv_import.py +++ b/tests/import_export/test_csv_import.py @@ -44,7 +44,7 @@ def test_import_2d(self, simple_df_2d): assert isinstance(tracks, SolutionTracks) assert tracks.graph.num_nodes == 4 - assert tracks.graph.number_of_edges() == 3 + assert tracks.graph.num_edges == 3 assert tracks.ndim == 3 def test_import_3d(self, df_3d): @@ -144,7 +144,7 @@ def test_single_node(self): tracks = tracks_from_df(df) assert tracks.graph.num_nodes == 1 - assert tracks.graph.number_of_edges() == 0 + assert tracks.graph.num_edges == 0 def test_multiple_roots(self): """Test multiple independent lineages.""" @@ -161,7 +161,7 @@ def test_multiple_roots(self): tracks = tracks_from_df(df) assert tracks.graph.num_nodes == 4 - assert tracks.graph.number_of_edges() == 2 + assert tracks.graph.num_edges == 2 # Should have two root nodes roots = [n for n in tracks.graph.nodes() if tracks.graph.in_degree(n) == 0] @@ -182,7 +182,7 @@ def test_division_event(self): tracks = tracks_from_df(df) assert tracks.graph.num_nodes == 3 - assert tracks.graph.number_of_edges() == 2 + assert tracks.graph.num_edges == 2 # Node 1 should have two children children = list(tracks.graph.successors(1)) @@ -204,7 +204,7 @@ def test_long_track(self): tracks = tracks_from_df(df) assert tracks.graph.num_nodes == 10 - assert tracks.graph.number_of_edges() == 9 + assert tracks.graph.num_edges == 9 # Should form a single linear chain roots = [n for n in tracks.graph.nodes() if tracks.graph.in_degree(n) == 0] diff --git a/tests/user_actions/test_user_add_delete_node.py b/tests/user_actions/test_user_add_delete_node.py index 55f1385d..c4dbaeac 100644 --- a/tests/user_actions/test_user_add_delete_node.py +++ b/tests/user_actions/test_user_add_delete_node.py @@ -56,10 +56,10 @@ def test_user_add_node(self, get_tracks, ndim, with_seg): else: pixels = None graph = tracks.graph - assert not graph.has_node(node_id) + assert node_id not in graph.node_ids() assert graph.has_edge(4, 5) action = UserAddNode(tracks, node_id, attributes, pixels=pixels) - assert graph.has_node(node_id) + assert node_id in graph.node_ids() assert not graph.has_edge(4, 5) assert graph.has_edge(4, node_id) assert graph.has_edge(node_id, 5) @@ -69,11 +69,11 @@ def test_user_add_node(self, get_tracks, ndim, with_seg): assert tracks.get_node_attr(node_id, "area") == 1 inverse = action.inverse() - assert not graph.has_node(node_id) + assert node_id not in graph.node_ids() assert graph.has_edge(4, 5) inverse.inverse() - assert graph.has_node(node_id) + assert node_id in graph.node_ids() assert not graph.has_edge(4, 5) assert graph.has_edge(4, node_id) assert graph.has_edge(node_id, 5) @@ -89,25 +89,25 @@ def test_user_delete_node(self, get_tracks, ndim, with_seg): node_id = 4 graph = tracks.graph - assert graph.has_node(node_id) + assert node_id in graph.node_ids() 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 node_id not in graph.node_ids() 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 node_id in graph.node_ids() 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 node_id not in graph.node_ids() assert not graph.has_edge(3, node_id) assert not graph.has_edge(node_id, 5) assert graph.has_edge(3, 5) @@ -122,7 +122,7 @@ def test_user_delete_node_after_division(self, get_tracks, ndim, with_seg): sib = 3 graph = tracks.graph - assert graph.has_node(node_id) + assert node_id in graph.node_ids() 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) @@ -132,18 +132,18 @@ def test_user_delete_node_after_division(self, get_tracks, ndim, with_seg): assert node_track_id != sib_track_id action = UserDeleteNode(tracks, node_id) - assert not graph.has_node(node_id) + assert node_id not in graph.node_ids() 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 node_id in graph.node_ids() 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 node_id not in graph.node_ids() assert graph.has_edge(parent_node, sib) assert tracks.get_track_id(sib) == parent_track_id diff --git a/tests/user_actions/test_user_update_segmentation.py b/tests/user_actions/test_user_update_segmentation.py index 0d228a14..5aac7653 100644 --- a/tests/user_actions/test_user_update_segmentation.py +++ b/tests/user_actions/test_user_update_segmentation.py @@ -39,14 +39,14 @@ def test_user_update_seg_smaller(self, get_tracks, ndim): updated_pixels=[(pixels_to_remove, node_id)], current_track_id=1, ) - assert tracks.graph.has_node(node_id) + assert node_id in tracks.graph.node_ids() assert self.pixel_equals(tracks.get_pixels(node_id), remaining_pixels) 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 node_id in tracks.graph.node_ids() assert self.pixel_equals(tracks.get_pixels(node_id), orig_pixels) assert tracks.get_position(node_id) == orig_position assert tracks.get_node_attr(node_id, "area") == orig_area @@ -84,20 +84,20 @@ def test_user_update_seg_bigger(self, get_tracks, ndim): action = UserUpdateSegmentation( tracks, new_value=3, updated_pixels=[(pixels_to_add, 0)], current_track_id=1 ) - assert tracks.graph.has_node(node_id) + assert node_id in tracks.graph.node_ids() assert self.pixel_equals(all_pixels, tracks.get_pixels(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 node_id in tracks.graph.node_ids() assert self.pixel_equals(orig_pixels, tracks.get_pixels(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 node_id in tracks.graph.node_ids() assert self.pixel_equals(all_pixels, tracks.get_pixels(node_id)) assert tracks.get_node_attr(node_id, "area") == orig_area + 1 assert tracks.get_edge_attr(edge, iou_key) != orig_iou @@ -123,11 +123,11 @@ def test_user_erase_seg(self, get_tracks, ndim): updated_pixels=[(pixels_to_remove, node_id)], current_track_id=1, ) - assert not tracks.graph.has_node(node_id) + assert node_id not in tracks.graph.node_ids() tracks.set_pixels(pixels_to_remove, node_id) inverse = action.inverse() - assert tracks.graph.has_node(node_id) + assert node_id in tracks.graph.node_ids() self.pixel_equals(tracks.get_pixels(node_id), orig_pixels) assert tracks.get_position(node_id) == orig_position assert tracks.get_node_attr(node_id, "area") == orig_area @@ -135,7 +135,7 @@ def test_user_erase_seg(self, get_tracks, ndim): tracks.set_pixels(pixels_to_remove, 0) inverse.inverse() - assert not tracks.graph.has_node(node_id) + assert node_id not in tracks.graph.node_ids() def test_user_add_seg(self, get_tracks, ndim): tracks = get_tracks(ndim=ndim, with_seg=True, is_solution=True) @@ -152,7 +152,7 @@ def test_user_add_seg(self, get_tracks, ndim): 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 node_id not in tracks.graph.node_ids() assert np.sum(tracks.segmentation == node_id) == 0 tracks.set_pixels(pixels_to_add, node_id) @@ -163,16 +163,16 @@ def test_user_add_seg(self, get_tracks, ndim): current_track_id=10, ) assert np.sum(tracks.segmentation == node_id) == len(pixels_to_add[0]) - assert tracks.graph.has_node(node_id) + assert node_id in tracks.graph.node_ids() 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) + assert node_id not in tracks.graph.node_ids() inverse.inverse() - assert tracks.graph.has_node(node_id) + assert node_id in tracks.graph.node_ids() assert tracks.get_position(node_id) == position assert tracks.get_node_attr(node_id, "area") == area assert tracks.get_track_id(node_id) == 10 From 567cefbdb8b1bf3c5aeeb39b79c5410d9f642ba4 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Thu, 11 Dec 2025 15:20:16 -0800 Subject: [PATCH 03/44] make compatible with latest tracksdata api --- src/funtracks/actions/add_delete_edge.py | 2 +- src/funtracks/actions/add_delete_node.py | 2 +- .../annotators/_regionprops_annotator.py | 2 +- src/funtracks/annotators/_track_annotator.py | 4 +-- src/funtracks/data_model/tracks.py | 8 +++--- tests/actions/test_action_history.py | 8 +++--- tests/data_model/test_solution_tracks.py | 9 +++---- tests/data_model/test_tracks_controller.py | 26 +++++++++---------- tests/import_export/test_csv_export.py | 2 +- tests/import_export/test_csv_import.py | 24 ++++++++--------- 10 files changed, 43 insertions(+), 44 deletions(-) diff --git a/src/funtracks/actions/add_delete_edge.py b/src/funtracks/actions/add_delete_edge.py index 3482a36a..4376d5b8 100644 --- a/src/funtracks/actions/add_delete_edge.py +++ b/src/funtracks/actions/add_delete_edge.py @@ -61,7 +61,7 @@ def _apply(self) -> None: attrs = self.attributes attrs[td.DEFAULT_ATTR_KEYS.SOLUTION] = 1 - required_attrs = self.tracks.graph.edge_attr_keys + required_attrs = self.tracks.graph.edge_attr_keys() for attr in required_attrs: if attr not in attrs: attrs[attr] = None diff --git a/src/funtracks/actions/add_delete_node.py b/src/funtracks/actions/add_delete_node.py index 3ef64110..5f7284ec 100644 --- a/src/funtracks/actions/add_delete_node.py +++ b/src/funtracks/actions/add_delete_node.py @@ -159,7 +159,7 @@ def _apply(self) -> None: # Position is already set in attrs above # Add nodes to td graph - required_attrs = self.tracks.graph.node_attr_keys.copy() + required_attrs = self.tracks.graph.node_attr_keys().copy() if td.DEFAULT_ATTR_KEYS.NODE_ID in required_attrs: required_attrs.remove(td.DEFAULT_ATTR_KEYS.NODE_ID) if td.DEFAULT_ATTR_KEYS.SOLUTION not in attrs: diff --git a/src/funtracks/annotators/_regionprops_annotator.py b/src/funtracks/annotators/_regionprops_annotator.py index e7f65c63..dec5a22a 100644 --- a/src/funtracks/annotators/_regionprops_annotator.py +++ b/src/funtracks/annotators/_regionprops_annotator.py @@ -181,7 +181,7 @@ def _regionprops_update(self, seg_frame: np.ndarray, feature_keys: list[str]) -> for region in regionprops_extended(seg_frame, spacing=spacing): node = region.label # Skip labels that aren't nodes in the graph (e.g., unselected detections) - if node not in self.tracks.graph: + if node not in self.tracks.graph.node_ids(): continue for key in feature_keys: value = getattr(region, self.regionprops_names[key]) diff --git a/src/funtracks/annotators/_track_annotator.py b/src/funtracks/annotators/_track_annotator.py index f1049690..71e6c876 100644 --- a/src/funtracks/annotators/_track_annotator.py +++ b/src/funtracks/annotators/_track_annotator.py @@ -114,13 +114,13 @@ def __init__( self.max_lineage_id = 0 # Initialize tracklet bookkeeping if track IDs already exist in the graph - if tracks.graph.num_nodes > 0: + if tracks.graph.num_nodes() > 0: max_id, id_to_nodes = self._get_max_id_and_map(self.tracklet_key) self.max_tracklet_id = max_id self.tracklet_id_to_nodes = id_to_nodes # Initialize lineage bookkeeping if lineage IDs already exist - if lineage_key is not None and tracks.graph.num_nodes > 0: + if lineage_key is not None and tracks.graph.num_nodes() > 0: max_id, id_to_nodes = self._get_max_id_and_map(self.lineage_key) self.max_lineage_id = max_id self.lineage_id_to_nodes = id_to_nodes diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index 24db86fd..258ee3cf 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -281,11 +281,11 @@ def _check_existing_feature(self, key: str) -> bool: bool: True if the key is on the first sampled node or there are no nodes, and False if missing from the first node. """ - if self.graph.num_nodes == 0: + if self.graph.num_nodes() == 0: return True # Get a sample node to check which attributes exist - node_attrs = set(self.graph.node_attr_keys) + node_attrs = set(self.graph.node_attr_keys()) return key in node_attrs def _setup_core_computed_features(self) -> None: @@ -617,7 +617,7 @@ def _set_nodes_attr(self, nodes: Iterable[Node], attr: str, values: Iterable[Any self.graph[node][attr] = [value] def get_node_attr(self, node: Node, attr: str, required: bool = False): - if attr not in self.graph.node_attr_keys: + if attr not in self.graph.node_attr_keys(): if required: raise KeyError(attr) return None @@ -652,7 +652,7 @@ def _set_edges_attr(self, edges: Iterable[Edge], attr: str, values: Iterable[Any self.graph.update_edge_attrs(attrs={attr: value}, edge_ids=[edge_id]) def get_edge_attr(self, edge: Edge, attr: str, required: bool = False): - if attr not in self.graph.edge_attr_keys: + if attr not in self.graph.edge_attr_keys(): if required: raise KeyError(attr) return None diff --git a/tests/actions/test_action_history.py b/tests/actions/test_action_history.py index 78e90e9e..88aa160b 100644 --- a/tests/actions/test_action_history.py +++ b/tests/actions/test_action_history.py @@ -23,7 +23,7 @@ def test_action_history(): history.add_new_action(action1) # undo the action assert history.undo() - assert tracks.graph.num_nodes == 0 + assert tracks.graph.num_nodes() == 0 assert len(history.undo_stack) == 1 assert len(history.redo_stack) == 1 assert history._undo_pointer == -1 @@ -33,7 +33,7 @@ def test_action_history(): # redo the action assert history.redo() - assert tracks.graph.num_nodes == 1 + assert tracks.graph.num_nodes() == 1 assert len(history.undo_stack) == 1 assert len(history.redo_stack) == 0 assert history._undo_pointer == 0 @@ -45,7 +45,7 @@ def test_action_history(): 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 + 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 @@ -54,7 +54,7 @@ def test_action_history(): # undo back to after action 1 assert history.undo() assert history.undo() - assert tracks.graph.num_nodes == 1 + assert tracks.graph.num_nodes() == 1 assert len(history.undo_stack) == 3 assert len(history.redo_stack) == 2 diff --git a/tests/data_model/test_solution_tracks.py b/tests/data_model/test_solution_tracks.py index 851e5bca..07504230 100644 --- a/tests/data_model/test_solution_tracks.py +++ b/tests/data_model/test_solution_tracks.py @@ -95,7 +95,7 @@ def test_export_to_csv( with open(temp_file) as f: lines = f.readlines() - assert len(lines) == tracks.graph.num_nodes + 1 # add header + assert len(lines) == tracks.graph.num_nodes() + 1 # add header # Backward compatible format: t, y, x, id, parent_id, track_id header = ["t", "y", "x", "id", "parent_id", "track_id"] @@ -107,8 +107,7 @@ def test_export_to_csv( with open(temp_file) as f: lines = f.readlines() - assert len(lines) == tracks.graph.num_nodes + 1 # add header - + assert len(lines) == tracks.graph.num_nodes() # Backward compatible format: t, z, y, x, id, parent_id, track_id header = ["t", "z", "y", "x", "id", "parent_id", "track_id"] assert lines[0].strip().split(",") == header @@ -147,7 +146,7 @@ def test_export_to_csv_with_display_names( with open(temp_file) as f: lines = f.readlines() - assert len(lines) == tracks.graph.num_nodes + 1 # add header + assert len(lines) == tracks.graph.num_nodes() + 1 # add header # With display names: ID, Parent ID, Time, y, x, Tracklet ID header = ["ID", "Parent ID", "Time", "y", "x", "Tracklet ID"] @@ -160,7 +159,7 @@ def test_export_to_csv_with_display_names( with open(temp_file) as f: lines = f.readlines() - assert len(lines) == tracks.graph.num_nodes + 1 # add header + assert len(lines) == tracks.graph.num_nodes() + 1 # add header # With display names: ID, Parent ID, Time, z, y, x, Tracklet ID header = ["ID", "Parent ID", "Time", "z", "y", "x", "Tracklet ID"] diff --git a/tests/data_model/test_tracks_controller.py b/tests/data_model/test_tracks_controller.py index cbaa2cdc..0ff0e121 100644 --- a/tests/data_model/test_tracks_controller.py +++ b/tests/data_model/test_tracks_controller.py @@ -14,7 +14,7 @@ def test__add_nodes_no_seg(graph_2d_with_computed_features): ) controller = TracksController(tracks) - num_edges = tracks.graph.num_edges + num_edges = tracks.graph.num_edges() # start a new track with multiple nodes attrs = { @@ -30,7 +30,7 @@ def test__add_nodes_no_seg(graph_2d_with_computed_features): assert tracks.get_position(node) == [1, 3] assert tracks.get_track_id(node) == 6 - assert tracks.graph.num_edges == num_edges + 1 # one edge added + assert tracks.graph.num_edges() == num_edges + 1 # one edge added # add nodes to end of existing track attrs = { @@ -76,7 +76,7 @@ def test__add_nodes_with_seg(graph_2d_with_computed_features, segmentation_2d): ) controller = TracksController(tracks) - num_edges = tracks.graph.num_edges + num_edges = tracks.graph.num_edges() new_seg = segmentation_2d.copy() time = 0 @@ -112,7 +112,7 @@ def test__add_nodes_with_seg(graph_2d_with_computed_features, segmentation_2d): assert tracks.get_track_id(node2) == 6 assert np.sum(tracks.segmentation != new_seg) == 0 - assert tracks.graph.num_edges == num_edges + 1 # one edge added + assert tracks.graph.num_edges() == num_edges + 1 # one edge added # add nodes to end of existing track time = 2 @@ -180,13 +180,13 @@ def test__delete_nodes_no_seg(graph_2d_with_computed_features): tracklet_attr="track_id", ) controller = TracksController(tracks) - num_edges = tracks.graph.num_edges + num_edges = tracks.graph.num_edges() # delete unconnected node node = 6 action = controller._delete_nodes([node]) assert node not in tracks.graph.node_ids() - assert tracks.graph.num_edges == num_edges + assert tracks.graph.num_edges() == num_edges action.inverse() # delete end node @@ -229,7 +229,7 @@ def test__delete_nodes_with_seg(graph_2d_with_computed_features, segmentation_2d tracklet_attr="track_id", ) controller = TracksController(tracks) - num_edges = tracks.graph.num_edges + num_edges = tracks.graph.num_edges() # delete unconnected node node = 6 @@ -238,7 +238,7 @@ def test__delete_nodes_with_seg(graph_2d_with_computed_features, segmentation_2d action = controller._delete_nodes([node]) assert node not in tracks.graph.node_ids() assert track_id not in np.unique(tracks.segmentation[time]) - assert tracks.graph.num_edges == num_edges + assert tracks.graph.num_edges() == num_edges action.inverse() # delete end node @@ -294,7 +294,7 @@ def test__add_remove_edges_no_seg(graph_2d_with_computed_features): tracklet_attr="track_id", ) controller = TracksController(tracks) - num_edges = tracks.graph.num_edges + num_edges = tracks.graph.num_edges() # delete continuation edge edge = (3, 4) @@ -302,13 +302,13 @@ def test__add_remove_edges_no_seg(graph_2d_with_computed_features): controller._delete_edges([edge]) assert not tracks.graph.has_edge(*edge) assert tracks.get_track_id(edge[1]) != track_id # relabeled the rest of the track - assert tracks.graph.num_edges == num_edges - 1 + assert tracks.graph.num_edges() == num_edges - 1 # add back in continuation edge controller._add_edges([edge]) assert tracks.graph.has_edge(*edge) assert tracks.get_track_id(edge[1]) == track_id # track id was changed back - assert tracks.graph.num_edges == num_edges + assert tracks.graph.num_edges() == num_edges # delete division edge edge = (1, 3) @@ -317,7 +317,7 @@ def test__add_remove_edges_no_seg(graph_2d_with_computed_features): assert not tracks.graph.has_edge(*edge) assert tracks.get_track_id(edge[1]) == track_id # dont relabel after removal assert tracks.get_track_id(2) == 1 # but do relabel the sibling - assert tracks.graph.num_edges == num_edges - 1 + assert tracks.graph.num_edges() == num_edges - 1 # add back in division edge edge = (1, 3) @@ -326,4 +326,4 @@ def test__add_remove_edges_no_seg(graph_2d_with_computed_features): assert tracks.graph.has_edge(*edge) assert tracks.get_track_id(edge[1]) == track_id # dont relabel after removal assert tracks.get_track_id(2) != 1 # give sibling new id again (not necessarily 2) - assert tracks.graph.num_edges == num_edges + assert tracks.graph.num_edges() == num_edges diff --git a/tests/import_export/test_csv_export.py b/tests/import_export/test_csv_export.py index baddb762..e6ff1fe8 100644 --- a/tests/import_export/test_csv_export.py +++ b/tests/import_export/test_csv_export.py @@ -20,7 +20,7 @@ def test_export_solution_to_csv(get_tracks, tmp_path, ndim, expected_header): with open(temp_file) as f: lines = f.readlines() - assert len(lines) == tracks.graph.num_nodes + 1 # add header + assert len(lines) == tracks.graph.num_nodes() + 1 # add header assert lines[0].strip().split(",") == expected_header # Check first data line (node 1: t=0, pos=[50, 50] or [50, 50, 50], track_id=1) diff --git a/tests/import_export/test_csv_import.py b/tests/import_export/test_csv_import.py index f8d65924..3aa45d68 100644 --- a/tests/import_export/test_csv_import.py +++ b/tests/import_export/test_csv_import.py @@ -43,8 +43,8 @@ def test_import_2d(self, simple_df_2d): tracks = tracks_from_df(simple_df_2d) assert isinstance(tracks, SolutionTracks) - assert tracks.graph.num_nodes == 4 - assert tracks.graph.num_edges == 3 + assert tracks.graph.num_nodes() == 4 + assert tracks.graph.num_edges() == 3 assert tracks.ndim == 3 def test_import_3d(self, df_3d): @@ -52,7 +52,7 @@ def test_import_3d(self, df_3d): tracks = tracks_from_df(df_3d) assert tracks.ndim == 4 - assert tracks.graph.num_nodes == 3 + assert tracks.graph.num_nodes() == 3 # Check z coordinate pos = tracks.get_position(1) assert len(pos) == 3 # z, y, x @@ -143,8 +143,8 @@ def test_single_node(self): tracks = tracks_from_df(df) - assert tracks.graph.num_nodes == 1 - assert tracks.graph.num_edges == 0 + assert tracks.graph.num_nodes() == 1 + assert tracks.graph.num_edges() == 0 def test_multiple_roots(self): """Test multiple independent lineages.""" @@ -160,8 +160,8 @@ def test_multiple_roots(self): tracks = tracks_from_df(df) - assert tracks.graph.num_nodes == 4 - assert tracks.graph.num_edges == 2 + assert tracks.graph.num_nodes() == 4 + assert tracks.graph.num_edges() == 2 # Should have two root nodes roots = [n for n in tracks.graph.nodes() if tracks.graph.in_degree(n) == 0] @@ -181,8 +181,8 @@ def test_division_event(self): tracks = tracks_from_df(df) - assert tracks.graph.num_nodes == 3 - assert tracks.graph.num_edges == 2 + assert tracks.graph.num_nodes() == 3 + assert tracks.graph.num_edges() == 2 # Node 1 should have two children children = list(tracks.graph.successors(1)) @@ -203,8 +203,8 @@ def test_long_track(self): tracks = tracks_from_df(df) - assert tracks.graph.num_nodes == 10 - assert tracks.graph.num_edges == 9 + 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.nodes() if tracks.graph.in_degree(n) == 0] @@ -297,7 +297,7 @@ def test_seg_id_same_as_id(self, simple_df_2d): 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 + assert tracks.graph.num_nodes() == 4 for node_id in tracks.graph.nodes(): assert tracks.get_node_attr(node_id, "seg_id") == node_id From e99768f702a9f7edebd0a9704dfe1da22db2dc6c Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Fri, 12 Dec 2025 11:13:09 -0800 Subject: [PATCH 04/44] more tests passing --- pyproject.toml | 2 +- src/funtracks/data_model/solution_tracks.py | 18 +++++--- src/funtracks/data_model/tracks.py | 9 ++++ .../import_export/internal_format.py | 1 + src/funtracks/user_actions/user_add_node.py | 5 ++- tests/data_model/test_solution_tracks.py | 2 +- tests/data_model/test_tracks.py | 43 ++++++++++--------- 7 files changed, 50 insertions(+), 30 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6ba05696..bc717328 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies =[ "pandas>=2.3.3", "zarr>=2.18,<4", "numcodecs>=0.13,<0.16", - "tracksdata@git+https://github.com/royerlab/tracksdata", + "tracksdata@git+https://github.com/royerlab/tracksdata@daba10830826b3652ed5ee296c3fd83cac52b27a", ] [project.urls] diff --git a/src/funtracks/data_model/solution_tracks.py b/src/funtracks/data_model/solution_tracks.py index a7e66e8f..20239f35 100644 --- a/src/funtracks/data_model/solution_tracks.py +++ b/src/funtracks/data_model/solution_tracks.py @@ -112,11 +112,16 @@ def _get_track_annotator(self) -> TrackAnnotator: def from_tracks(cls, tracks: Tracks): force_recompute = False # Check if all nodes have track_id before trusting existing track IDs - if tracks.features.tracklet_key is not None and any( - value is None - for value in tracks.graph.node_attrs(attr_keys=tracks.features.tracklet_key) + if ( + tracks.features.tracklet_key is not None + and tracks.graph.node_attrs(attr_keys=tracks.features.tracklet_key)[ + tracks.features.tracklet_key + ] + .is_null() + .any() ): force_recompute = True + soln_tracks = cls( tracks.graph, segmentation=tracks.segmentation, @@ -145,7 +150,7 @@ def node_id_to_track_id(self) -> dict[Node, int]: stacklevel=2, ) all_track_ids = self.graph.node_attrs(attr_keys=NodeAttr.TRACK_ID.value) - return dict(zip(self.graph.node_ids(), all_track_ids, strict=True)) + return dict(zip(self.graph.node_ids(), all_track_ids["track_id"], strict=True)) def get_next_track_id(self) -> int: """Return the next available track_id and update max_tracklet_id in TrackAnnotator @@ -226,7 +231,10 @@ def get_track_neighbors( elif self.get_time(cand) > time: succ = cand break - return pred, succ + return ( + int(pred) if pred is not None else None, + int(succ) if succ is not None else None, + ) def has_track_id_at_time(self, track_id: int, time: int) -> bool: """Function to check if a node with given track id exists at given time point. diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index 258ee3cf..df39a7a1 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -330,13 +330,22 @@ def edges(self): return np.array(self.graph.edge_ids()) def in_degree(self, nodes: np.ndarray | None = None) -> np.ndarray: + """Get the in-degree edge_ids of the nodes in the graph.""" if nodes is not None: + # make sure nodes is a numpy array + if not isinstance(nodes, np.ndarray): + nodes = np.array(nodes) + return np.array([self.graph.in_degree(node.item()) for node in nodes]) else: return np.array(self.graph.in_degree()) def out_degree(self, nodes: np.ndarray | None = None) -> np.ndarray: if nodes is not None: + # make sure nodes is a numpy array + if not isinstance(nodes, np.ndarray): + nodes = np.array(nodes) + return np.array([self.graph.out_degree(node.item()) for node in nodes]) else: return np.array(self.graph.out_degree()) diff --git a/src/funtracks/import_export/internal_format.py b/src/funtracks/import_export/internal_format.py index a7ffcb63..3cdedec3 100644 --- a/src/funtracks/import_export/internal_format.py +++ b/src/funtracks/import_export/internal_format.py @@ -43,6 +43,7 @@ def _save_graph(tracks: Tracks, directory: Path) -> None: tracks (Tracks): the tracks to save the graph of directory (Path): The directory in which to save the graph file. """ + # TODO Teun: change to geff! graph_file = directory / GRAPH_FILE graph_data = nx.node_link_data(tracks.graph, edges="links") diff --git a/src/funtracks/user_actions/user_add_node.py b/src/funtracks/user_actions/user_add_node.py index 48871a1b..5246ba10 100644 --- a/src/funtracks/user_actions/user_add_node.py +++ b/src/funtracks/user_actions/user_add_node.py @@ -91,7 +91,7 @@ def __init__( pred, succ = self.tracks.get_track_neighbors(track_id, time) # check if you are adding a node to a track that divided previously - if pred is not None and self.tracks.graph.out_degree(pred) == 2: + if pred is not None and self.tracks.graph.out_degree(int(pred)) == 2: if not force: raise InvalidActionError( "Cannot add node here - upstream division event detected.", @@ -107,7 +107,8 @@ def __init__( # downstream elif succ is not None: # check pred of succ - pred_of_succ = next(self.tracks.graph.predecessors(succ), None) + preds = self.tracks.graph.predecessors(succ) + pred_of_succ = preds[0] if preds else None if ( pred_of_succ is not None and self.tracks.graph.out_degree(pred_of_succ) == 2 diff --git a/tests/data_model/test_solution_tracks.py b/tests/data_model/test_solution_tracks.py index 07504230..5b66579c 100644 --- a/tests/data_model/test_solution_tracks.py +++ b/tests/data_model/test_solution_tracks.py @@ -107,7 +107,7 @@ def test_export_to_csv( with open(temp_file) as f: lines = f.readlines() - assert len(lines) == tracks.graph.num_nodes() + assert len(lines) == tracks.graph.num_nodes() + 1 # add header # Backward compatible format: t, z, y, x, id, parent_id, track_id header = ["t", "z", "y", "x", "id", "parent_id", "track_id"] assert lines[0].strip().split(",") == header diff --git a/tests/data_model/test_tracks.py b/tests/data_model/test_tracks.py index 4852d7c0..70e31459 100644 --- a/tests/data_model/test_tracks.py +++ b/tests/data_model/test_tracks.py @@ -1,8 +1,6 @@ import networkx as nx import numpy as np import pytest -from networkx.utils import graphs_equal -from numpy.testing import assert_array_almost_equal from funtracks.data_model import Tracks from funtracks.utils.tracksdata_utils import ( @@ -101,25 +99,28 @@ def test_pixels_and_seg_id(graph_3d_with_computed_features, segmentation_3d): def test_save_load_delete(tmp_path, graph_2d_with_computed_features, segmentation_2d): - tracks_dir = tmp_path / "tracks" - tracks = Tracks(graph_2d_with_computed_features, segmentation_2d, **track_attrs) - with pytest.warns( - DeprecationWarning, - match="`Tracks.save` is deprecated and will be removed in 2.0", - ): - tracks.save(tracks_dir) - with pytest.warns( - DeprecationWarning, - match="`Tracks.load` is deprecated and will be removed in 2.0", - ): - loaded = Tracks.load(tracks_dir) - assert graphs_equal(loaded.graph, tracks.graph) - assert_array_almost_equal(loaded.segmentation, tracks.segmentation) - with pytest.warns( - DeprecationWarning, - match="`Tracks.delete` is deprecated and will be removed in 2.0", - ): - Tracks.delete(tracks_dir) + # TODO Teun: re-enable when Tracks.save/load/delete use geff! + pass + + # tracks_dir = tmp_path / "tracks" + # tracks = Tracks(graph_2d_with_computed_features, segmentation_2d, **track_attrs) + # with pytest.warns( + # DeprecationWarning, + # match="`Tracks.save` is deprecated and will be removed in 2.0", + # ): + # tracks.save(tracks_dir) + # with pytest.warns( + # DeprecationWarning, + # match="`Tracks.load` is deprecated and will be removed in 2.0", + # ): + # loaded = Tracks.load(tracks_dir) + # assert graphs_equal(loaded.graph, tracks.graph) + # assert_array_almost_equal(loaded.segmentation, tracks.segmentation) + # with pytest.warns( + # DeprecationWarning, + # match="`Tracks.delete` is deprecated and will be removed in 2.0", + # ): + # Tracks.delete(tracks_dir) def test_nodes_edges(graph_2d_with_computed_features): From 34bf99b6af8a29e9ad9351b866c820c5095676c4 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Fri, 12 Dec 2025 14:45:22 -0800 Subject: [PATCH 05/44] fixed minor bugs resulting from merge with v2-branch --- src/funtracks/annotators/_track_annotator.py | 5 ++--- src/funtracks/data_model/tracks.py | 9 +++++---- tests/data_model/test_tracks.py | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/funtracks/annotators/_track_annotator.py b/src/funtracks/annotators/_track_annotator.py index 71e6c876..8b074b0b 100644 --- a/src/funtracks/annotators/_track_annotator.py +++ b/src/funtracks/annotators/_track_annotator.py @@ -9,7 +9,6 @@ from funtracks.actions import AddNode, DeleteNode, UpdateTrackID from funtracks.data_model import SolutionTracks -from funtracks.data_model.graph_attributes import NodeAttr from funtracks.features import LineageID, TrackletID from funtracks.utils.tracksdata_utils import td_graph_edge_list @@ -137,7 +136,7 @@ def _get_max_id_and_map(self, key: str) -> tuple[int, dict[int, list[int]]]: """ id_to_nodes = defaultdict(list) max_id = 0 - for node in self.tracks.nodes(): + for node in self.tracks.graph.node_ids(): _id: int = self.tracks.get_node_attr(node, key) if _id is None: continue @@ -244,7 +243,7 @@ def _assign_tracklet_ids(self) -> None: node_ids_internal = list(tracklet) node_ids_external = [graph_copy.node_ids()[nid] for nid in node_ids_internal] self.tracks.graph.update_node_attrs( - attrs={NodeAttr.TRACK_ID.value: [track_id] * len(node_ids_external)}, + attrs={self.tracks.features.tracklet_key: [track_id] * len(node_ids_external)}, node_ids=node_ids_external, ) self.tracks.track_id_to_node[track_id] = node_ids_external diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index 1907f1ea..e883a3d7 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -485,10 +485,11 @@ def _set_nodes_attr(self, nodes: Iterable[Node], attr: str, values: Iterable[Any self.graph[node][attr] = [value] def get_node_attr(self, node: Node, attr: str, required: bool = False): - if required: - return self.graph.nodes[node][attr] - else: - return self.graph.nodes[node].get(attr, None) + if attr not in self.graph.node_attr_keys(): + if required: + raise KeyError(attr) + return None + return self.graph[int(node)][attr] def get_nodes_attr(self, nodes: Iterable[Node], attr: str, required: bool = False): return [self.get_node_attr(node, attr, required=required) for node in nodes] diff --git a/tests/data_model/test_tracks.py b/tests/data_model/test_tracks.py index 294da9d6..235f8b61 100644 --- a/tests/data_model/test_tracks.py +++ b/tests/data_model/test_tracks.py @@ -48,9 +48,9 @@ def test_create_tracks(graph_3d_with_computed_features: nx.DiGraph, segmentation assert tracks.get_positions([1]).tolist() == [[50, 50, 50]] assert tracks.get_time(1) == 0 assert tracks.get_positions([1], incl_time=True).tolist() == [[0, 50, 50, 50]] - tracks._set_node_attr(1, tracks.features.time_key, 1) - # TODO: Explicitly block doing this - assert tracks.get_positions([1], incl_time=True).tolist() == [[1, 50, 50, 50]] + # TODO: Explicitly block doing setting the time + # tracks._set_node_attr(1, tracks.features.time_key, 1) + # assert tracks.get_positions([1], incl_time=True).tolist() == [[1, 50, 50, 50]] tracks_wrong_attr = Tracks( graph=graph_3d_with_computed_features, From 6410e871819db6175422e164cbba0b39e0e30bce Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Fri, 12 Dec 2025 14:48:26 -0800 Subject: [PATCH 06/44] fixed due to merge with v2 --- src/funtracks/annotators/_track_annotator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/funtracks/annotators/_track_annotator.py b/src/funtracks/annotators/_track_annotator.py index 8b074b0b..ffc6e45f 100644 --- a/src/funtracks/annotators/_track_annotator.py +++ b/src/funtracks/annotators/_track_annotator.py @@ -243,7 +243,9 @@ def _assign_tracklet_ids(self) -> None: node_ids_internal = list(tracklet) node_ids_external = [graph_copy.node_ids()[nid] for nid in node_ids_internal] self.tracks.graph.update_node_attrs( - attrs={self.tracks.features.tracklet_key: [track_id] * len(node_ids_external)}, + attrs={ + self.tracks.features.tracklet_key: [track_id] * len(node_ids_external) + }, node_ids=node_ids_external, ) self.tracks.track_id_to_node[track_id] = node_ids_external From 65791b064ec3535e7837c5bdce1b359b31a43639 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Fri, 12 Dec 2025 15:30:56 -0800 Subject: [PATCH 07/44] user_actions tests passing --- src/funtracks/user_actions/user_add_edge.py | 4 +++- tests/user_actions/test_user_actions_force.py | 15 ++++++++------- tests/user_actions/test_user_add_delete_edge.py | 13 +++++++++++-- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/funtracks/user_actions/user_add_edge.py b/src/funtracks/user_actions/user_add_edge.py index 2d82fc9d..b94391c5 100644 --- a/src/funtracks/user_actions/user_add_edge.py +++ b/src/funtracks/user_actions/user_add_edge.py @@ -53,7 +53,9 @@ def __init__( forceable=True, ) else: - merge_edge = list(self.tracks.graph.in_edges(target))[0] + # merge_edge = list(self.tracks.graph.in_edges(target))[0] + pred = next(iter(self.tracks.graph.predecessors(target))) + merge_edge = (pred, target) warnings.warn( f"Removing edge {merge_edge} to add new edge without merging.", stacklevel=2, diff --git a/tests/user_actions/test_user_actions_force.py b/tests/user_actions/test_user_actions_force.py index bae5d5f4..a519318a 100644 --- a/tests/user_actions/test_user_actions_force.py +++ b/tests/user_actions/test_user_actions_force.py @@ -1,6 +1,7 @@ import pytest from funtracks.user_actions import UserAddNode +from funtracks.utils.tracksdata_utils import td_graph_edge_list def test_user_force_add_downstream(get_tracks): @@ -13,9 +14,9 @@ def test_user_force_add_downstream(get_tracks): 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.edges - assert (1, 3) not in tracks.graph.edges - assert (1, 7) in tracks.graph.edges + assert [1, 2] not in td_graph_edge_list(tracks.graph) + assert [1, 3] not in td_graph_edge_list(tracks.graph) + assert [1, 7] in td_graph_edge_list(tracks.graph) def test_user_force_add_upstream(get_tracks): @@ -28,9 +29,9 @@ def test_user_force_add_upstream(get_tracks): 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.edges # still there - assert (1, 3) not in tracks.graph.edges # should be removed - assert (7, 3) in tracks.graph.edges # new forced edge + assert [1, 2] in td_graph_edge_list(tracks.graph) # still there + assert [1, 3] not in td_graph_edge_list(tracks.graph) # should be removed + assert [7, 3] in td_graph_edge_list(tracks.graph) # new forced edge def test_auto_assign_new_track_id(get_tracks): @@ -44,5 +45,5 @@ def test_auto_assign_new_track_id(get_tracks): attrs = {"t": 1, "track_id": 2, "pos": [3, 4]} # combination exists already UserAddNode(tracks, node=7, attributes=attrs) - assert 7 in tracks.graph.nodes + assert 7 in tracks.graph.node_ids() assert tracks.get_track_id(7) == 6 # new assigned track id diff --git a/tests/user_actions/test_user_add_delete_edge.py b/tests/user_actions/test_user_add_delete_edge.py index 852558e0..84f4c5e1 100644 --- a/tests/user_actions/test_user_add_delete_edge.py +++ b/tests/user_actions/test_user_add_delete_edge.py @@ -1,4 +1,5 @@ import pytest +import tracksdata as td from funtracks.exceptions import InvalidActionError from funtracks.user_actions import UserAddEdge, UserDeleteEdge @@ -123,8 +124,16 @@ def test_delete_missing_edge(get_tracks): def test_delete_edge_triple_div(get_tracks): tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) - tracks.graph.add_edge(1, 6) + attrs = {} + attrs[td.DEFAULT_ATTR_KEYS.SOLUTION] = 1 + 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" + InvalidActionError, match="Expected degree of 0 or 1 after removing edge, got 2" ): UserDeleteEdge(tracks, (1, 6)) From bd45a60025c1497d76b740425799bc7419033686 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Mon, 15 Dec 2025 15:56:08 -0800 Subject: [PATCH 08/44] update tracks.add_feature to work on any feature (nodes/edges) --- src/funtracks/annotators/_compute_ious.py | 2 +- src/funtracks/annotators/_edge_annotator.py | 5 ++- src/funtracks/data_model/tracks.py | 31 ++++--------- src/funtracks/import_export/_utils.py | 2 +- src/funtracks/user_actions/user_add_edge.py | 1 - tests/actions/test_add_delete_edge.py | 2 +- tests/actions/test_add_delete_nodes.py | 2 +- tests/actions/test_update_node_attrs.py | 2 +- tests/annotators/test_annotator_registry.py | 50 ++++++++++++++------- 9 files changed, 52 insertions(+), 45 deletions(-) diff --git a/src/funtracks/annotators/_compute_ious.py b/src/funtracks/annotators/_compute_ious.py index 7f848c2f..c6522561 100644 --- a/src/funtracks/annotators/_compute_ious.py +++ b/src/funtracks/annotators/_compute_ious.py @@ -31,5 +31,5 @@ def _compute_ious(frame1: np.ndarray, frame2: np.ndarray) -> list[tuple[int, int intersection = counts[index] id1, id2 = pair union = frame1_label_sizes[id1] + frame2_label_sizes[id2] - intersection - ious.append((id1, id2, intersection / union)) + ious.append((int(id1), int(id2), intersection / union)) return ious diff --git a/src/funtracks/annotators/_edge_annotator.py b/src/funtracks/annotators/_edge_annotator.py index ddfb1179..256ba161 100644 --- a/src/funtracks/annotators/_edge_annotator.py +++ b/src/funtracks/annotators/_edge_annotator.py @@ -91,7 +91,10 @@ def compute(self, feature_keys: list[str] | None = None) -> None: for t in range(seg.shape[0] - 1): nodes_in_t = nodes_by_frame[t] - edges = list(self.tracks.graph.out_edges(nodes_in_t)) + edges = [] + for node in nodes_in_t: + for succ in self.tracks.graph.successors(node): + edges.append((node, succ)) self._iou_update(edges, seg[t], seg[t + 1]) def _iou_update( diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index e883a3d7..aa17e474 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -293,7 +293,7 @@ def _setup_core_computed_features(self) -> None: # Add to FeatureDict if not already there if key not in self.features: feature, _ = self.annotators.all_features[key] - self.features[key] = feature + self.add_feature(key, feature) self.annotators.activate_features([key]) else: # enable it (compute it) @@ -554,7 +554,7 @@ def enable_features(self, feature_keys: list[str], recompute: bool = True) -> No for key in feature_keys: if key not in self.features: feature, _ = self.annotators.all_features[key] - self.features[key] = feature + self.add_feature(key, feature) # Compute the features if requested if recompute: @@ -579,26 +579,8 @@ def disable_features(self, feature_keys: list[str]) -> None: if key in self.features: del self.features[key] - def add_node_feature(self, key: str, feature: Feature) -> None: - """Add a node feature to the features dictionary and perform graph operations. - - TODO Teun: add_feature and auto-detect node or edge from Feature dict - - This is the preferred way to add new features as it ensures both the - features dictionary is updated and any necessary graph operations are performed. - - Args: - key: The key for the new feature - feature: The Feature object to add - """ - # Add to the features dictionary - self.features[key] = feature - - # Perform custom graph operations when a feature is added - self.graph.add_node_attr_key(key, default_value=feature["default_value"]) - - def add_edge_feature(self, key: str, feature: Feature) -> None: - """Add an edge feature to the features dictionary and perform graph operations. + def add_feature(self, key: str, feature: Feature) -> None: + """Add a feature to the features dictionary and perform graph operations. This is the preferred way to add new features as it ensures both the features dictionary is updated and any necessary graph operations are performed. @@ -611,4 +593,7 @@ def add_edge_feature(self, key: str, feature: Feature) -> None: self.features[key] = feature # Perform custom graph operations when a feature is added - self.graph.add_edge_attr_key(key, default_value=feature["default_value"]) + if feature["feature_type"] == "node": + self.graph.add_node_attr_key(key, default_value=feature["default_value"]) + elif feature["feature_type"] == "edge": + self.graph.add_edge_attr_key(key, default_value=feature["default_value"]) diff --git a/src/funtracks/import_export/_utils.py b/src/funtracks/import_export/_utils.py index 3d4ab5b1..17f74506 100644 --- a/src/funtracks/import_export/_utils.py +++ b/src/funtracks/import_export/_utils.py @@ -109,7 +109,7 @@ def rename_feature(tracks: Tracks, old_key: str, new_key: str) -> None: # Register it to the feature dictionary, removing old key if necessary if old_key in tracks.features: tracks.features.pop(old_key) - tracks.add_node_feature(new_key, feature_dict) + tracks.add_feature(new_key, feature_dict) # Update FeatureDict special key attributes if we renamed position or tracklet if tracks.features.position_key == old_key: diff --git a/src/funtracks/user_actions/user_add_edge.py b/src/funtracks/user_actions/user_add_edge.py index b94391c5..2e96aa0f 100644 --- a/src/funtracks/user_actions/user_add_edge.py +++ b/src/funtracks/user_actions/user_add_edge.py @@ -53,7 +53,6 @@ def __init__( forceable=True, ) else: - # merge_edge = list(self.tracks.graph.in_edges(target))[0] pred = next(iter(self.tracks.graph.predecessors(target))) merge_edge = (pred, target) warnings.warn( diff --git a/tests/actions/test_add_delete_edge.py b/tests/actions/test_add_delete_edge.py index 3dade525..39dae7ad 100644 --- a/tests/actions/test_add_delete_edge.py +++ b/tests/actions/test_add_delete_edge.py @@ -131,7 +131,7 @@ def test_custom_edge_attributes_preserved(get_tracks, ndim, with_seg): ), } for key, feature in custom_features.items(): - tracks.add_edge_feature(key, feature) + tracks.add_feature(key, feature) # Define custom edge attributes custom_attrs = { diff --git a/tests/actions/test_add_delete_nodes.py b/tests/actions/test_add_delete_nodes.py index 6d4eb68c..4b658c37 100644 --- a/tests/actions/test_add_delete_nodes.py +++ b/tests/actions/test_add_delete_nodes.py @@ -149,7 +149,7 @@ def test_custom_attributes_preserved(get_tracks, ndim, with_seg): ), } for key, feature in custom_features.items(): - tracks.add_node_feature(key, feature) + tracks.add_feature(key, feature) # Define attributes including custom ones custom_attrs = { diff --git a/tests/actions/test_update_node_attrs.py b/tests/actions/test_update_node_attrs.py index 6084a4d4..b371cb61 100644 --- a/tests/actions/test_update_node_attrs.py +++ b/tests/actions/test_update_node_attrs.py @@ -19,7 +19,7 @@ def test_update_node_attrs(get_tracks, ndim): required=False, default_value=None, ) - tracks.add_node_feature("score", new_feature) + tracks.add_feature("score", new_feature) action = UpdateNodeAttrs(tracks, node, {"score": 1.0}) assert tracks.get_node_attr(node, "score") == 1.0 diff --git a/tests/annotators/test_annotator_registry.py b/tests/annotators/test_annotator_registry.py index 46f5be6d..2a002b5c 100644 --- a/tests/annotators/test_annotator_registry.py +++ b/tests/annotators/test_annotator_registry.py @@ -6,10 +6,17 @@ track_attrs = {"time_attr": "t", "tracklet_attr": "track_id"} -def test_annotator_registry_init_with_segmentation(graph_clean, segmentation_2d): +def test_annotator_registry_init_with_segmentation( + graph_2d_with_computed_features, segmentation_2d +): """Test AnnotatorRegistry initializes regionprops and edge annotators with segmentation.""" - tracks = Tracks(graph_clean, segmentation=segmentation_2d, ndim=3, **track_attrs) + tracks = Tracks( + graph_2d_with_computed_features, + segmentation=segmentation_2d, + ndim=3, + **track_attrs, + ) annotator_types = [type(ann) for ann in tracks.annotators] assert RegionpropsAnnotator in annotator_types @@ -27,11 +34,16 @@ def test_annotator_registry_init_without_segmentation(graph_2d_with_position): assert TrackAnnotator not in annotator_types -def test_annotator_registry_init_solution_tracks(graph_clean, segmentation_2d): +def test_annotator_registry_init_solution_tracks( + graph_2d_with_computed_features, segmentation_2d +): """Test AnnotatorRegistry creates all annotators for SolutionTracks with segmentation.""" tracks = SolutionTracks( - graph_clean, segmentation=segmentation_2d, ndim=3, **track_attrs + graph_2d_with_computed_features, + segmentation=segmentation_2d, + ndim=3, + **track_attrs, ) annotator_types = [type(ann) for ann in tracks.annotators] @@ -40,18 +52,23 @@ def test_annotator_registry_init_solution_tracks(graph_clean, segmentation_2d): assert TrackAnnotator in annotator_types -def test_enable_disable_features(graph_clean, segmentation_2d): - tracks = Tracks(graph_clean, segmentation=segmentation_2d, ndim=3, **track_attrs) +def test_enable_disable_features(graph_2d_with_computed_features, segmentation_2d): + tracks = Tracks( + graph_2d_with_computed_features, + segmentation=segmentation_2d, + ndim=3, + **track_attrs, + ) - nodes = list(tracks.graph.nodes()) - edges = list(tracks.graph.edges()) + nodes = list(tracks.graph.node_ids()) + edges = list(tracks.graph.edge_ids()) # Core features (time, pos, area) should be in tracks.features and computed assert "pos" in tracks.features assert "t" in tracks.features assert "area" in tracks.features # Core feature for backward compatibility - assert tracks.graph.nodes[nodes[0]].get("pos") is not None - assert tracks.graph.nodes[nodes[0]].get("area") is not None + assert tracks.graph[nodes[0]]["pos"] is not None + assert tracks.graph[nodes[0]]["area"] is not None # Other features should NOT be in tracks.features initially assert "iou" not in tracks.features @@ -65,9 +82,9 @@ def test_enable_disable_features(graph_clean, segmentation_2d): assert "circularity" in tracks.features # Verify values are actually computed on the graph - assert tracks.graph.nodes[nodes[0]].get("circularity") is not None + assert tracks.graph[nodes[0]]["circularity"] is not None if edges: - assert tracks.graph.edges[edges[0]].get("iou") is not None + assert None not in tracks.graph.edge_attrs()["iou"].to_list() # Disable one feature tracks.disable_features(["area"]) @@ -79,7 +96,7 @@ def test_enable_disable_features(graph_clean, segmentation_2d): assert "circularity" in tracks.features # Values still exist on the graph (disabling doesn't erase computed values) - assert tracks.graph.nodes[nodes[0]].get("area") is not None + assert tracks.graph[1]["area"] is not None # Disable the remaining enabled features tracks.disable_features(["pos", "iou", "circularity"]) @@ -88,10 +105,13 @@ def test_enable_disable_features(graph_clean, segmentation_2d): assert "circularity" not in tracks.features -def test_get_available_features(graph_clean, segmentation_2d): +def test_get_available_features(graph_2d_with_computed_features, segmentation_2d): """Test get_available_features returns all features from all annotators.""" tracks = SolutionTracks( - graph_clean, segmentation=segmentation_2d, ndim=3, **track_attrs + graph_2d_with_computed_features, + segmentation=segmentation_2d, + ndim=3, + **track_attrs, ) available = tracks.get_available_features() From fa4965bb22bd2c7b4ef0c94293ad60abec2d9ddd Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Mon, 15 Dec 2025 16:20:06 -0800 Subject: [PATCH 09/44] check if graph.column exists when adding feature --- src/funtracks/data_model/tracks.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index 93785072..c29dbfd0 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -292,7 +292,7 @@ def _setup_core_computed_features(self) -> None: # Add to FeatureDict if not already there if key not in self.features: feature, _ = self.annotators.all_features[key] - self.add_feature(key, feature) + self.features[key] = feature self.annotators.activate_features([key]) else: # enable it (compute it) @@ -592,7 +592,7 @@ def add_feature(self, key: str, feature: Feature) -> None: self.features[key] = feature # Perform custom graph operations when a feature is added - if feature["feature_type"] == "node": + if feature["feature_type"] == "node" and key not in self.graph.node_attr_keys(): self.graph.add_node_attr_key(key, default_value=feature["default_value"]) - elif feature["feature_type"] == "edge": + elif feature["feature_type"] == "edge" and key not in self.graph.edge_attr_keys(): self.graph.add_edge_attr_key(key, default_value=feature["default_value"]) From 1c126ff70f9ead6d2cf35db2e313a77e4eea9201 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Mon, 15 Dec 2025 22:37:26 -0800 Subject: [PATCH 10/44] working towards fixing from_tracks_df --- .../import_export/_tracks_builder.py | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/src/funtracks/import_export/_tracks_builder.py b/src/funtracks/import_export/_tracks_builder.py index 92506a44..42f0b51b 100644 --- a/src/funtracks/import_export/_tracks_builder.py +++ b/src/funtracks/import_export/_tracks_builder.py @@ -11,9 +11,9 @@ from pathlib import Path from typing import TYPE_CHECKING, Literal -import geff import networkx as nx import numpy as np +import tracksdata as td from geff._typing import InMemoryGeff from funtracks.data_model.solution_tracks import SolutionTracks @@ -38,6 +38,9 @@ validate_node_name_map, validate_spatial_dims, ) +from funtracks.utils.tracksdata_utils import ( + create_empty_graphview_graph, +) if TYPE_CHECKING: import pandas as pd @@ -396,7 +399,45 @@ def construct_graph(self) -> nx.DiGraph: """ if self.in_memory_geff is None: raise ValueError("No data loaded. Call load_source() first.") - return geff.construct(**self.in_memory_geff) + + graph = create_empty_graphview_graph( + with_pos=True, + with_area="seg_id" in self.in_memory_geff["node_props"], + with_iou="iou" in self.in_memory_geff["edge_props"], + database=":memory:", + ) + + node_ids = [int(i) for i in self.in_memory_geff["node_ids"]] + node_attrs = [] + for idx in range(len(self.in_memory_geff["node_ids"])): + node_attr = {} + node_attr[td.DEFAULT_ATTR_KEYS.SOLUTION] = 1 # Default solution value + for key, prop in self.in_memory_geff["node_props"].items(): + if key == self.TIME_ATTR: + key = "t" + value = prop["values"][idx] + if prop.get("missing") is not None and prop["missing"][idx]: + value = None + node_attr[key] = value + node_attrs.append(node_attr) + + edge_attrs = [] + for idx in range(len(self.in_memory_geff["edge_ids"])): + edge_attr = {} + edge_attr["source_id"] = int(self.in_memory_geff["edge_ids"][idx][0]) + edge_attr["target_id"] = int(self.in_memory_geff["edge_ids"][idx][1]) + edge_attr[td.DEFAULT_ATTR_KEYS.SOLUTION] = 1 # Default solution value + for key, prop in self.in_memory_geff["edge_props"].items(): + value = prop["values"][idx] + if prop.get("missing") is not None and prop["missing"][idx]: + value = None + edge_attr[key] = value + edge_attrs.append(edge_attr) + + graph.bulk_add_nodes(nodes=node_attrs, indices=node_ids) + graph.bulk_add_edges(edge_attrs) + + return graph def handle_segmentation( self, @@ -463,8 +504,7 @@ def handle_segmentation( return seg_array.compute(), scale # Relabel segmentation: seg_id -> node_id - time_attr = "time" - time_values = node_props[time_attr]["values"] + time_values = node_props[self.TIME_ATTR]["values"] new_segmentation = relabel_segmentation( seg_array, graph, node_ids, seg_ids, time_values ) From d4d17cc040b36564bfc9be6c24fd3118a1823979 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Wed, 17 Dec 2025 11:53:14 -0800 Subject: [PATCH 11/44] working on geff tests etc. --- src/funtracks/annotators/_track_annotator.py | 4 +- .../import_export/_import_segmentation.py | 20 +- .../import_export/_tracks_builder.py | 60 ++++-- src/funtracks/import_export/_validation.py | 16 +- src/funtracks/import_export/csv/_import.py | 1 + src/funtracks/import_export/geff/_export.py | 64 +++++-- src/funtracks/import_export/geff/_import.py | 1 + src/funtracks/utils/tracksdata_utils.py | 179 +++++++++++++++--- tests/actions/test_action_history.py | 3 +- tests/actions/test_add_delete_nodes.py | 9 +- tests/conftest.py | 20 +- tests/data_model/test_solution_tracks.py | 3 +- tests/import_export/test_csv_import.py | 16 +- tests/import_export/test_export_to_geff.py | 21 +- tests/import_export/test_import_from_geff.py | 38 ++-- .../import_export/test_import_segmentation.py | 40 ++-- 16 files changed, 357 insertions(+), 138 deletions(-) diff --git a/src/funtracks/annotators/_track_annotator.py b/src/funtracks/annotators/_track_annotator.py index ffc6e45f..3c6cc26e 100644 --- a/src/funtracks/annotators/_track_annotator.py +++ b/src/funtracks/annotators/_track_annotator.py @@ -248,9 +248,9 @@ def _assign_tracklet_ids(self) -> None: }, node_ids=node_ids_external, ) - self.tracks.track_id_to_node[track_id] = node_ids_external + self.tracklet_id_to_nodes[track_id] = node_ids_external track_id += 1 - self.max_track_id = track_id - 1 + self.max_tracklet_id = track_id - 1 def update(self, action: BasicAction) -> None: """Update track-level features based on the action. diff --git a/src/funtracks/import_export/_import_segmentation.py b/src/funtracks/import_export/_import_segmentation.py index ef66d048..5b6b6b72 100644 --- a/src/funtracks/import_export/_import_segmentation.py +++ b/src/funtracks/import_export/_import_segmentation.py @@ -10,10 +10,11 @@ from typing import TYPE_CHECKING import dask.array as da -import networkx as nx import numpy as np +import tracksdata as td from funtracks.import_export.magic_imread import magic_imread +from funtracks.utils.tracksdata_utils import td_relabel_nodes if TYPE_CHECKING: from numpy.typing import ArrayLike @@ -45,11 +46,11 @@ def load_segmentation(segmentation: Path | np.ndarray | da.Array) -> da.Array: def relabel_segmentation( seg_array: da.Array | np.ndarray, - graph: nx.DiGraph, + graph: td.graph.GraphView, node_ids: ArrayLike, seg_ids: ArrayLike, time_values: ArrayLike, -) -> np.ndarray: +) -> tuple[np.ndarray, td.graph.GraphView]: """Relabel segmentation from seg_id to node_id. Handles the case where node_id 0 exists by offsetting all node IDs by 1, @@ -57,13 +58,14 @@ def relabel_segmentation( Args: seg_array: Segmentation array (dask or numpy) - graph: NetworkX graph (modified in-place if node_id 0 exists) + graph: tracksdata GraphView (will be relabeled if node_id 0 exists) node_ids: Array of node IDs seg_ids: Array of segmentation IDs corresponding to each node time_values: Array of time values for each node Returns: - Relabeled segmentation as numpy array with dtype uint64 + Tuple of (relabeled segmentation as numpy array with dtype uint64, + graph (potentially relabeled if node_id 0 existed)) """ # Convert to numpy arrays for processing node_ids = np.asarray(node_ids) @@ -77,8 +79,10 @@ def relabel_segmentation( # in segmentation arrays. We also need to relabel the graph nodes. offset = 1 if 0 in node_ids else 0 if offset: - mapping = {old_id: old_id + offset for old_id in graph.nodes()} - nx.relabel_nodes(graph, mapping, copy=False) + mapping = {old_id: old_id + offset for old_id in graph.node_ids()} + # nx.relabel_nodes modified graph in-place, but td_relabel_nodes returns new graph + # Note: This modifies the graph reference but caller must handle reassignment + graph = td_relabel_nodes(graph, mapping) # Update node_ids array to match node_ids = node_ids + offset @@ -98,7 +102,7 @@ def relabel_segmentation( for seg_id, node_id in seg_to_node.items(): new_segmentation[t][computed_seg[t] == seg_id] = node_id - return new_segmentation + return new_segmentation, graph # TODO: export segmentation with check to relabel to track_id diff --git a/src/funtracks/import_export/_tracks_builder.py b/src/funtracks/import_export/_tracks_builder.py index 42f0b51b..0bdca09f 100644 --- a/src/funtracks/import_export/_tracks_builder.py +++ b/src/funtracks/import_export/_tracks_builder.py @@ -386,7 +386,9 @@ def validate(self) -> None: # Validate graph structure and optional properties validate_in_memory_geff(self.in_memory_geff) - def construct_graph(self) -> nx.DiGraph: + def construct_graph( + self, node_name_map: dict[str, str | list[str]] | None = None + ) -> td.graph.GraphView: """Construct NetworkX graph from validated InMemoryGeff data. Common logic shared across all formats. @@ -400,10 +402,20 @@ def construct_graph(self) -> nx.DiGraph: if self.in_memory_geff is None: raise ValueError("No data loaded. Call load_source() first.") + if node_name_map is not None: + node_attributes = list(self.in_memory_geff["node_props"].keys()) + node_default_values = [ + 0.0 + if key in node_name_map and isinstance(node_name_map[key], str) + else None + for key in node_attributes + ] graph = create_empty_graphview_graph( - with_pos=True, - with_area="seg_id" in self.in_memory_geff["node_props"], - with_iou="iou" in self.in_memory_geff["edge_props"], + node_attributes=list(self.in_memory_geff["node_props"].keys()), + edge_attributes=list(self.in_memory_geff["edge_props"].keys()), + node_default_values=node_default_values + if node_name_map is not None + else None, database=":memory:", ) @@ -411,14 +423,19 @@ def construct_graph(self) -> nx.DiGraph: node_attrs = [] for idx in range(len(self.in_memory_geff["node_ids"])): node_attr = {} - node_attr[td.DEFAULT_ATTR_KEYS.SOLUTION] = 1 # Default solution value + node_attr[td.DEFAULT_ATTR_KEYS.SOLUTION] = 1 # Add default solution value for key, prop in self.in_memory_geff["node_props"].items(): + # force time key to be "t" in graph if key == self.TIME_ATTR: key = "t" value = prop["values"][idx] + # set missing attribute to None if prop.get("missing") is not None and prop["missing"][idx]: value = None node_attr[key] = value + for key in graph.node_attr_keys(): + if key not in node_attr: + node_attr[key] = None # type: ignore[assignment] node_attrs.append(node_attr) edge_attrs = [] @@ -432,11 +449,17 @@ def construct_graph(self) -> nx.DiGraph: if prop.get("missing") is not None and prop["missing"][idx]: value = None edge_attr[key] = value + for key in graph.edge_attr_keys(): + if key not in edge_attr: + edge_attr[key] = None # type: ignore[assignment] edge_attrs.append(edge_attr) graph.bulk_add_nodes(nodes=node_attrs, indices=node_ids) graph.bulk_add_edges(edge_attrs) + if self.TIME_ATTR != "t": + graph.remove_node_attr_key(self.TIME_ATTR) + return graph def handle_segmentation( @@ -444,7 +467,7 @@ def handle_segmentation( graph: nx.DiGraph, segmentation: Path | np.ndarray | None, scale: list[float] | None, - ) -> tuple[np.ndarray | None, list[float] | None]: + ) -> tuple[np.ndarray | None, list[float] | None, nx.DiGraph]: """Load, validate, and optionally relabel segmentation. Common logic shared across all formats. @@ -455,13 +478,14 @@ def handle_segmentation( scale: Spatial scale for coordinate transformation Returns: - Tuple of (segmentation array, scale) or (None, scale) + Tuple of (segmentation array, scale, graph). The graph may be relabeled + if node_id 0 exists in the original graph. Raises: ValueError: If segmentation validation fails """ if segmentation is None: - return None, scale + return None, scale, graph if self.in_memory_geff is None: raise ValueError("No data loaded. Call load_source() first.") @@ -482,8 +506,8 @@ def handle_segmentation( # Validate segmentation matches graph (only if position is loaded) # If position is not in graph, it will be computed from segmentation - sample_node = next(iter(graph.nodes())) - has_position = "pos" in graph.nodes[sample_node] + # sample_node = next(iter(graph.node_ids())) + has_position = "pos" in graph.node_attr_keys() if has_position: from funtracks.import_export._validation import validate_graph_seg_match @@ -493,7 +517,7 @@ def handle_segmentation( node_props = self.in_memory_geff["node_props"] if "seg_id" not in node_props: # No seg_id property, assume segmentation labels match node IDs - return seg_array.compute(), scale + return seg_array.compute(), scale, graph node_ids = self.in_memory_geff["node_ids"] seg_ids = node_props["seg_id"]["values"] @@ -501,15 +525,15 @@ def handle_segmentation( # Check if any seg_id differs from node_id if np.array_equal(seg_ids, node_ids): # No relabeling needed - return seg_array.compute(), scale + return seg_array.compute(), scale, graph # Relabel segmentation: seg_id -> node_id time_values = node_props[self.TIME_ATTR]["values"] - new_segmentation = relabel_segmentation( + new_segmentation, graph = relabel_segmentation( seg_array, graph, node_ids, seg_ids, time_values ) - return new_segmentation, scale + return new_segmentation, scale, graph def enable_features( self, @@ -593,6 +617,7 @@ def build( scale: list[float] | None = None, node_features: dict[str, bool] | None = None, edge_features: dict[str, bool] | None = None, + node_name_map: dict[str, str | list[str]] | None = None, ) -> SolutionTracks: """Orchestrate the full construction process. @@ -602,6 +627,7 @@ def build( scale: Optional spatial scale node_features: Optional node features to enable/load edge_features: Optional edge features to enable/load + node_name_map: Optional node_name_map to override self.node_name_map Returns: Fully constructed SolutionTracks object @@ -665,10 +691,12 @@ def build( self.validate() # 4. Construct graph - graph = self.construct_graph() + graph = self.construct_graph(node_name_map) # 5. Handle segmentation - segmentation_array, scale = self.handle_segmentation(graph, segmentation, scale) + segmentation_array, scale, graph = self.handle_segmentation( + graph, segmentation, scale + ) # 6. Create SolutionTracks tracks = SolutionTracks( diff --git a/src/funtracks/import_export/_validation.py b/src/funtracks/import_export/_validation.py index 5031f4f4..6d6ab4d9 100644 --- a/src/funtracks/import_export/_validation.py +++ b/src/funtracks/import_export/_validation.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING from warnings import warn -import networkx as nx +import tracksdata as td from geff._typing import InMemoryGeff from geff.validate.graph import ( validate_no_repeated_edges, @@ -22,7 +22,7 @@ def validate_graph_seg_match( - graph: nx.DiGraph, + graph: td.graph.GraphView, segmentation: da.Array, scale: list[float], position_attr: list[str], @@ -34,7 +34,7 @@ def validate_graph_seg_match( of the segmentation to match node id values is required. Args: - graph: NetworkX graph with standard keys + graph: tracksdata graph with standard keys segmentation: Segmentation data (dask array) scale: Scaling information (pixel to world coordinates) position_attr: Position keys (e.g., ["y", "x"] or ["z", "y", "x"]) @@ -51,20 +51,20 @@ def validate_graph_seg_match( ) # Get the last node for validation - node_ids = list(graph.nodes()) + node_ids = list(graph.node_ids()) if not node_ids: raise ValueError("Graph has no nodes") last_node_id = node_ids[-1] - last_node_data = graph.nodes[last_node_id] + last_node_data = graph[last_node_id] # Check if seg_id exists; if not, assume it matches node_id - seg_id = last_node_data.get(SEG_KEY, last_node_id) + seg_id = last_node_data["seg_id"] # Get the coordinates for the last node (using standard keys) # Position may be stored as composite "pos" attribute or separate y/x/z attributes - coord = [int(last_node_data["time"])] - if "pos" in last_node_data: + coord = [int(last_node_data["t"])] + if "pos" in graph.node_attr_keys(): # Composite position: [z, y, x] or [y, x] pos = last_node_data["pos"] coord.extend(pos) diff --git a/src/funtracks/import_export/csv/_import.py b/src/funtracks/import_export/csv/_import.py index 85dfb45b..457a5471 100644 --- a/src/funtracks/import_export/csv/_import.py +++ b/src/funtracks/import_export/csv/_import.py @@ -273,4 +273,5 @@ def tracks_from_df( segmentation, scale=scale, node_features=node_features, + node_name_map=builder.node_name_map, ) diff --git a/src/funtracks/import_export/geff/_export.py b/src/funtracks/import_export/geff/_export.py index 2edff0a3..9b81b5c4 100644 --- a/src/funtracks/import_export/geff/_export.py +++ b/src/funtracks/import_export/geff/_export.py @@ -6,7 +6,6 @@ Literal, ) -import geff import geff_spec import networkx as nx import numpy as np @@ -71,11 +70,23 @@ def export_to_geff( if tracks.scale is None: tracks.scale = (1.0,) * tracks.ndim + # Create axes metadata + axes = [] + for name, axis_type, scale in zip(axis_names, axis_types, tracks.scale, strict=True): + axes.append( + { + "name": name, + "type": axis_type, + "scale": scale, + } + ) + metadata = GeffMetadata( geff_version=geff_spec.__version__, directed=isinstance(graph, nx.DiGraph), node_props_metadata={}, edge_props_metadata={}, + axes=axes, ) # Save segmentation if present @@ -135,20 +146,11 @@ def export_to_geff( # Filter the graph if node_ids is provided if node_ids is not None: - graph = graph.subgraph(nodes_to_keep).copy() + graph = graph.filter(node_ids=nodes_to_keep).subgraph() # Save the graph in a 'tracks' folder tracks_path = directory / "tracks" - geff.write( - graph=graph, - store=tracks_path, - metadata=metadata, - axis_names=axis_names, - axis_types=axis_types, - axis_scales=tracks.scale, - overwrite=overwrite, - zarr_format=zarr_format, - ) + graph.to_geff(geff_store=tracks_path, geff_metadata=metadata, zarr_format=zarr_format) def split_position_attr(tracks: Tracks) -> tuple[nx.DiGraph, list[str] | None]: @@ -169,15 +171,35 @@ def split_position_attr(tracks: Tracks) -> tuple[nx.DiGraph, list[str] | None]: if isinstance(pos_key, str): # Position is stored as a single attribute, need to split - new_graph = tracks.graph.copy() - new_keys = ["y", "x"] - if tracks.ndim == 4: - new_keys.insert(0, "z") - for _, attrs in new_graph.nodes(data=True): - pos = attrs.pop(pos_key) - for i in range(len(new_keys)): - attrs[new_keys[i]] = pos[i] - + new_graph = tracks.graph.detach() + new_graph = new_graph.filter().subgraph() + + # Register new attribute keys + new_graph.add_node_attr_key("x", default_value=0.0) + new_graph.add_node_attr_key("y", default_value=0.0) + + # Get all position values at once + pos_values = new_graph.node_attrs()["pos"].to_numpy() + ndim = pos_values.shape[1] + + if ndim == 2: + new_keys = ["y", "x"] + new_graph.update_node_attrs( + attrs={"x": pos_values[:, 1], "y": pos_values[:, 0]}, + node_ids=new_graph.node_ids(), + ) + elif ndim == 3: + new_keys = ["z", "y", "x"] + new_graph.add_node_attr_key("z", default_value=0.0) + new_graph.update_node_attrs( + attrs={ + "x": pos_values[:, 2], + "y": pos_values[:, 1], + "z": pos_values[:, 0], + }, + node_ids=new_graph.node_ids(), + ) + new_graph.remove_node_attr_key(pos_key) return new_graph, new_keys elif pos_key is not None: # Position is already split into separate attributes diff --git a/src/funtracks/import_export/geff/_import.py b/src/funtracks/import_export/geff/_import.py index 6a7ddd16..680e451d 100644 --- a/src/funtracks/import_export/geff/_import.py +++ b/src/funtracks/import_export/geff/_import.py @@ -287,4 +287,5 @@ def import_from_geff( scale=scale, node_features=node_features, edge_features=edge_features, + node_name_map=node_name_map, ) diff --git a/src/funtracks/utils/tracksdata_utils.py b/src/funtracks/utils/tracksdata_utils.py index 6c2b9c9f..e9767a4f 100644 --- a/src/funtracks/utils/tracksdata_utils.py +++ b/src/funtracks/utils/tracksdata_utils.py @@ -12,10 +12,10 @@ def create_empty_graphview_graph( - with_pos: bool = False, - with_track_id: bool = False, - with_area: bool = False, - with_iou: bool = False, + node_attributes: list[str] | None = None, + edge_attributes: list[str] | None = None, + node_default_values: list[Any] | None = None, + edge_default_values: list[Any] | None = None, database: str | None = None, position_attrs: list[str] | None = None, ) -> td.graph.GraphView: @@ -23,14 +23,16 @@ def create_empty_graphview_graph( Create an empty tracksdata GraphView with standard node and edge attributes. Parameters ---------- - with_pos : bool - Whether to include position attributes. - with_track_id : bool - Whether to include track ID attribute. - with_area : bool - Whether to include area attribute. - with_iou : bool - Whether to include IOU attribute. + node_attributes : list[str] | None + List of node attribute names to include. (providing time attribute not necessary) + edge_attributes : list[str] | None + List of edge attribute names to include. + node_default_values : list[Any] | None + List of default values for each node attribute. + Must match length of node_attributes. + edge_default_values : list[Any] | None + List of default values for each edge attribute. + Must match length of edge_attributes. database : str | None Path to the SQLite database file. If None, creates a unique temporary file. Use ':memory:' for in-memory database (may cause issues with pickling in pytest). @@ -52,6 +54,20 @@ def create_empty_graphview_graph( 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" + ) + else: + node_default_values = [0.0] * len(node_attributes or []) + + if edge_default_values is not None: + assert len(edge_default_values) == len(edge_attributes or []), ( + "Length of edge_default_values must match length of edge_attributes" + ) + else: + edge_default_values = [0.0] * len(edge_attributes or []) + kwargs = { "drivername": "sqlite", "database": database, @@ -59,27 +75,39 @@ def create_empty_graphview_graph( } graph_sql = td.graph.SQLGraph(**kwargs) - if with_pos: + # Add standard node and edge attributes + if "pos" in (node_attributes or []) or any( + attr in (node_attributes or []) for attr in position_attrs + ): if "pos" in position_attrs: graph_sql.add_node_attr_key("pos", default_value=None) else: if "x" in position_attrs: - graph_sql.add_node_attr_key("x", default_value=0) + graph_sql.add_node_attr_key("x", default_value=0.0) if "y" in position_attrs: - graph_sql.add_node_attr_key("y", default_value=0) + graph_sql.add_node_attr_key("y", default_value=0.0) if "z" in position_attrs: - graph_sql.add_node_attr_key("z", default_value=0) - if with_area: - graph_sql.add_node_attr_key("area", default_value=0.0) - if with_track_id: - graph_sql.add_node_attr_key("track_id", default_value=0) + graph_sql.add_node_attr_key("z", default_value=0.0) + + for attr in node_attributes or []: + if attr not in graph_sql.node_attr_keys(): + graph_sql.add_node_attr_key( + attr, + default_value=node_default_values[(node_attributes or []).index(attr)], + ) + + for attr in edge_attributes or []: + if attr not in graph_sql.edge_attr_keys(): + graph_sql.add_edge_attr_key( + attr, + default_value=edge_default_values[(edge_attributes or []).index(attr)], + ) graph_sql.add_node_attr_key(td.DEFAULT_ATTR_KEYS.SOLUTION, default_value=1) + graph_sql.add_edge_attr_key(td.DEFAULT_ATTR_KEYS.SOLUTION, default_value=1) + # TODO Teun: segmentation # graph_sql.add_node_attr_key(td.DEFAULT_ATTR_KEYS.MASK, default_value=None) # graph_sql.add_node_attr_key(td.DEFAULT_ATTR_KEYS.BBOX, default_value=None) - if with_iou: - graph_sql.add_edge_attr_key("iou", default_value=0) - graph_sql.add_edge_attr_key(td.DEFAULT_ATTR_KEYS.SOLUTION, default_value=1) graph_td_sub = graph_sql.filter( td.NodeAttr(td.DEFAULT_ATTR_KEYS.SOLUTION) == 1, @@ -299,3 +327,108 @@ def td_get_single_attr_from_edge(graph, edge: tuple[int, int], attrs: Sequence[s # TODO Teun: AND do edge_attrs(key) directly to prevent loading all attributes item = graph.filter(node_ids=[edge[0], edge[1]]).edge_attrs()[attrs].item() return item + + +def td_relabel_nodes(graph, mapping: dict[int, int]) -> td.graph.SQLGraph: + """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 + + Returns: + A new SQLGraph with relabeled nodes + """ + + # For IndexedRXGraph or SQLGraph + old_graph = graph + + kwargs = { + "drivername": "sqlite", + "database": ":memory:", + "overwrite": True, + } + new_graph = td.graph.SQLGraph(**kwargs) + + # Copy attribute key registrations with defaults + node_defaults = get_node_attr_defaults(graph) + for key, default_val in node_defaults.items(): + new_graph.add_node_attr_key(key, default_value=default_val) + + edge_defaults = get_edge_attr_defaults(graph) + for key, default_val in edge_defaults.items(): + new_graph.add_edge_attr_key(key, default_value=default_val) + + # Get all data + old_nodes = old_graph.node_attrs() + old_edges = old_graph.edge_attrs() + + # Use the provided mapping + id_mapping = mapping + + # Add nodes with new IDs + for row in old_nodes.iter_rows(named=True): + old_id = row.pop("node_id") + new_id = id_mapping[old_id] + new_graph.add_node(row, index=new_id) + + # Add edges with remapped IDs + for row in old_edges.iter_rows(named=True): + source_id = id_mapping[row["source_id"]] + target_id = id_mapping[row["target_id"]] + attrs = { + k: v for k, v in row.items() if k not in ["edge_id", "source_id", "target_id"] + } + new_graph.add_edge(source_id, target_id, attrs) + + return new_graph + + +def get_node_attr_defaults(graph) -> dict[str, Any]: + """Get node attribute keys and their default values from SQLGraph.""" + # Unwrap GraphView if needed + actual_graph = graph._root if hasattr(graph, "_root") else graph + + defaults = {} + for col in actual_graph.Node.__table__.columns: + col_name = col.name + # Skip system columns + if col_name in ["node_id", "t"]: + continue + + # Extract default value from SQLAlchemy column + default_val = None + if ( + hasattr(col, "default") + and col.default is not None + and hasattr(col.default, "arg") + ): + default_val = col.default.arg + + defaults[col_name] = default_val + return defaults + + +def get_edge_attr_defaults(graph) -> dict[str, Any]: + """Get edge attribute keys and their default values from SQLGraph.""" + # Unwrap GraphView if needed + actual_graph = graph._root if hasattr(graph, "_root") else graph + + defaults = {} + for col in actual_graph.Edge.__table__.columns: + col_name = col.name + # Skip system columns + if col_name in ["edge_id", "source_id", "target_id"]: + continue + + # Extract default value + default_val = None + if ( + hasattr(col, "default") + and col.default is not None + and hasattr(col.default, "arg") + ): + default_val = col.default.arg + + defaults[col_name] = default_val + return defaults diff --git a/tests/actions/test_action_history.py b/tests/actions/test_action_history.py index 88aa160b..4365f22c 100644 --- a/tests/actions/test_action_history.py +++ b/tests/actions/test_action_history.py @@ -9,7 +9,8 @@ def test_action_history(): history = ActionHistory() empty_graph = create_empty_graphview_graph( - with_pos=True, with_track_id=True, with_area=False, with_iou=False + node_attributes=["track_id", "pos"], + edge_attributes=[], ) tracks = SolutionTracks(empty_graph, ndim=3, tracklet_attr="track_id", time_attr="t") pos = [0, 1] diff --git a/tests/actions/test_add_delete_nodes.py b/tests/actions/test_add_delete_nodes.py index 4b658c37..3578fcf0 100644 --- a/tests/actions/test_add_delete_nodes.py +++ b/tests/actions/test_add_delete_nodes.py @@ -24,8 +24,15 @@ def test_add_delete_nodes(get_tracks, ndim, with_seg): reference_seg = copy.deepcopy(tracks.segmentation) # Start with an empty Tracks + node_attributes = [ + tracks.features.time_key, + tracks.features.tracklet_key, + tracks.features.position_key, + ] + edge_attributes = ["iou"] if with_seg else [] empty_graph = create_empty_graphview_graph( - with_pos=True, with_track_id=True, with_area=with_seg, with_iou=with_seg + node_attributes=node_attributes + (["area"] if with_seg else []), + edge_attributes=edge_attributes, ) empty_seg = np.zeros_like(tracks.segmentation) if with_seg else None tracks.graph = empty_graph diff --git a/tests/conftest.py b/tests/conftest.py index 3ff9fe72..2414a012 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -74,12 +74,23 @@ def _make_graph( Returns: A graph with the requested features """ + + node_attributes = [] + edge_attributes = [] + if with_pos: + node_attributes.append("pos") + if with_track_id: + node_attributes.append("track_id") + if with_area: + node_attributes.append("area") + if with_iou: + edge_attributes.append("iou") + graph = create_empty_graphview_graph( - with_pos=with_pos, - with_track_id=with_track_id, - with_area=with_area, + node_attributes=node_attributes, + edge_attributes=edge_attributes, database=database, - position_attrs=["pos"], + position_attrs=["pos"] if with_pos else None, ) # Base node data (always has time) @@ -149,7 +160,6 @@ def _make_graph( # Add IOUs to edges if requested if with_iou: - graph.add_edge_attr_key("iou", default_value=0.0) for edge, iou in ious.items(): if graph.has_edge(edge[0], edge[1]): edge_id = graph.edge_id(edge[0], edge[1]) diff --git a/tests/data_model/test_solution_tracks.py b/tests/data_model/test_solution_tracks.py index 7254e158..24801dbc 100644 --- a/tests/data_model/test_solution_tracks.py +++ b/tests/data_model/test_solution_tracks.py @@ -68,7 +68,8 @@ def test_from_tracks_cls_recompute(graph_2d_with_computed_features): def test_next_track_id_empty(): graph = create_empty_graphview_graph( - with_pos=True, with_track_id=True, with_area=False, with_iou=False + node_attributes=["pos", "track_id"], + edge_attributes=[], ) seg = np.zeros(shape=(10, 100, 100, 100), dtype=np.uint64) tracks = SolutionTracks(graph, segmentation=seg, **track_attrs) diff --git a/tests/import_export/test_csv_import.py b/tests/import_export/test_csv_import.py index 1e635adf..c2c36324 100644 --- a/tests/import_export/test_csv_import.py +++ b/tests/import_export/test_csv_import.py @@ -164,7 +164,7 @@ def test_multiple_roots(self): assert tracks.graph.num_edges() == 2 # Should have two root nodes - roots = [n for n in tracks.graph.nodes() if tracks.graph.in_degree(n) == 0] + roots = [n for n in tracks.graph.node_ids() if tracks.graph.in_degree(n) == 0] assert len(roots) == 2 def test_division_event(self): @@ -207,11 +207,13 @@ def test_long_track(self): assert tracks.graph.num_edges() == 9 # Should form a single linear chain - roots = [n for n in tracks.graph.nodes() if tracks.graph.in_degree(n) == 0] + 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.nodes() if tracks.graph.out_degree(n) > 0] + 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 @@ -331,7 +333,7 @@ def test_seg_id_same_as_id(self, simple_df_2d): # Both id and seg_id should be present with same values assert tracks.graph.num_nodes() == 4 - for node_id in tracks.graph.nodes(): + 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): @@ -623,7 +625,7 @@ def test_empty_list_in_name_map_removed(self): 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 not tracks.graph.nodes[1].get("ellipse_axis_radii") + 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. @@ -656,8 +658,8 @@ def test_import_without_position_with_segmentation(self): assert tracks is not None # Position should be computed from segmentation centroids - assert "pos" in tracks.graph.nodes[1] - pos_1 = tracks.graph.nodes[1]["pos"] + assert "pos" in tracks.graph.node_attr_keys() + pos_1 = tracks.graph[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) diff --git a/tests/import_export/test_export_to_geff.py b/tests/import_export/test_export_to_geff.py index cfc860e1..3a093c4a 100644 --- a/tests/import_export/test_export_to_geff.py +++ b/tests/import_export/test_export_to_geff.py @@ -39,11 +39,14 @@ def test_export_to_geff( # Determine position attribute keys based on dimensions pos_keys = ["y", "x"] if ndim == 3 else ["z", "y", "x"] # Split the composite position attribute into separate attributes - for node in graph.nodes(): - pos = graph.nodes[node]["pos"] + for key in pos_keys: + graph.add_node_attr_key(key, default_value=0.0) + for node in graph.node_ids(): + pos = graph[node]["pos"] for i, key in enumerate(pos_keys): - graph.nodes[node][key] = pos[i] - del graph.nodes[node]["pos"] + graph[node][key] = pos[i] + # del graph.nodes[node]["pos"] + graph.remove_node_attr_key("pos") # Create Tracks with split position attributes # Features like area, track_id will be auto-detected from the graph tracks_cls = SolutionTracks if is_solution else Tracks @@ -58,13 +61,17 @@ def test_export_to_geff( else: # Use get_tracks fixture for the simple case tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=is_solution) - export_to_geff(tracks, tmp_path) - z = zarr.open((tmp_path / "tracks").as_posix(), mode="r") + + # Export to subdirectory to avoid conflicts with database files in tmp_path + export_dir = tmp_path / "export" + export_dir.mkdir() + export_to_geff(tracks, export_dir) + z = zarr.open((export_dir / "tracks").as_posix(), mode="r") assert isinstance(z, zarr.Group) # Check that segmentation was saved (only when using segmentation) if with_seg: - seg_path = tmp_path / "segmentation" + seg_path = export_dir / "segmentation" seg_zarr = zarr.open(str(seg_path), mode="r") assert isinstance(seg_zarr, zarr.Array) np.testing.assert_array_equal(seg_zarr[:], tracks.segmentation) diff --git a/tests/import_export/test_import_from_geff.py b/tests/import_export/test_import_from_geff.py index 35a713f4..9b36a533 100644 --- a/tests/import_export/test_import_from_geff.py +++ b/tests/import_export/test_import_from_geff.py @@ -253,15 +253,15 @@ def test_duplicate_values_in_name_map(valid_geff): store, _ = valid_geff # Duplicate values should be allowed - each standard key gets a copy of the data - name_map = {"time": "t", "pos": ["y", "x"], "seg_id": "t"} + 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, name_map) + 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.nodes(): + for node_id in tracks.graph.node_ids(): assert tracks.get_node_attr(node_id, "seg_id") == tracks.get_node_attr( - node_id, "time" + node_id, "t" ) @@ -313,11 +313,11 @@ def test_tracks_with_segmentation(valid_geff, invalid_geff, valid_segmentation, 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.nodes()) + last_node = max(tracks.graph.node_ids()) # With composite pos, position is stored as an array - pos = tracks.graph.nodes[last_node]["pos"] + pos = tracks.graph[last_node]["pos"] coords = [ - tracks.graph.nodes[last_node]["time"], + tracks.graph[last_node]["t"], pos[0], # y pos[1], # x ] @@ -330,10 +330,10 @@ def test_tracks_with_segmentation(valid_geff, invalid_geff, valid_segmentation, ) # test that the seg id has been relabeled # Check that only required/requested features are present, and that area is recomputed - data = tracks.graph.nodes[last_node] - assert "random_feature" in data - assert "random_feature2" not in data - assert "area" in data + data = tracks.graph[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"] == 0.01 ) # recomputed area values should be 1 pixel, so 0.01 after applying the scaling. @@ -352,9 +352,9 @@ def test_tracks_with_segmentation(valid_geff, invalid_geff, valid_segmentation, node_features=node_features, ) # Get last node by ID (don't rely on iteration order) - last_node = max(tracks.graph.nodes()) - data = tracks.graph.nodes[last_node] - assert "area" in data + last_node = max(tracks.graph.node_ids()) + data = tracks.graph[last_node] + assert "area" in tracks.graph.node_attr_keys() assert data["area"] == 21 # Test that import fails with ValueError when invalid seg_ids are provided. @@ -417,7 +417,7 @@ def test_node_features_compute_vs_load(valid_geff, valid_segmentation, tmp_path) Features not in the geff can still be computed if marked True. """ store, _ = valid_geff - name_map = { + node_name_map = { "time": "t", "pos": ["y", "x"], # Composite position mapping "seg_id": "seg_id", @@ -437,7 +437,7 @@ def test_node_features_compute_vs_load(valid_geff, valid_segmentation, tmp_path) tracks = import_from_geff( store, - name_map, + node_name_map, segmentation_path=valid_segmentation_path, scale=scale, node_features=node_features, @@ -448,12 +448,12 @@ def test_node_features_compute_vs_load(valid_geff, valid_segmentation, tmp_path) assert key in tracks.features # Get last node by ID (don't rely on iteration order) - max_node_id = max(tracks.graph.nodes()) - data = tracks.graph.nodes[max_node_id] + max_node_id = max(tracks.graph.node_ids()) + data = tracks.graph[max_node_id] # All requested features should be present for key in feature_keys: - assert key in data + assert data[key] is not None # Verify computed values (1 pixel = 0.01 after scaling) # Original geff had area=21 for last node diff --git a/tests/import_export/test_import_segmentation.py b/tests/import_export/test_import_segmentation.py index 6c1f51fb..cc22dd6d 100644 --- a/tests/import_export/test_import_segmentation.py +++ b/tests/import_export/test_import_segmentation.py @@ -1,6 +1,5 @@ """Tests for _import_segmentation module.""" -import networkx as nx import numpy as np import tifffile @@ -8,6 +7,7 @@ load_segmentation, relabel_segmentation, ) +from funtracks.utils.tracksdata_utils import create_empty_graphview_graph class TestLoadSegmentation: @@ -46,15 +46,15 @@ def test_basic_relabeling(self): seg[1, 2, 2] = 20 # seg_id 20 at t=1 # Create graph with node_ids 1, 2 - graph = nx.DiGraph() - graph.add_node(1) - graph.add_node(2) + graph = create_empty_graphview_graph() + graph.add_node(index=1, attrs={"t": 0, "solution": 1}) + graph.add_node(index=2, attrs={"t": 1, "solution": 1}) node_ids = np.array([1, 2]) seg_ids = np.array([10, 20]) time_values = np.array([0, 1]) - result = relabel_segmentation(seg, graph, node_ids, seg_ids, time_values) + 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 @@ -70,15 +70,15 @@ def test_relabeling_with_node_id_zero(self): seg[1, 2, 2] = 20 # seg_id 20 at t=1 # Create graph with node_ids 0, 1 (includes 0!) - graph = nx.DiGraph() - graph.add_node(0) - graph.add_node(1) + graph = create_empty_graphview_graph() + graph.add_node(index=0, attrs={"t": 0, "solution": 1}) + graph.add_node(index=1, attrs={"t": 1, "solution": 1}) node_ids = np.array([0, 1]) seg_ids = np.array([10, 20]) time_values = np.array([0, 1]) - result = relabel_segmentation(seg, graph, node_ids, seg_ids, time_values) + 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) @@ -86,9 +86,9 @@ def test_relabeling_with_node_id_zero(self): assert result[1, 2, 2] == 2 # Graph should also be relabeled - assert 1 in graph.nodes() - assert 2 in graph.nodes() - assert 0 not in graph.nodes() + assert 1 in graph.node_ids() + assert 2 in graph.node_ids() + assert 0 not in graph.node_ids() def test_no_relabeling_needed_same_ids(self): """Test when seg_ids equal node_ids (relabeling still applies mapping).""" @@ -97,15 +97,15 @@ def test_no_relabeling_needed_same_ids(self): seg[0, 1, 1] = 1 seg[1, 2, 2] = 2 - graph = nx.DiGraph() - graph.add_node(1) - graph.add_node(2) + graph = create_empty_graphview_graph() + graph.add_node(index=1, attrs={"t": 0, "solution": 1}) + graph.add_node(index=2, attrs={"t": 1, "solution": 1}) node_ids = np.array([1, 2]) seg_ids = np.array([1, 2]) # Same as node_ids time_values = np.array([0, 1]) - result = relabel_segmentation(seg, graph, node_ids, seg_ids, time_values) + 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 @@ -119,14 +119,16 @@ def test_multiple_nodes_same_timepoint(self): seg[0, 2, 2] = 20 seg[0, 3, 3] = 30 - graph = nx.DiGraph() - graph.add_nodes_from([1, 2, 3]) + graph = create_empty_graphview_graph() + graph.add_node(index=1, attrs={"t": 0, "solution": 1}) + graph.add_node(index=2, attrs={"t": 0, "solution": 1}) + graph.add_node(index=3, attrs={"t": 0, "solution": 1}) node_ids = np.array([1, 2, 3]) seg_ids = np.array([10, 20, 30]) time_values = np.array([0, 0, 0]) - result = relabel_segmentation(seg, graph, node_ids, seg_ids, time_values) + result, graph = relabel_segmentation(seg, graph, node_ids, seg_ids, time_values) assert result[0, 1, 1] == 1 assert result[0, 2, 2] == 2 From 406bddf721a3bb0a7d3325d7ffc81293a6397c70 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Wed, 17 Dec 2025 17:00:52 -0800 Subject: [PATCH 12/44] fix annotator tests and working on input/output --- pyproject.toml | 2 +- src/funtracks/annotators/_graph_annotator.py | 16 +++++++++++++++ src/funtracks/annotators/_track_annotator.py | 13 +++++++++--- src/funtracks/data_model/tracks.py | 3 ++- tests/annotators/test_edge_annotator.py | 12 +++++------ .../annotators/test_regionprops_annotator.py | 19 +++++++++--------- tests/annotators/test_track_annotator.py | 11 +++++----- .../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 0 -> 200128 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 0 -> 20000128 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 0 -> 200128 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 0 -> 20000128 bytes tests/import_export/test_internal_format.py | 12 +++++++---- 28 files changed, 74 insertions(+), 30 deletions(-) create mode 100644 tests/data/format_v1/test_save_load_False_3_False_0/attrs.json create mode 100644 tests/data/format_v1/test_save_load_False_3_False_0/graph.json create mode 100644 tests/data/format_v1/test_save_load_False_3_True_0/attrs.json create mode 100644 tests/data/format_v1/test_save_load_False_3_True_0/graph.json create mode 100644 tests/data/format_v1/test_save_load_False_3_True_0/seg.npy create mode 100644 tests/data/format_v1/test_save_load_False_4_False_0/attrs.json create mode 100644 tests/data/format_v1/test_save_load_False_4_False_0/graph.json create mode 100644 tests/data/format_v1/test_save_load_False_4_True_0/attrs.json create mode 100644 tests/data/format_v1/test_save_load_False_4_True_0/graph.json create mode 100644 tests/data/format_v1/test_save_load_False_4_True_0/seg.npy create mode 100644 tests/data/format_v1/test_save_load_True_3_False_0/attrs.json create mode 100644 tests/data/format_v1/test_save_load_True_3_False_0/graph.json create mode 100644 tests/data/format_v1/test_save_load_True_3_True_0/attrs.json create mode 100644 tests/data/format_v1/test_save_load_True_3_True_0/graph.json create mode 100644 tests/data/format_v1/test_save_load_True_3_True_0/seg.npy create mode 100644 tests/data/format_v1/test_save_load_True_4_False_0/attrs.json create mode 100644 tests/data/format_v1/test_save_load_True_4_False_0/graph.json create mode 100644 tests/data/format_v1/test_save_load_True_4_True_0/attrs.json create mode 100644 tests/data/format_v1/test_save_load_True_4_True_0/graph.json create mode 100644 tests/data/format_v1/test_save_load_True_4_True_0/seg.npy diff --git a/pyproject.toml b/pyproject.toml index bc717328..4920e7fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies =[ "pandas>=2.3.3", "zarr>=2.18,<4", "numcodecs>=0.13,<0.16", - "tracksdata@git+https://github.com/royerlab/tracksdata@daba10830826b3652ed5ee296c3fd83cac52b27a", + "tracksdata@git+https://github.com/royerlab/tracksdata@b35a0f111f8d39baec00245a457d65941222174b", ] [project.urls] diff --git a/src/funtracks/annotators/_graph_annotator.py b/src/funtracks/annotators/_graph_annotator.py index 7fb8583c..3bf33087 100644 --- a/src/funtracks/annotators/_graph_annotator.py +++ b/src/funtracks/annotators/_graph_annotator.py @@ -66,6 +66,22 @@ def activate_features(self, keys: list[str]) -> None: feat, _ = self.all_features[key] self.all_features[key] = (feat, True) + # Ensure attribute key exists in graph schema + if ( + feat["feature_type"] == "node" + and key not in self.tracks.graph.node_attr_keys() + ): + self.tracks.graph.add_node_attr_key( + key, default_value=feat["default_value"] + ) + elif ( + feat["feature_type"] == "edge" + and key not in self.tracks.graph.edge_attr_keys() + ): + self.tracks.graph.add_edge_attr_key( + key, default_value=feat["default_value"] + ) + def deactivate_features(self, keys: list[str]) -> None: """Deactivate computation of the given features in the annotation process. diff --git a/src/funtracks/annotators/_track_annotator.py b/src/funtracks/annotators/_track_annotator.py index 3c6cc26e..c8e72f2a 100644 --- a/src/funtracks/annotators/_track_annotator.py +++ b/src/funtracks/annotators/_track_annotator.py @@ -3,7 +3,6 @@ from collections import defaultdict from typing import TYPE_CHECKING -import networkx as nx import rustworkx as rx import tracksdata as td @@ -198,8 +197,16 @@ def _assign_lineage_ids(self) -> None: Each connected component will get a unique id, and the relevant class attributes will be updated. """ - lineages = nx.weakly_connected_components(self.tracks.graph) - max_id, ids_to_nodes = self._assign_ids(lineages, self.lineage_key) + lineages_internal = rx.weakly_connected_components(self.tracks.graph.rx_graph) + lineages_external = [] + for lin in lineages_internal: + node_ids_internal = list(lin) + node_ids_external = [ + self.tracks.graph.node_ids()[nid] for nid in node_ids_internal + ] + lineages_external.append(node_ids_external) + + max_id, ids_to_nodes = self._assign_ids(lineages_external, self.lineage_key) self.max_lineage_id = max_id self.lineage_id_to_nodes = ids_to_nodes diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index c29dbfd0..fd30df41 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -292,7 +292,7 @@ def _setup_core_computed_features(self) -> None: # Add to FeatureDict if not already there if key not in self.features: feature, _ = self.annotators.all_features[key] - self.features[key] = feature + self.add_feature(key, feature) self.annotators.activate_features([key]) else: # enable it (compute it) @@ -577,6 +577,7 @@ def disable_features(self, feature_keys: list[str]) -> None: for key in feature_keys: if key in self.features: del self.features[key] + # TODO Teun: do we need to remove feature here from graph as well? def add_feature(self, key: str, feature: Feature) -> None: """Add a feature to the features dictionary and perform graph operations. diff --git a/tests/annotators/test_edge_annotator.py b/tests/annotators/test_edge_annotator.py index 2999b646..60ba9e4f 100644 --- a/tests/annotators/test_edge_annotator.py +++ b/tests/annotators/test_edge_annotator.py @@ -3,6 +3,7 @@ from funtracks.actions import UpdateNodeSeg, UpdateTrackID from funtracks.annotators import EdgeAnnotator from funtracks.data_model import SolutionTracks, Tracks +from funtracks.utils.tracksdata_utils import td_get_single_attr_from_edge track_attrs = {"time_attr": "t", "tracklet_attr": "track_id"} @@ -33,9 +34,8 @@ def test_compute_all(self, get_graph, get_segmentation, ndim): # Compute values ann.compute() - for edge in tracks.edges(): - for key in all_features: - assert key in tracks.graph.edges[edge] + for key in all_features: + assert key in tracks.graph.edge_attr_keys() def test_update_all(self, get_graph, get_segmentation, ndim) -> None: graph = get_graph(ndim, with_features="clean") @@ -68,7 +68,7 @@ def test_update_all(self, get_graph, get_segmentation, ndim) -> None: ): UpdateNodeSeg(tracks, node_id, pixels, added=False) - assert tracks.graph.edges[edge_id]["iou"] == 0 + assert td_get_single_attr_from_edge(tracks.graph, edge_id, "iou") == 0 def test_add_remove_feature(self, get_graph, get_segmentation, ndim): graph = get_graph(ndim, with_features="clean") @@ -125,7 +125,7 @@ def test_ignores_irrelevant_actions(self, get_graph, get_segmentation, ndim): tracks.enable_features(["iou", track_attrs["tracklet_attr"]]) edge_id = (1, 3) - initial_iou = tracks.graph.edges[edge_id]["iou"] + initial_iou = td_get_single_attr_from_edge(tracks.graph, edge_id, "iou") # Manually modify segmentation (without triggering an action) # Remove half the pixels from node 3 (target of the edge) @@ -149,6 +149,6 @@ def test_ignores_irrelevant_actions(self, get_graph, get_segmentation, ndim): UpdateTrackID(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 + assert td_get_single_attr_from_edge(tracks.graph, 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/annotators/test_regionprops_annotator.py b/tests/annotators/test_regionprops_annotator.py index 807cee59..1627e395 100644 --- a/tests/annotators/test_regionprops_annotator.py +++ b/tests/annotators/test_regionprops_annotator.py @@ -33,9 +33,11 @@ def test_compute_all(self, get_graph, get_segmentation, ndim): # Compute values rp_ann.compute() - for node in tracks.nodes(): - for key in rp_ann.features: - assert key in tracks.graph.nodes[node] + for key in rp_ann.features: + assert key in tracks.graph.node_attr_keys() + for node_id in tracks.graph.node_ids(): + value = tracks.graph[node_id][key] + assert value is not None def test_update_all(self, get_graph, get_segmentation, ndim): graph = get_graph(ndim, with_features="clean") @@ -59,7 +61,7 @@ def test_update_all(self, get_graph, get_segmentation, ndim): UpdateNodeSeg(tracks, node_id, pixels_to_remove, added=False) assert tracks.get_node_attr(node_id, "area") == expected_area for key in rp_ann.features: - assert key in tracks.graph.nodes[node_id] + assert key in tracks.graph.node_attr_keys() # segmentation is fully erased and you try to update node_id = 1 @@ -70,7 +72,7 @@ def test_update_all(self, get_graph, get_segmentation, ndim): UpdateNodeSeg(tracks, node_id, pixels, added=False) for key in rp_ann.features: - assert tracks.graph.nodes[node_id][key] is None + assert tracks.graph[node_id][key] is None def test_add_remove_feature(self, get_graph, get_segmentation, ndim): graph = get_graph(ndim, with_features="clean") @@ -85,13 +87,10 @@ def test_add_remove_feature(self, get_graph, get_segmentation, ndim): rp_ann.deactivate_features([to_remove_key]) # Clear existing area attributes from graph (from fixture) - for node in tracks.nodes(): - if to_remove_key in tracks.graph.nodes[node]: - del tracks.graph.nodes[node][to_remove_key] + graph.remove_node_attr_key(to_remove_key) rp_ann.compute() - for node in tracks.nodes(): - assert to_remove_key not in tracks.graph.nodes[node] + assert to_remove_key not in tracks.graph.node_attr_keys() # add it back in rp_ann.activate_features([to_remove_key]) diff --git a/tests/annotators/test_track_annotator.py b/tests/annotators/test_track_annotator.py index 3d5d33c3..c3eaf6bb 100644 --- a/tests/annotators/test_track_annotator.py +++ b/tests/annotators/test_track_annotator.py @@ -26,16 +26,16 @@ def test_init(self, get_tracks, ndim, with_seg) -> None: 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) + 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.nodes(): + for node in tracks.graph.node_ids(): for key in all_features: - assert key in tracks.graph.nodes[node] + assert tracks.graph[node][key] is not None lineages = [ [1, 2, 3, 4, 5], @@ -62,7 +62,7 @@ def test_compute_all(self, get_tracks, ndim, with_seg) -> None: 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) + 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 @@ -70,7 +70,8 @@ def test_add_remove_feature(self, get_tracks, ndim, with_seg): # add an edge node_id = 6 edge_id = (4, 6) - tracks.graph.add_edge(*edge_id) + attrs = {"iou": 0, "solution": 1} if with_seg else {"solution": 1} + 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, required=True) orig_tra = tracks.get_node_attr(node_id, ann.tracklet_key, required=True) diff --git a/tests/data/format_v1/test_save_load_False_3_False_0/attrs.json b/tests/data/format_v1/test_save_load_False_3_False_0/attrs.json new file mode 100644 index 00000000..00ca9cd1 --- /dev/null +++ b/tests/data/format_v1/test_save_load_False_3_False_0/attrs.json @@ -0,0 +1 @@ +{"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/data/format_v1/test_save_load_False_3_False_0/graph.json b/tests/data/format_v1/test_save_load_False_3_False_0/graph.json new file mode 100644 index 00000000..d0a025b5 --- /dev/null +++ b/tests/data/format_v1/test_save_load_False_3_False_0/graph.json @@ -0,0 +1 @@ +{"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/data/format_v1/test_save_load_False_3_True_0/attrs.json b/tests/data/format_v1/test_save_load_False_3_True_0/attrs.json new file mode 100644 index 00000000..8aa55337 --- /dev/null +++ b/tests/data/format_v1/test_save_load_False_3_True_0/attrs.json @@ -0,0 +1 @@ +{"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}, "track_id": {"feature_type": "node", "value_type": "int", "num_values": 1, "display_name": "Tracklet ID", "required": true, "default_value": null}}, "time_key": "t", "position_key": "pos", "tracklet_key": "track_id"}}} diff --git a/tests/data/format_v1/test_save_load_False_3_True_0/graph.json b/tests/data/format_v1/test_save_load_False_3_True_0/graph.json new file mode 100644 index 00000000..024fe42b --- /dev/null +++ b/tests/data/format_v1/test_save_load_False_3_True_0/graph.json @@ -0,0 +1 @@ +{"directed": true, "multigraph": false, "graph": {}, "nodes": [{"t": 0, "pos": [50, 50], "track_id": 1, "area": 1245, "id": 1}, {"t": 1, "pos": [20, 80], "track_id": 2, "area": 305, "id": 2}, {"t": 1, "pos": [60, 45], "track_id": 3, "area": 697, "id": 3}, {"t": 2, "pos": [1.5, 1.5], "track_id": 3, "area": 16, "id": 4}, {"t": 4, "pos": [1.5, 1.5], "track_id": 3, "area": 16, "id": 5}, {"t": 4, "pos": [97.5, 97.5], "track_id": 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/data/format_v1/test_save_load_False_3_True_0/seg.npy b/tests/data/format_v1/test_save_load_False_3_True_0/seg.npy new file mode 100644 index 0000000000000000000000000000000000000000..6440622383fd7489f5baf1205275b5271746a104 GIT binary patch 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 literal 0 HcmV?d00001 diff --git a/tests/data/format_v1/test_save_load_False_4_False_0/attrs.json b/tests/data/format_v1/test_save_load_False_4_False_0/attrs.json new file mode 100644 index 00000000..0d7d20ba --- /dev/null +++ b/tests/data/format_v1/test_save_load_False_4_False_0/attrs.json @@ -0,0 +1 @@ +{"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/data/format_v1/test_save_load_False_4_False_0/graph.json b/tests/data/format_v1/test_save_load_False_4_False_0/graph.json new file mode 100644 index 00000000..4d000548 --- /dev/null +++ b/tests/data/format_v1/test_save_load_False_4_False_0/graph.json @@ -0,0 +1 @@ +{"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/data/format_v1/test_save_load_False_4_True_0/attrs.json b/tests/data/format_v1/test_save_load_False_4_True_0/attrs.json new file mode 100644 index 00000000..2325b0ec --- /dev/null +++ b/tests/data/format_v1/test_save_load_False_4_True_0/attrs.json @@ -0,0 +1 @@ +{"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}}, "time_key": "t", "position_key": "pos", "tracklet_key": "track_id"}}} diff --git a/tests/data/format_v1/test_save_load_False_4_True_0/graph.json b/tests/data/format_v1/test_save_load_False_4_True_0/graph.json new file mode 100644 index 00000000..380d5887 --- /dev/null +++ b/tests/data/format_v1/test_save_load_False_4_True_0/graph.json @@ -0,0 +1 @@ +{"directed": true, "multigraph": false, "graph": {}, "nodes": [{"t": 0, "pos": [50, 50, 50], "track_id": 1, "area": 33401, "id": 1}, {"t": 1, "pos": [20, 50, 80], "track_id": 2, "area": 4169, "id": 2}, {"t": 1, "pos": [60, 50, 45], "track_id": 3, "area": 14147, "id": 3}, {"t": 2, "pos": [1.5, 1.5, 1.5], "track_id": 3, "area": 64, "id": 4}, {"t": 4, "pos": [1.5, 1.5, 1.5], "track_id": 3, "area": 64, "id": 5}, {"t": 4, "pos": [97.5, 97.5, 97.5], "track_id": 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/data/format_v1/test_save_load_False_4_True_0/seg.npy b/tests/data/format_v1/test_save_load_False_4_True_0/seg.npy new file mode 100644 index 0000000000000000000000000000000000000000..236d56ee3c48ab8641576a62fa27dac0ef8feb00 GIT binary patch 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 literal 0 HcmV?d00001 diff --git a/tests/data/format_v1/test_save_load_True_4_False_0/attrs.json b/tests/data/format_v1/test_save_load_True_4_False_0/attrs.json new file mode 100644 index 00000000..98c52606 --- /dev/null +++ b/tests/data/format_v1/test_save_load_True_4_False_0/attrs.json @@ -0,0 +1 @@ +{"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}}, "time_key": "t", "position_key": "pos", "tracklet_key": "track_id"}}} diff --git a/tests/data/format_v1/test_save_load_True_4_False_0/graph.json b/tests/data/format_v1/test_save_load_True_4_False_0/graph.json new file mode 100644 index 00000000..a82a72f0 --- /dev/null +++ b/tests/data/format_v1/test_save_load_True_4_False_0/graph.json @@ -0,0 +1 @@ +{"directed": true, "multigraph": false, "graph": {}, "nodes": [{"t": 0, "pos": [50, 50, 50], "track_id": 1, "id": 1}, {"t": 1, "pos": [20, 50, 80], "track_id": 2, "id": 2}, {"t": 1, "pos": [60, 50, 45], "track_id": 3, "id": 3}, {"t": 2, "pos": [1.5, 1.5, 1.5], "track_id": 3, "id": 4}, {"t": 4, "pos": [1.5, 1.5, 1.5], "track_id": 3, "id": 5}, {"t": 4, "pos": [97.5, 97.5, 97.5], "track_id": 5, "id": 6}], "links": [{"source": 1, "target": 2}, {"source": 1, "target": 3}, {"source": 3, "target": 4}, {"source": 4, "target": 5}]} diff --git a/tests/data/format_v1/test_save_load_True_4_True_0/attrs.json b/tests/data/format_v1/test_save_load_True_4_True_0/attrs.json new file mode 100644 index 00000000..2325b0ec --- /dev/null +++ b/tests/data/format_v1/test_save_load_True_4_True_0/attrs.json @@ -0,0 +1 @@ +{"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}}, "time_key": "t", "position_key": "pos", "tracklet_key": "track_id"}}} diff --git a/tests/data/format_v1/test_save_load_True_4_True_0/graph.json b/tests/data/format_v1/test_save_load_True_4_True_0/graph.json new file mode 100644 index 00000000..380d5887 --- /dev/null +++ b/tests/data/format_v1/test_save_load_True_4_True_0/graph.json @@ -0,0 +1 @@ +{"directed": true, "multigraph": false, "graph": {}, "nodes": [{"t": 0, "pos": [50, 50, 50], "track_id": 1, "area": 33401, "id": 1}, {"t": 1, "pos": [20, 50, 80], "track_id": 2, "area": 4169, "id": 2}, {"t": 1, "pos": [60, 50, 45], "track_id": 3, "area": 14147, "id": 3}, {"t": 2, "pos": [1.5, 1.5, 1.5], "track_id": 3, "area": 64, "id": 4}, {"t": 4, "pos": [1.5, 1.5, 1.5], "track_id": 3, "area": 64, "id": 5}, {"t": 4, "pos": [97.5, 97.5, 97.5], "track_id": 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/data/format_v1/test_save_load_True_4_True_0/seg.npy b/tests/data/format_v1/test_save_load_True_4_True_0/seg.npy new file mode 100644 index 0000000000000000000000000000000000000000..236d56ee3c48ab8641576a62fa27dac0ef8feb00 GIT binary patch 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 Date: Wed, 17 Dec 2025 17:05:59 -0800 Subject: [PATCH 13/44] remove all graph.nodes and replace td_graph_edge_list with graph.edge_list --- docs/features.md | 4 ++-- src/funtracks/annotators/_track_annotator.py | 3 +-- src/funtracks/utils/tracksdata_utils.py | 15 --------------- tests/actions/test_add_delete_edge.py | 11 ++++------- tests/data_model/test_tracks.py | 3 +-- tests/import_export/test_export_to_geff.py | 1 - tests/user_actions/test_user_actions_force.py | 13 ++++++------- 7 files changed, 14 insertions(+), 36 deletions(-) diff --git a/docs/features.md b/docs/features.md index c34f500d..132ba2a1 100644 --- a/docs/features.md +++ b/docs/features.md @@ -272,9 +272,9 @@ tracks.disable_features(["area"]) def compute(self, feature_keys=None): # Compute feature values in bulk if "custom" in self.features: - for node in self.tracks.graph.nodes(): + for node in self.tracks.graph.node_ids(): value = self._compute_custom(node) - self.tracks.graph.nodes[node]["custom"] = value + self.tracks.graph[node]["custom"] = value def update(self, action): # Incremental update when graph changes diff --git a/src/funtracks/annotators/_track_annotator.py b/src/funtracks/annotators/_track_annotator.py index c8e72f2a..34248015 100644 --- a/src/funtracks/annotators/_track_annotator.py +++ b/src/funtracks/annotators/_track_annotator.py @@ -9,7 +9,6 @@ from funtracks.actions import AddNode, DeleteNode, UpdateTrackID from funtracks.data_model import SolutionTracks from funtracks.features import LineageID, TrackletID -from funtracks.utils.tracksdata_utils import td_graph_edge_list from ._graph_annotator import GraphAnnotator @@ -229,7 +228,7 @@ def _assign_tracklet_ids(self) -> None: # Remove all intertrack edges from a copy of the original graph for parent in parents: - all_edges = td_graph_edge_list(self.tracks.graph) + all_edges = self.tracks.graph.edge_list() daughters = [edge[1] for edge in all_edges if edge[0] == parent] for daughter in daughters: diff --git a/src/funtracks/utils/tracksdata_utils.py b/src/funtracks/utils/tracksdata_utils.py index e9767a4f..94070380 100644 --- a/src/funtracks/utils/tracksdata_utils.py +++ b/src/funtracks/utils/tracksdata_utils.py @@ -283,21 +283,6 @@ def pixels_to_td_mask( return mask, area -def td_graph_edge_list(graph): - """Get list of edges from a tracksdata graph. - - Args: - graph: A tracksdata graph - - Returns: - list: List of edges: [[source_id, target_id], ...] - """ - existing_edges = ( - graph.edge_attrs().select(["source_id", "target_id"]).to_numpy().tolist() - ) - return existing_edges - - def td_get_ancestors(graph, node_id): """Get ancestors of a node in a tracksdata graph. diff --git a/tests/actions/test_add_delete_edge.py b/tests/actions/test_add_delete_edge.py index 39dae7ad..b237d590 100644 --- a/tests/actions/test_add_delete_edge.py +++ b/tests/actions/test_add_delete_edge.py @@ -11,7 +11,6 @@ ) from funtracks.utils.tracksdata_utils import ( td_get_single_attr_from_edge, - td_graph_edge_list, ) iou_key = "iou" @@ -25,7 +24,7 @@ def test_add_delete_edges(get_tracks, ndim, with_seg): reference_seg = copy.deepcopy(tracks.segmentation) # Create an empty tracks with just nodes (no edges) - for edge in td_graph_edge_list(tracks.graph): + for edge in tracks.graph.edge_list(): tracks.graph.remove_edge(*edge) edges = [(1, 2), (1, 3), (3, 4), (4, 5)] @@ -39,7 +38,7 @@ def test_add_delete_edges(get_tracks, ndim, with_seg): # TODO: test all the edge cases, invalid operations, etc. for all actions assert set(tracks.graph.node_ids()) == set(reference_graph.node_ids()) if with_seg: - for edge in td_graph_edge_list(tracks.graph): + for edge in tracks.graph.edge_list(): edge_id_tracks = tracks.graph.edge_id(edge[0], edge[1]) edge_id_graph = reference_graph.edge_id(edge[0], edge[1]) assert tracks.graph.edge_attrs().filter(pl.col("edge_id") == edge_id_tracks)[ @@ -62,11 +61,9 @@ def test_add_delete_edges(get_tracks, ndim, with_seg): 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(td_graph_edge_list(tracks.graph)) == sorted( - td_graph_edge_list(reference_graph) - ) + assert sorted(tracks.graph.edge_list()) == sorted(reference_graph.edge_list()) if with_seg: - for edge in td_graph_edge_list(tracks.graph): + for edge in tracks.graph.edge_list(): edge_id_tracks = tracks.graph.edge_id(edge[0], edge[1]) edge_id_graph = reference_graph.edge_id(edge[0], edge[1]) diff --git a/tests/data_model/test_tracks.py b/tests/data_model/test_tracks.py index 235f8b61..25afdf15 100644 --- a/tests/data_model/test_tracks.py +++ b/tests/data_model/test_tracks.py @@ -5,7 +5,6 @@ from funtracks.data_model import Tracks from funtracks.utils.tracksdata_utils import ( create_empty_graphview_graph, - td_graph_edge_list, ) track_attrs = {"time_attr": "t", "tracklet_attr": "track_id"} @@ -105,7 +104,7 @@ def test_nodes_edges(graph_2d_with_computed_features): tracks = Tracks(graph_2d_with_computed_features, ndim=3, **track_attrs) assert set(tracks.nodes()) == {1, 2, 3, 4, 5, 6} assert set(tracks.edges()) == {1, 2, 3, 4} - assert set(map(tuple, td_graph_edge_list(tracks.graph))) == { + assert set(map(tuple, tracks.graph.edge_list())) == { (1, 2), (1, 3), (3, 4), diff --git a/tests/import_export/test_export_to_geff.py b/tests/import_export/test_export_to_geff.py index 3a093c4a..c7eb831e 100644 --- a/tests/import_export/test_export_to_geff.py +++ b/tests/import_export/test_export_to_geff.py @@ -45,7 +45,6 @@ def test_export_to_geff( pos = graph[node]["pos"] for i, key in enumerate(pos_keys): graph[node][key] = pos[i] - # del graph.nodes[node]["pos"] graph.remove_node_attr_key("pos") # Create Tracks with split position attributes # Features like area, track_id will be auto-detected from the graph diff --git a/tests/user_actions/test_user_actions_force.py b/tests/user_actions/test_user_actions_force.py index a519318a..23a209cb 100644 --- a/tests/user_actions/test_user_actions_force.py +++ b/tests/user_actions/test_user_actions_force.py @@ -1,7 +1,6 @@ import pytest from funtracks.user_actions import UserAddNode -from funtracks.utils.tracksdata_utils import td_graph_edge_list def test_user_force_add_downstream(get_tracks): @@ -14,9 +13,9 @@ def test_user_force_add_downstream(get_tracks): 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 td_graph_edge_list(tracks.graph) - assert [1, 3] not in td_graph_edge_list(tracks.graph) - assert [1, 7] in td_graph_edge_list(tracks.graph) + 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): @@ -29,9 +28,9 @@ def test_user_force_add_upstream(get_tracks): 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 td_graph_edge_list(tracks.graph) # still there - assert [1, 3] not in td_graph_edge_list(tracks.graph) # should be removed - assert [7, 3] in td_graph_edge_list(tracks.graph) # new forced edge + 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): From a56a468ea6f88ec9044afb8e6b327970275e8b4c Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Wed, 17 Dec 2025 17:35:23 -0800 Subject: [PATCH 14/44] replace ancestors by rx.ancestors --- src/funtracks/data_model/tracks.py | 4 ++++ src/funtracks/import_export/_utils.py | 11 ++++++++--- src/funtracks/utils/tracksdata_utils.py | 22 ---------------------- 3 files changed, 12 insertions(+), 25 deletions(-) diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index fd30df41..f26fdbc5 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -578,6 +578,10 @@ def disable_features(self, feature_keys: list[str]) -> None: if key in self.features: del self.features[key] # TODO Teun: do we need to remove feature here from graph as well? + # Currently the tests want to maintain the values on the graph, + # but this might become a problem when you want to add a node later, + # and you NEED to add all existing attributes, sooo conclusion: + # we will remove all the features from the graph, so change the tests! def add_feature(self, key: str, feature: Feature) -> None: """Add a feature to the features dictionary and perform graph operations. diff --git a/src/funtracks/import_export/_utils.py b/src/funtracks/import_export/_utils.py index 17f74506..6539209e 100644 --- a/src/funtracks/import_export/_utils.py +++ b/src/funtracks/import_export/_utils.py @@ -6,7 +6,6 @@ import numpy as np from funtracks.data_model.tracks import Tracks -from funtracks.utils.tracksdata_utils import td_get_ancestors if TYPE_CHECKING: from numpy.typing import ArrayLike @@ -83,10 +82,16 @@ def filter_graph_with_ancestors(graph: nx.DiGraph, nodes_to_keep: set[int]) -> l in `nodes_to_keep` and their ancestors. """ all_nodes_to_keep = set(nodes_to_keep) + import rustworkx as rx for node in nodes_to_keep: - ancestors = td_get_ancestors(graph, node) - all_nodes_to_keep.update(ancestors) + # Map external node ID to internal RustWorkX index + internal_node = graph._external_to_local[node] + # Get ancestors using internal indices + ancestors = rx.ancestors(graph.rx_graph, internal_node) + # Convert ancestor indices back to external node IDs + ancestors_external = [graph._local_to_external[nid] for nid in ancestors] + all_nodes_to_keep.update(ancestors_external) return list(all_nodes_to_keep) diff --git a/src/funtracks/utils/tracksdata_utils.py b/src/funtracks/utils/tracksdata_utils.py index 94070380..fd96ebe9 100644 --- a/src/funtracks/utils/tracksdata_utils.py +++ b/src/funtracks/utils/tracksdata_utils.py @@ -283,28 +283,6 @@ def pixels_to_td_mask( return mask, area -def td_get_ancestors(graph, node_id): - """Get ancestors of a node in a tracksdata graph. - - Args: - graph: A tracksdata graph - node_id: Node ID to get ancestors for - """ - - ancestors = set() - to_visit = [node_id] - - while to_visit: - current_node = to_visit.pop() - predecessors = graph.predecessors(current_node) - for pred in predecessors: - if pred not in ancestors: - ancestors.add(pred) - to_visit.append(pred) - - return ancestors - - def td_get_single_attr_from_edge(graph, edge: tuple[int, int], attrs: Sequence[str]): """Get a single attribute from a edge in a tracksdata graph.""" From 90088b4a0e8eb6827935a89e281fb059758ac0ee Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Thu, 18 Dec 2025 11:17:03 -0800 Subject: [PATCH 15/44] all tests passing! (before adding segmentation to graph) --- src/funtracks/import_export/_v1_format.py | 27 ++------- src/funtracks/utils/tracksdata_utils.py | 67 +++++++++++++++++++++ tests/import_export/test_internal_format.py | 37 +++++++++--- 3 files changed, 100 insertions(+), 31 deletions(-) diff --git a/src/funtracks/import_export/_v1_format.py b/src/funtracks/import_export/_v1_format.py index fc4397f6..63a54b79 100644 --- a/src/funtracks/import_export/_v1_format.py +++ b/src/funtracks/import_export/_v1_format.py @@ -9,6 +9,7 @@ import numpy as np from funtracks.features import FeatureDict +from funtracks.utils.tracksdata_utils import convert_graph_nx_to_td if TYPE_CHECKING: from ..data_model import SolutionTracks, Tracks @@ -18,24 +19,6 @@ ATTRS_FILE = "attrs.json" -def _save_v1_tracks(tracks: Tracks, directory: Path) -> None: - """Only used for testing backward compatibility! - - Currently, saves the graph as a json file in networkx node link data format, - saves the segmentation as a numpy npz file, and saves the time and position - attributes and scale information in an attributes json file. - Will make the directory if it doesn't exist. - - Args: - tracks (Tracks): the tracks to save - directory (Path): The directory to save the tracks in. - """ - directory.mkdir(exist_ok=True, parents=True) - _save_graph(tracks, directory) - _save_seg(tracks, directory) - _save_attrs(tracks, directory) - - def _save_graph(tracks: Tracks, directory: Path) -> None: """Save the graph to file. Currently uses networkx node link data format (and saves it as json). @@ -120,7 +103,7 @@ def load_v1_tracks( Tracks: A tracks object loaded from the given directory """ graph_file = directory / GRAPH_FILE - graph = _load_graph(graph_file) + graph_nx = _load_graph(graph_file) seg_file = directory / SEG_FILE seg = _load_seg(seg_file, seg_required=seg_required) @@ -128,6 +111,8 @@ def load_v1_tracks( attrs_file = directory / ATTRS_FILE attrs = _load_attrs(attrs_file) + graph_td = convert_graph_nx_to_td(graph_nx) + # filtering the warnings because the default values of time_attr and pos_attr are # not None. Therefore, new style Tracks attrs that have features instead of # pos_attr and time_attr will always trigger the warning. Updating default values @@ -142,9 +127,9 @@ def load_v1_tracks( ) tracks: Tracks if solution: - tracks = SolutionTracks(graph, seg, **attrs) + tracks = SolutionTracks(graph_td, seg, **attrs) else: - tracks = Tracks(graph, seg, **attrs) + tracks = Tracks(graph_td, seg, **attrs) return tracks diff --git a/src/funtracks/utils/tracksdata_utils.py b/src/funtracks/utils/tracksdata_utils.py index fd96ebe9..ddeb3578 100644 --- a/src/funtracks/utils/tracksdata_utils.py +++ b/src/funtracks/utils/tracksdata_utils.py @@ -3,6 +3,7 @@ from collections.abc import Sequence from typing import Any +import networkx as nx import numpy as np import polars as pl import tracksdata as td @@ -395,3 +396,69 @@ def get_edge_attr_defaults(graph) -> dict[str, Any]: defaults[col_name] = default_val return defaults + + +def convert_graph_nx_to_td(graph_nx: nx.DiGraph) -> td.graph.GraphView: + """Convert a NetworkX DiGraph to a tracksdata SQLGraph. + + Args: + graph_nx: The NetworkX DiGraph to convert. + + Returns: + A tracksdata SQLGraph representing the same graph. + """ + + # Initialize an empty tracksdata SQLGraph + kwargs = { + "drivername": "sqlite", + "database": ":memory:", + "overwrite": True, + } + graph_td = td.graph.SQLGraph(**kwargs) + + # Get all nodes and edges with attributes + all_nodes = list(graph_nx.nodes(data=True)) + all_edges = list(graph_nx.edges(data=True)) + + # Add node attribute keys to tracksdata graph + for attr, value in all_nodes[0][1].items(): + if attr not in graph_td.node_attr_keys(): + default_value = None if isinstance(value, list) else 0.0 + graph_td.add_node_attr_key(attr, default_value=default_value) + else: + if attr != "t": + raise Warning( + f"Node attribute '{attr}' already exists in " + f"tracksdata graph. Skipping addition." + ) + graph_td.add_node_attr_key(td.DEFAULT_ATTR_KEYS.SOLUTION, default_value=1) + + # Add edge attribute keys to tracksdata graph + for attr, value in all_edges[0][2].items(): + if attr not in graph_td.edge_attr_keys(): + default_value = None if isinstance(value, list) else 0.0 + graph_td.add_edge_attr_key(attr, default_value=default_value) + else: + raise Warning( + f"Edge attribute '{attr}' already exists in tracksdata graph. " + f"Skipping addition." + ) + graph_td.add_edge_attr_key(td.DEFAULT_ATTR_KEYS.SOLUTION, default_value=1) + + # Add node attributes + for node_id, attrs in all_nodes: + attrs[td.DEFAULT_ATTR_KEYS.SOLUTION] = 1 + graph_td.add_node(attrs, index=node_id) + + # Add edges + for source_id, target_id, attrs in all_edges: + attrs[td.DEFAULT_ATTR_KEYS.SOLUTION] = 1 + graph_td.add_edge(source_id, target_id, attrs) + + # Create subgraph (GraphView) with only solution nodes and edges + graph_td_sub = graph_td.filter( + td.NodeAttr(td.DEFAULT_ATTR_KEYS.SOLUTION) == 1, + td.EdgeAttr(td.DEFAULT_ATTR_KEYS.SOLUTION) == 1, + ).subgraph() + + return graph_td_sub diff --git a/tests/import_export/test_internal_format.py b/tests/import_export/test_internal_format.py index b24c99c1..bc2e8740 100644 --- a/tests/import_export/test_internal_format.py +++ b/tests/import_export/test_internal_format.py @@ -1,14 +1,12 @@ import json +import shutil from collections.abc import Sequence from pathlib import Path import pytest -from networkx.utils import graphs_equal from numpy.testing import assert_array_almost_equal -from funtracks.data_model import Tracks from funtracks.import_export._v1_format import ( - _save_v1_tracks, delete_tracks, load_v1_tracks, ) @@ -24,10 +22,9 @@ def test_save_load( is_solution, ): tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=is_solution) - # _save_v1_tracks(tracks, tmp_path) data_path = Path( - f"tests/data/format_v1/test_save_load_{with_seg}_{ndim}_{is_solution}_0" + f"tests/data/format_v1/test_save_load_{is_solution}_{ndim}_{with_seg}_0" ) loaded = load_v1_tracks(data_path, solution=is_solution) @@ -75,7 +72,13 @@ def test_save_load( else: assert loaded.segmentation is None - assert graphs_equal(loaded.graph, tracks.graph) + # graphs_equal doesn't exist for TracksData, so we check properties + assert loaded.graph.node_attr_keys() == tracks.graph.node_attr_keys() + assert loaded.graph.edge_attr_keys() == tracks.graph.edge_attr_keys() + assert loaded.graph.num_nodes() == tracks.graph.num_nodes() + assert loaded.graph.num_edges() == tracks.graph.num_edges() + assert loaded.graph.node_ids() == tracks.graph.node_ids() + assert loaded.graph.edge_ids() == tracks.graph.edge_ids() @pytest.mark.parametrize("with_seg", [True, False]) @@ -88,9 +91,15 @@ def test_delete( 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" - tracks = get_tracks(ndim=ndim, with_seg=with_seg, is_solution=is_solution) - _save_v1_tracks(tracks, tracks_path) + shutil.copytree(reference_path, tracks_path) + + # Delete the copy delete_tracks(tracks_path) with pytest.raises(StopIteration): next(tmp_path.iterdir()) @@ -98,9 +107,16 @@ def test_delete( # for backward compatibility def test_load_without_features(tmp_path, graph_2d_with_computed_features): - tracks = Tracks(graph_2d_with_computed_features, ndim=3) + 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" - _save_v1_tracks(tracks, tracks_path) + 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) @@ -111,6 +127,7 @@ def test_load_without_features(tmp_path, graph_2d_with_computed_features): 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" From 17aa6a7c78b4ea446e90e776aa58ccee0b0e366f Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Thu, 18 Dec 2025 14:06:03 -0800 Subject: [PATCH 16/44] clean up: del deepcopy(graph), del NodeAttr/EdgeAttr, tracks.delete_feature, removed all nx, made tests easier to read --- docs/features.md | 6 +- src/funtracks/annotators/_edge_annotator.py | 2 +- src/funtracks/annotators/_track_annotator.py | 15 +---- src/funtracks/data_model/solution_tracks.py | 8 +-- src/funtracks/data_model/tracks.py | 27 ++++++-- .../import_export/_tracks_builder.py | 5 +- src/funtracks/import_export/_utils.py | 6 +- src/funtracks/import_export/_v1_format.py | 65 ------------------- src/funtracks/import_export/geff/_export.py | 13 ++-- src/funtracks/utils/tracksdata_utils.py | 6 +- tests/actions/test_add_delete_edge.py | 37 ++++------- tests/actions/test_update_node_segs.py | 8 +-- tests/annotators/test_annotator_registry.py | 4 +- tests/annotators/test_edge_annotator.py | 5 +- tests/conftest.py | 6 +- tests/data_model/test_tracks.py | 11 ++-- tests/import_export/test_csv_export.py | 4 +- tests/import_export/test_internal_format.py | 9 +-- 18 files changed, 86 insertions(+), 151 deletions(-) diff --git a/docs/features.md b/docs/features.md index 132ba2a1..24ed1e53 100644 --- a/docs/features.md +++ b/docs/features.md @@ -204,6 +204,7 @@ These features are **automatically checked** during initialization: **Scenario 1: Loading tracks from CSV with pre-computed features** ```python # CSV has columns: id, time, y, x, area, track_id +# TODO: load_graph_from_csv no longer exists! graph = load_graph_from_csv(df) # Nodes already have area, track_id tracks = SolutionTracks(graph, segmentation=seg) # Auto-detection: pos, area, track_id exist → activate without recomputing @@ -212,8 +213,9 @@ tracks = SolutionTracks(graph, segmentation=seg) **Scenario 2: Creating tracks from raw segmentation** ```python # Graph has no features yet -graph = nx.DiGraph() -graph.add_node(1, time=0) +#TODO: test these examples with the new tracksdata api +graph = create_empty_graphview_graph() +graph.add_node(index=1, attrs={"t": 0, "solution": 1}) tracks = Tracks(graph, segmentation=seg) # Auto-detection: pos, area don't exist → compute them ``` diff --git a/src/funtracks/annotators/_edge_annotator.py b/src/funtracks/annotators/_edge_annotator.py index 256ba161..b640f69c 100644 --- a/src/funtracks/annotators/_edge_annotator.py +++ b/src/funtracks/annotators/_edge_annotator.py @@ -122,7 +122,7 @@ def _iou_update( # anything left has IOU of 0 for edge in edges: - self.tracks._set_edge_attr(edge, self.iou_key, 0) + self.tracks._set_edge_attr(edge, self.iou_key, 0.0) def update(self, action: BasicAction): """Update the edge features based on the action. diff --git a/src/funtracks/annotators/_track_annotator.py b/src/funtracks/annotators/_track_annotator.py index 34248015..c89c273f 100644 --- a/src/funtracks/annotators/_track_annotator.py +++ b/src/funtracks/annotators/_track_annotator.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING import rustworkx as rx -import tracksdata as td from funtracks.actions import AddNode, DeleteNode, UpdateTrackID from funtracks.data_model import SolutionTracks @@ -215,7 +214,7 @@ def _assign_tracklet_ids(self) -> None: After removing division edges, each connected component will get a unique ID, and the relevant class attributes will be updated. """ - graph_copy = td.graph.IndexedRXGraph.from_other(self.tracks.graph) + graph_copy = self.tracks.graph.detach().filter().subgraph() parents = [ node @@ -224,7 +223,6 @@ def _assign_tracklet_ids(self) -> None: ) if degree >= 2 ] - intertrack_edges = [] # Remove all intertrack edges from a copy of the original graph for parent in parents: @@ -233,16 +231,7 @@ def _assign_tracklet_ids(self) -> None: for daughter in daughters: # remove edge from graph, by setting solution to 0 + subgraphing - edge_id = graph_copy.edge_id(parent, daughter) - graph_copy.update_edge_attrs( - edge_ids=[edge_id], attrs={td.DEFAULT_ATTR_KEYS.SOLUTION: [0]} - ) - graph_copy = graph_copy.filter( - td.NodeAttr(td.DEFAULT_ATTR_KEYS.SOLUTION) == 1, - td.EdgeAttr(td.DEFAULT_ATTR_KEYS.SOLUTION) == 1, - ).subgraph() - - intertrack_edges.append((parent, daughter)) + graph_copy.remove_edge(parent, daughter) track_id = 1 for tracklet in rx.weakly_connected_components(graph_copy.rx_graph): diff --git a/src/funtracks/data_model/solution_tracks.py b/src/funtracks/data_model/solution_tracks.py index f04db12a..b165d999 100644 --- a/src/funtracks/data_model/solution_tracks.py +++ b/src/funtracks/data_model/solution_tracks.py @@ -2,8 +2,8 @@ from typing import TYPE_CHECKING -import networkx as nx import numpy as np +import tracksdata as td from funtracks.features import FeatureDict @@ -20,7 +20,7 @@ class SolutionTracks(Tracks): def __init__( self, - graph: nx.DiGraph, + graph: td.graph.GraphView, segmentation: np.ndarray | None = None, time_attr: str | None = None, pos_attr: str | tuple[str] | list[str] | None = None, @@ -35,8 +35,8 @@ def __init__( TrackAnnotator is automatically added to manage track IDs. Args: - graph (nx.DiGraph): NetworkX directed graph with nodes as detections and - edges as links. + graph (td.graph.GraphView): NetworkX directed graph with nodes as detections + and edges as links. segmentation (np.ndarray | None): Optional segmentation array where labels match node IDs. Required for computing region properties (area, etc.). time_attr (str | None): Graph attribute name for time. Defaults to "time" diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index f26fdbc5..687875af 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -576,12 +576,7 @@ def disable_features(self, feature_keys: list[str]) -> None: # Remove from FeatureDict for key in feature_keys: if key in self.features: - del self.features[key] - # TODO Teun: do we need to remove feature here from graph as well? - # Currently the tests want to maintain the values on the graph, - # but this might become a problem when you want to add a node later, - # and you NEED to add all existing attributes, sooo conclusion: - # we will remove all the features from the graph, so change the tests! + self.delete_feature(key) def add_feature(self, key: str, feature: Feature) -> None: """Add a feature to the features dictionary and perform graph operations. @@ -601,3 +596,23 @@ def add_feature(self, key: str, feature: Feature) -> None: self.graph.add_node_attr_key(key, default_value=feature["default_value"]) elif feature["feature_type"] == "edge" and key not in self.graph.edge_attr_keys(): self.graph.add_edge_attr_key(key, default_value=feature["default_value"]) + + def delete_feature(self, key: str) -> None: + """Delete a feature from the features dictionary and perform graph operations. + + This is the preferred way to delete features as it ensures both the + features dictionary is updated and any necessary graph operations are performed. + + Args: + key: The key for the feature to delete + """ + # Remove from the features dictionary + del self.features[key] + + # Perform custom graph operations when a feature is deleted + if feature := self.annotators.all_features.get(key): + feature_type = feature[0]["feature_type"] + if feature_type == "node" and key in self.graph.node_attr_keys(): + self.graph.remove_node_attr_key(key) + elif feature_type == "edge" and key in self.graph.edge_attr_keys(): + self.graph.remove_edge_attr_key(key) diff --git a/src/funtracks/import_export/_tracks_builder.py b/src/funtracks/import_export/_tracks_builder.py index 0bdca09f..c9da7bde 100644 --- a/src/funtracks/import_export/_tracks_builder.py +++ b/src/funtracks/import_export/_tracks_builder.py @@ -11,7 +11,6 @@ from pathlib import Path from typing import TYPE_CHECKING, Literal -import networkx as nx import numpy as np import tracksdata as td from geff._typing import InMemoryGeff @@ -464,10 +463,10 @@ def construct_graph( def handle_segmentation( self, - graph: nx.DiGraph, + graph: td.graph.GraphView, segmentation: Path | np.ndarray | None, scale: list[float] | None, - ) -> tuple[np.ndarray | None, list[float] | None, nx.DiGraph]: + ) -> tuple[np.ndarray | None, list[float] | None, td.graph.GraphView]: """Load, validate, and optionally relabel segmentation. Common logic shared across all formats. diff --git a/src/funtracks/import_export/_utils.py b/src/funtracks/import_export/_utils.py index 6539209e..f022fc19 100644 --- a/src/funtracks/import_export/_utils.py +++ b/src/funtracks/import_export/_utils.py @@ -2,8 +2,8 @@ from typing import TYPE_CHECKING -import networkx as nx import numpy as np +import tracksdata as td from funtracks.data_model.tracks import Tracks @@ -70,7 +70,9 @@ def infer_dtype_from_array(arr: ArrayLike) -> ValueType: return "str" -def filter_graph_with_ancestors(graph: nx.DiGraph, nodes_to_keep: set[int]) -> list[int]: +def filter_graph_with_ancestors( + graph: td.graph.GraphView, nodes_to_keep: set[int] +) -> list[int]: """Filter a graph to keep only the nodes in `nodes_to_keep` and their ancestors. Args: diff --git a/src/funtracks/import_export/_v1_format.py b/src/funtracks/import_export/_v1_format.py index 63a54b79..dcfe5e3c 100644 --- a/src/funtracks/import_export/_v1_format.py +++ b/src/funtracks/import_export/_v1_format.py @@ -19,71 +19,6 @@ ATTRS_FILE = "attrs.json" -def _save_graph(tracks: Tracks, directory: Path) -> None: - """Save the graph to file. Currently uses networkx node link data - format (and saves it as json). - - Args: - tracks (Tracks): the tracks to save the graph of - directory (Path): The directory in which to save the graph file. - """ - # TODO Teun: change to geff! - graph_file = directory / GRAPH_FILE - graph_data = nx.node_link_data(tracks.graph, edges="links") - - def convert_np_types(data): - """Recursively convert numpy types to native Python types.""" - - if isinstance(data, dict): - return {key: convert_np_types(value) for key, value in data.items()} - elif isinstance(data, list): - return [convert_np_types(item) for item in data] - elif isinstance(data, np.ndarray): - return data.tolist() # Convert numpy arrays to Python lists - elif isinstance(data, np.integer): - return int(data) # Convert numpy integers to Python int - elif isinstance(data, np.floating): - return float(data) # Convert numpy floats to Python float - else: - return data # Return the data as-is if it's already a native Python type - - graph_data = convert_np_types(graph_data) - with open(graph_file, "w") as f: - json.dump(graph_data, f) - - -def _save_seg(tracks: Tracks, directory: Path) -> None: - """Save a segmentation as a numpy array using np.save. In the future, - could be changed to use zarr or other file types. - - Args: - tracks (Tracks): the tracks to save the segmentation of - directory (Path): The directory in which to save the segmentation - """ - if tracks.segmentation is not None: - out_path = directory / SEG_FILE - np.save(out_path, tracks.segmentation) - - -def _save_attrs(tracks: Tracks, directory: Path) -> None: - """Save the and scale, ndim, and features in a json file in the given directory. - - Args: - tracks (Tracks): the tracks to save the attributes of - directory (Path): The directory in which to save the attributes - """ - out_path = directory / ATTRS_FILE - attrs_dict = { - "scale": tracks.scale - if not isinstance(tracks.scale, np.ndarray) - else tracks.scale.tolist(), - "ndim": tracks.ndim, - "features": tracks.features.dump_json(), - } - with open(out_path, "w") as f: - json.dump(attrs_dict, f) - - def load_v1_tracks( directory: Path, seg_required: bool = False, solution: bool = False ) -> Tracks | SolutionTracks: diff --git a/src/funtracks/import_export/geff/_export.py b/src/funtracks/import_export/geff/_export.py index 9b81b5c4..c86d2aa4 100644 --- a/src/funtracks/import_export/geff/_export.py +++ b/src/funtracks/import_export/geff/_export.py @@ -7,8 +7,8 @@ ) import geff_spec -import networkx as nx import numpy as np +import tracksdata as td from geff_spec import GeffMetadata from funtracks.utils import remove_tilde, setup_zarr_array, setup_zarr_group @@ -28,7 +28,7 @@ def export_to_geff( node_ids: set[int] | None = None, zarr_format: Literal[2, 3] = 2, ): - """Export the Tracks nxgraph to geff. + """Export the Tracks graph to geff. Args: tracks (Tracks): Tracks object containing a graph to save. @@ -83,7 +83,7 @@ def export_to_geff( metadata = GeffMetadata( geff_version=geff_spec.__version__, - directed=isinstance(graph, nx.DiGraph), + directed=True, node_props_metadata={}, edge_props_metadata={}, axes=axes, @@ -153,7 +153,7 @@ def export_to_geff( graph.to_geff(geff_store=tracks_path, geff_metadata=metadata, zarr_format=zarr_format) -def split_position_attr(tracks: Tracks) -> tuple[nx.DiGraph, list[str] | None]: +def split_position_attr(tracks: Tracks) -> tuple[td.graph.GraphView, list[str] | None]: # TODO: this exists in unsqueeze in geff somehow? """Spread the spatial coordinates to separate node attrs in order to export to geff format. @@ -163,8 +163,9 @@ def split_position_attr(tracks: Tracks) -> tuple[nx.DiGraph, list[str] | None]: converted. Returns: - tuple[nx.DiGraph, list[str]]: graph with a separate positional attribute for each - coordinate, and the axis names used to store the separate attributes + tuple[td.graph.GraphView, list[str] | None]: graph with a separate positional + attribute for each coordinate, and the axis names used to store the + separate attributes """ pos_key = tracks.features.position_key diff --git a/src/funtracks/utils/tracksdata_utils.py b/src/funtracks/utils/tracksdata_utils.py index ddeb3578..d4838b5f 100644 --- a/src/funtracks/utils/tracksdata_utils.py +++ b/src/funtracks/utils/tracksdata_utils.py @@ -345,7 +345,11 @@ def td_relabel_nodes(graph, mapping: dict[int, int]) -> td.graph.SQLGraph: } new_graph.add_edge(source_id, target_id, attrs) - return new_graph + new_graph_sub = new_graph.filter( + td.NodeAttr(td.DEFAULT_ATTR_KEYS.SOLUTION) == 1, + td.EdgeAttr(td.DEFAULT_ATTR_KEYS.SOLUTION) == 1, + ).subgraph() + return new_graph_sub def get_node_attr_defaults(graph) -> dict[str, Any]: diff --git a/tests/actions/test_add_delete_edge.py b/tests/actions/test_add_delete_edge.py index b237d590..78bbae83 100644 --- a/tests/actions/test_add_delete_edge.py +++ b/tests/actions/test_add_delete_edge.py @@ -1,8 +1,8 @@ import copy -import polars as pl import pytest from numpy.testing import assert_array_almost_equal +from polars.testing import assert_frame_equal from funtracks.actions import ( ActionGroup, @@ -37,18 +37,13 @@ def test_add_delete_edges(get_tracks, ndim, with_seg): # 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: - for edge in tracks.graph.edge_list(): - edge_id_tracks = tracks.graph.edge_id(edge[0], edge[1]) - edge_id_graph = reference_graph.edge_id(edge[0], edge[1]) - assert tracks.graph.edge_attrs().filter(pl.col("edge_id") == edge_id_tracks)[ - iou_key - ].item() == pytest.approx( - reference_graph.edge_attrs() - .filter(pl.col("edge_id") == edge_id_graph)[iou_key] - .item(), - abs=0.01, - ) assert_array_almost_equal(tracks.segmentation, reference_seg) # TODO Teun: the next line fails: @@ -62,19 +57,13 @@ def test_add_delete_edges(get_tracks, ndim, with_seg): 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: - for edge in tracks.graph.edge_list(): - edge_id_tracks = tracks.graph.edge_id(edge[0], edge[1]) - edge_id_graph = reference_graph.edge_id(edge[0], edge[1]) - - assert tracks.graph.edge_attrs().filter(pl.col("edge_id") == edge_id_tracks)[ - iou_key - ].item() == pytest.approx( - reference_graph.edge_attrs() - .filter(pl.col("edge_id") == edge_id_graph)[iou_key] - .item(), - abs=0.01, - ) assert_array_almost_equal(tracks.segmentation, reference_seg) diff --git a/tests/actions/test_update_node_segs.py b/tests/actions/test_update_node_segs.py index c1938a5e..49e1e345 100644 --- a/tests/actions/test_update_node_segs.py +++ b/tests/actions/test_update_node_segs.py @@ -1,9 +1,7 @@ -import copy - import numpy as np import pytest from numpy.testing import assert_array_almost_equal -from polars.testing import assert_series_not_equal +from polars.testing import assert_series_equal from funtracks.actions import ( UpdateNodeSeg, @@ -14,7 +12,7 @@ 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 = copy.deepcopy(tracks.graph) + reference_graph = tracks.graph.detach().filter().subgraph() original_seg = tracks.segmentation.copy() original_area = tracks.graph[1]["area"] @@ -38,7 +36,7 @@ def test_update_node_segs(get_tracks, ndim): inverse = action.inverse() assert set(tracks.graph.node_ids()) == set(reference_graph.node_ids()) - assert_series_not_equal( + assert_series_equal( reference_graph[1]["pos"], tracks.graph[1]["pos"], ) diff --git a/tests/annotators/test_annotator_registry.py b/tests/annotators/test_annotator_registry.py index 2a002b5c..f44eab45 100644 --- a/tests/annotators/test_annotator_registry.py +++ b/tests/annotators/test_annotator_registry.py @@ -95,8 +95,8 @@ def test_enable_disable_features(graph_2d_with_computed_features, segmentation_2 assert "iou" in tracks.features assert "circularity" in tracks.features - # Values still exist on the graph (disabling doesn't erase computed values) - assert tracks.graph[1]["area"] is not None + # Values no longer exist in the graph for tracksdata + # assert tracks.graph[1]["area"] is not None # Disable the remaining enabled features tracks.disable_features(["pos", "iou", "circularity"]) diff --git a/tests/annotators/test_edge_annotator.py b/tests/annotators/test_edge_annotator.py index 60ba9e4f..495b3e13 100644 --- a/tests/annotators/test_edge_annotator.py +++ b/tests/annotators/test_edge_annotator.py @@ -82,7 +82,6 @@ def test_add_remove_feature(self, get_graph, get_segmentation, ndim): node_id = 3 edge_id = (1, 3) to_remove_key = next(iter(ann.features)) - orig_iou = tracks.get_edge_attr(edge_id, to_remove_key, required=True) # remove the IOU from computation (tracks level) tracks.disable_features([to_remove_key]) @@ -96,8 +95,8 @@ def test_add_remove_feature(self, get_graph, get_segmentation, ndim): for a in tracks.annotators: if isinstance(a, EdgeAnnotator): a.compute() - # IoU was computed before removal, so value is still there - assert tracks.get_edge_attr(edge_id, to_remove_key, required=True) == orig_iou + # IoU feature was deleted, so IoU is no longer present on the graph + # assert tracks.get_edge_attr(edge_id, to_remove_key, required=True) == orig_iou # add it back in tracks.enable_features([to_remove_key]) diff --git a/tests/conftest.py b/tests/conftest.py index 2414a012..f950ccf6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,3 @@ -import copy from collections.abc import Callable from typing import TYPE_CHECKING @@ -316,7 +315,6 @@ def _make_tracks( @pytest.fixture def graph_2d_list(tmp_path) -> td.graph.GraphView: - # graph = nx.DiGraph() db_path = str(tmp_path / "graph_2d_list.db") graph = create_empty_graphview_graph(database=db_path) @@ -421,8 +419,8 @@ def _get_graph(ndim: int, with_features: str = "clean") -> td.graph.GraphView: f"got {with_features}" ) - # Return a deep copy to avoid fixture pollution - return copy.deepcopy(graph) + # Deepcopy alternative for tracksdata graph + return graph.detach().filter().subgraph() return _get_graph diff --git a/tests/data_model/test_tracks.py b/tests/data_model/test_tracks.py index 25afdf15..fb77295f 100644 --- a/tests/data_model/test_tracks.py +++ b/tests/data_model/test_tracks.py @@ -1,6 +1,6 @@ -import networkx as nx import numpy as np import pytest +import tracksdata as td from funtracks.data_model import Tracks from funtracks.utils.tracksdata_utils import ( @@ -10,7 +10,9 @@ track_attrs = {"time_attr": "t", "tracklet_attr": "track_id"} -def test_create_tracks(graph_3d_with_computed_features: nx.DiGraph, segmentation_3d): +def test_create_tracks( + graph_3d_with_computed_features: td.graph.GraphView, segmentation_3d +): # create empty tracks empty_graph = create_empty_graphview_graph() tracks = Tracks(graph=empty_graph, ndim=3, **track_attrs) # type: ignore[arg-type] @@ -227,8 +229,9 @@ def test_set_pixels_no_segmentation(graph_2d_with_computed_features): def test_compute_ndim_errors(): - g = nx.DiGraph() - g.add_node(1, time=0, pos=[0, 0, 0]) + g = create_empty_graphview_graph() + g.add_node_attr_key("pos", default_value=None) + g.add_node(index=1, attrs={"t": 0, "pos": [0, 0, 0], "solution": True}) # seg ndim = 3, scale ndim = 2, provided ndim = 4 -> mismatch seg = np.zeros((2, 2, 2)) with pytest.raises(ValueError, match="Dimensions from segmentation"): diff --git a/tests/import_export/test_csv_export.py b/tests/import_export/test_csv_export.py index e6ff1fe8..5f3db961 100644 --- a/tests/import_export/test_csv_export.py +++ b/tests/import_export/test_csv_export.py @@ -25,9 +25,9 @@ def test_export_solution_to_csv(get_tracks, tmp_path, ndim, expected_header): # Check first data line (node 1: t=0, pos=[50, 50] or [50, 50, 50], track_id=1) if ndim == 3: - expected_line1 = ["0", "50", "50", "1", "", "1"] + expected_line1 = ["0", "50.0", "50.0", "1", "", "1"] else: - expected_line1 = ["0", "50", "50", "50", "1", "", "1"] + expected_line1 = ["0", "50.0", "50.0", "50.0", "1", "", "1"] assert lines[1].strip().split(",") == expected_line1 diff --git a/tests/import_export/test_internal_format.py b/tests/import_export/test_internal_format.py index bc2e8740..5de30e25 100644 --- a/tests/import_export/test_internal_format.py +++ b/tests/import_export/test_internal_format.py @@ -73,12 +73,13 @@ def test_save_load( assert loaded.segmentation is None # graphs_equal doesn't exist for TracksData, so we check properties - assert loaded.graph.node_attr_keys() == tracks.graph.node_attr_keys() - assert loaded.graph.edge_attr_keys() == tracks.graph.edge_attr_keys() + 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 loaded.graph.node_ids() == tracks.graph.node_ids() - assert loaded.graph.edge_ids() == tracks.graph.edge_ids() + 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]) From 14b246d08b6e88a3e0c4e2e82d40408129040092 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Thu, 18 Dec 2025 14:42:38 -0800 Subject: [PATCH 17/44] fix iou type --- src/funtracks/annotators/_edge_annotator.py | 4 ++-- src/funtracks/utils/tracksdata_utils.py | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/funtracks/annotators/_edge_annotator.py b/src/funtracks/annotators/_edge_annotator.py index b640f69c..477b762c 100644 --- a/src/funtracks/annotators/_edge_annotator.py +++ b/src/funtracks/annotators/_edge_annotator.py @@ -174,10 +174,10 @@ def update(self, action: BasicAction): f"in frame {end_time}: updating edge IOU value to 0", stacklevel=2, ) - self.tracks._set_edge_attr(edge, self.iou_key, 0) + self.tracks._set_edge_attr(edge, self.iou_key, 0.0) else: iou_list = _compute_ious(masked_start, masked_end) - iou = 0 if len(iou_list) == 0 else iou_list[0][2] + iou = 0.0 if len(iou_list) == 0 else iou_list[0][2] self.tracks._set_edge_attr(edge, self.iou_key, iou) def change_key(self, old_key: str, new_key: str) -> None: diff --git a/src/funtracks/utils/tracksdata_utils.py b/src/funtracks/utils/tracksdata_utils.py index d4838b5f..8e028b8e 100644 --- a/src/funtracks/utils/tracksdata_utils.py +++ b/src/funtracks/utils/tracksdata_utils.py @@ -287,8 +287,14 @@ def pixels_to_td_mask( def td_get_single_attr_from_edge(graph, edge: tuple[int, int], attrs: Sequence[str]): """Get a single attribute from a edge in a tracksdata graph.""" - # TODO Teun: do graph.edge_id() - # TODO Teun: AND do edge_attrs(key) directly to prevent loading all attributes + # TODO Teun: later opdate to: + # edge_id = graph.edge_id(edge[0], edge[1]) + # item = graph.edge_attrs(attr_keys=attrs).filter(pl.col("edge_id") == edge_id) + # .select(attrs).item()once tracksdata supports default values. Right now, polars + # crashes when the edge attributes have different types. We either need a + # df = pl.DataFrame(data, strict=False).with_columns(... in line 171 in + # tracksdata/graph/rx, or tracksdata needs to support default values for missing + # attributes. The implementation below can be slow for large graphs. item = graph.filter(node_ids=[edge[0], edge[1]]).edge_attrs()[attrs].item() return item From 2eea4d3e48747d7dd9287943d1c1c1c8f1902762 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Wed, 7 Jan 2026 16:20:54 -0800 Subject: [PATCH 18/44] wip tests passing with segmetation on graph, todo is import/export --- pyproject.toml | 3 +- src/funtracks/actions/add_delete_node.py | 21 +- src/funtracks/actions/update_segmentation.py | 2 +- src/funtracks/annotators/_edge_annotator.py | 6 +- .../annotators/_regionprops_annotator.py | 10 +- src/funtracks/data_model/solution_tracks.py | 11 +- src/funtracks/data_model/tracks.py | 172 +++++++++++- .../import_export/_tracks_builder.py | 2 +- .../user_actions/user_update_segmentation.py | 8 +- src/funtracks/utils/tracksdata_utils.py | 252 +++++++++++++++-- tests/actions/test_add_delete_edge.py | 5 +- tests/actions/test_add_delete_nodes.py | 47 +++- tests/actions/test_update_node_segs.py | 4 +- tests/annotators/test_annotator_registry.py | 43 +-- tests/annotators/test_edge_annotator.py | 74 ++--- tests/annotators/test_graph_annotator.py | 6 +- .../annotators/test_regionprops_annotator.py | 66 +++-- tests/conftest.py | 257 +++++++++++------- tests/data_model/test_solution_tracks.py | 23 +- tests/data_model/test_tracks.py | 92 +++---- tests/import_export/test_internal_format.py | 2 +- tests/import_export/test_name_mapping.py | 22 +- .../user_actions/test_user_add_delete_node.py | 2 +- .../test_user_update_segmentation.py | 9 +- tests/utils/test_tracksdata_utils.py | 131 +++++++++ 25 files changed, 942 insertions(+), 328 deletions(-) create mode 100644 tests/utils/test_tracksdata_utils.py diff --git a/pyproject.toml b/pyproject.toml index 4920e7fe..d46f4161 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies =[ "pandas>=2.3.3", "zarr>=2.18,<4", "numcodecs>=0.13,<0.16", - "tracksdata@git+https://github.com/royerlab/tracksdata@b35a0f111f8d39baec00245a457d65941222174b", + "tracksdata[spatial]@git+https://github.com/royerlab/tracksdata@b35a0f111f8d39baec00245a457d65941222174b", ] [project.urls] @@ -108,6 +108,7 @@ unfixable = [ [tool.mypy] ignore_missing_imports = true python_version = "3.10" +explicit_package_bases = true [tool.coverage.report] exclude_also = [ diff --git a/src/funtracks/actions/add_delete_node.py b/src/funtracks/actions/add_delete_node.py index 5f7284ec..c522dbf9 100644 --- a/src/funtracks/actions/add_delete_node.py +++ b/src/funtracks/actions/add_delete_node.py @@ -16,6 +16,7 @@ from funtracks.utils.tracksdata_utils import ( compute_node_attrs_from_masks, compute_node_attrs_from_pixels, + pixels_to_td_mask, ) @@ -96,6 +97,13 @@ def _apply(self) -> None: ) # Extract single values from lists (since we passed one pixel set) computed_attrs = {key: value[0] for key, value in computed_attrs.items()} + # if masks are not given, calculate them from the pixels + if "mask" not in attrs: + mask_obj, _ = pixels_to_td_mask( + self.pixels, self.tracks.ndim, self.tracks.scale + ) + attrs[td.DEFAULT_ATTR_KEYS.MASK] = mask_obj + attrs[td.DEFAULT_ATTR_KEYS.BBOX] = mask_obj.bbox elif "mask" in attrs: computed_attrs = compute_node_attrs_from_masks( attrs["mask"], self.tracks.ndim, self.tracks.scale @@ -213,6 +221,17 @@ def __init__( if val is not None: self.attributes[key] = val + if td.DEFAULT_ATTR_KEYS.MASK in self.tracks.graph.node_attr_keys(): + self.attributes[td.DEFAULT_ATTR_KEYS.MASK] = self.tracks.get_nodes_attr( + [self.node], td.DEFAULT_ATTR_KEYS.MASK + )[0] + self.attributes[td.DEFAULT_ATTR_KEYS.BBOX] = self.tracks.get_nodes_attr( + [self.node], td.DEFAULT_ATTR_KEYS.BBOX + )[0] + self.attributes[td.DEFAULT_ATTR_KEYS.SOLUTION] = self.tracks.get_nodes_attr( + [self.node], td.DEFAULT_ATTR_KEYS.SOLUTION + )[0] + self.pixels = self.tracks.get_pixels(node) if pixels is None else pixels self._apply() @@ -229,8 +248,6 @@ def _apply(self) -> None: set pixels to 0 if self.pixels is provided - Remove nodes from graph """ - if self.pixels is not None: - self.tracks.set_pixels(self.pixels, 0) self.tracks.graph.remove_node(self.node) self.tracks.notify_annotators(self) diff --git a/src/funtracks/actions/update_segmentation.py b/src/funtracks/actions/update_segmentation.py index ce275bd7..d067459e 100644 --- a/src/funtracks/actions/update_segmentation.py +++ b/src/funtracks/actions/update_segmentation.py @@ -48,5 +48,5 @@ def inverse(self) -> BasicAction: def _apply(self) -> None: """Set new attributes""" value = self.node if self.added else 0 - self.tracks.set_pixels(self.pixels, value) + self.tracks.set_pixels(self.pixels, value, self.node) self.tracks.notify_annotators(self) diff --git a/src/funtracks/annotators/_edge_annotator.py b/src/funtracks/annotators/_edge_annotator.py index 477b762c..5cfd3c5b 100644 --- a/src/funtracks/annotators/_edge_annotator.py +++ b/src/funtracks/annotators/_edge_annotator.py @@ -95,7 +95,7 @@ def compute(self, feature_keys: list[str] | None = None) -> None: for node in nodes_in_t: for succ in self.tracks.graph.successors(node): edges.append((node, succ)) - self._iou_update(edges, seg[t], seg[t + 1]) + self._iou_update(edges, np.asarray(seg[t]), np.asarray(seg[t + 1])) def _iou_update( self, @@ -164,8 +164,8 @@ def update(self, action: BasicAction): source, target = edge start_time = self.tracks.get_time(source) end_time = self.tracks.get_time(target) - start_seg = self.tracks.segmentation[start_time] - end_seg = self.tracks.segmentation[end_time] + start_seg = np.asarray(self.tracks.segmentation[start_time]) + end_seg = np.asarray(self.tracks.segmentation[end_time]) masked_start = np.where(start_seg == source, source, 0) masked_end = np.where(end_seg == target, target, 0) if np.max(masked_start) == 0 or np.max(masked_end) == 0: diff --git a/src/funtracks/annotators/_regionprops_annotator.py b/src/funtracks/annotators/_regionprops_annotator.py index 5dcd8a80..f4c669c2 100644 --- a/src/funtracks/annotators/_regionprops_annotator.py +++ b/src/funtracks/annotators/_regionprops_annotator.py @@ -167,7 +167,7 @@ def compute(self, feature_keys: list[str] | None = None) -> None: seg = self.tracks.segmentation for t in range(seg.shape[0]): - self._regionprops_update(seg[t], keys_to_compute) + self._regionprops_update(np.asarray(seg[t]), keys_to_compute) def _regionprops_update(self, seg_frame: np.ndarray, feature_keys: list[str]) -> None: """Perform the regionprops computation and update all feature values for a @@ -187,7 +187,11 @@ def _regionprops_update(self, seg_frame: np.ndarray, feature_keys: list[str]) -> for key in feature_keys: value = getattr(region, self.regionprops_names[key]) if isinstance(value, tuple): - value = list(value) + value = [ + float(v) for v in value + ] # cannot be a list of np.arrays with single values + elif isinstance(value, np.floating): + value = float(value) self.tracks._set_node_attr(node, key, value) def update(self, action: BasicAction): @@ -214,7 +218,7 @@ def update(self, action: BasicAction): return time = self.tracks.get_time(node) - seg_frame = self.tracks.segmentation[time] + seg_frame = np.asarray(self.tracks.segmentation[time]) masked_frame = np.where(seg_frame == node, node, 0) if np.max(masked_frame) == 0: diff --git a/src/funtracks/data_model/solution_tracks.py b/src/funtracks/data_model/solution_tracks.py index b165d999..9377b6d9 100644 --- a/src/funtracks/data_model/solution_tracks.py +++ b/src/funtracks/data_model/solution_tracks.py @@ -2,7 +2,6 @@ from typing import TYPE_CHECKING -import numpy as np import tracksdata as td from funtracks.features import FeatureDict @@ -21,7 +20,7 @@ class SolutionTracks(Tracks): def __init__( self, graph: td.graph.GraphView, - segmentation: np.ndarray | None = None, + segmentation_shape: tuple[int, ...] | None = None, time_attr: str | None = None, pos_attr: str | tuple[str] | list[str] | None = None, tracklet_attr: str | None = None, @@ -37,8 +36,8 @@ def __init__( Args: graph (td.graph.GraphView): NetworkX directed graph with nodes as detections and edges as links. - segmentation (np.ndarray | None): Optional segmentation array where labels - match node IDs. Required for computing region properties (area, etc.). + segmentation_shape (tuple[int, ...] | None): Shape of the segmentation + volume. If None, segmentation-related features cannot be computed. time_attr (str | None): Graph attribute name for time. Defaults to "time" if None. pos_attr (str | tuple[str, ...] | list[str] | None): Graph attribute @@ -60,7 +59,7 @@ def __init__( """ super().__init__( graph, - segmentation=segmentation, + segmentation_shape=segmentation_shape, time_attr=time_attr, pos_attr=pos_attr, tracklet_attr=tracklet_attr, @@ -105,7 +104,7 @@ def from_tracks(cls, tracks: Tracks): soln_tracks = cls( tracks.graph, - segmentation=tracks.segmentation, + segmentation_shape=tracks.segmentation_shape, scale=tracks.scale, ndim=tracks.ndim, features=tracks.features, diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index 687875af..ca6e81e0 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -1,5 +1,6 @@ from __future__ import annotations +import itertools import logging from collections.abc import Iterable, Sequence from typing import ( @@ -10,10 +11,17 @@ from warnings import warn import numpy as np +import tracksdata as td from psygnal import Signal +from tracksdata.array import GraphArrayView from funtracks.features import Feature, FeatureDict, Position, Time -from funtracks.utils.tracksdata_utils import td_get_single_attr_from_edge +from funtracks.utils.tracksdata_utils import ( + combine_td_masks, + pixels_to_td_mask, + subtract_td_masks, + td_get_single_attr_from_edge, +) if TYPE_CHECKING: import tracksdata as td @@ -40,9 +48,10 @@ class Tracks: Attributes: graph (td.graph.GraphView): A graph with nodes representing detections and and edges representing links across time. - segmentation (np.ndarray | None): An optional segmentation that - accompanies the tracking graph. If a segmentation is provided, + segmentation_shape (tuple[int, ...] | None): An optional segmentation shape that + accompanies the tracking graph. If a segmentation_shape is provided, the node ids in the graph must match the segmentation labels. + Providing None assumes no segmentation on the graph. features (FeatureDict): Dictionary of features tracked on graph nodes/edges. annotators (AnnotatorRegistry): List of annotators that compute features. scale (list[float] | None): How much to scale each dimension by, including time. @@ -54,7 +63,7 @@ class Tracks: def __init__( self, graph: td.graph.GraphView, - segmentation: np.ndarray | None = None, + segmentation_shape: tuple[int, ...] | None = None, time_attr: str | None = None, pos_attr: str | tuple[str, ...] | list[str] | None = None, tracklet_attr: str | None = None, @@ -67,8 +76,8 @@ def __init__( Args: graph (td.graph.GraphView): NetworkX directed graph with nodes as detections and edges as links. - segmentation (np.ndarray | None): Optional segmentation array where labels - match node IDs. Required for computing region properties (area, etc.). + segmentation_shape (tuple[int, ...] | None): Optional segmentation shape where + labels match node IDs. Required for computing region props (area, etc.). time_attr (str | None): Graph attribute name for time. Defaults to "time" if None. pos_attr (str | tuple[str, ...] | list[str] | None): Graph attribute @@ -89,9 +98,22 @@ def __init__( area, track_id) are auto-detected by checking if they exist on the graph. """ self.graph = graph - self.segmentation = segmentation + self.segmentation_shape = segmentation_shape + if segmentation_shape is not None: + try: + array_view = GraphArrayView( + graph=graph, shape=segmentation_shape, attr_key="node_id", offset=0 + ) + self.segmentation = array_view + except (ValueError, KeyError) as err: + raise ValueError( + "segmentation_shape is incompatible with graph, " + "check if mask and bbox attrs exist on nodes" + ) from err + else: + self.segmentation = None self.scale = scale - self.ndim = self._compute_ndim(segmentation, scale, ndim) + self.ndim = self._compute_ndim(self.segmentation_shape, scale, ndim) self.axis_names = ["z", "y", "x"] if self.ndim == 4 else ["y", "x"] # initialization steps: @@ -432,12 +454,23 @@ def get_pixels(self, node: Node) -> tuple[np.ndarray, ...] | None: """ if self.segmentation is None: return None + + # Get time and mask for the node time = self.get_time(node) - loc_pixels = np.nonzero(self.segmentation[time] == node) - time_array = np.ones_like(loc_pixels[0]) * time - return (time_array, *loc_pixels) + mask = self.graph[node][td.DEFAULT_ATTR_KEYS.MASK] + + # Get local coordinates and convert to global using bbox offset + local_coords = np.nonzero(mask.mask) + global_coords = [coord + mask.bbox[dim] for dim, coord in enumerate(local_coords)] - def set_pixels(self, pixels: tuple[np.ndarray, ...], value: int) -> None: + # Create time array matching the number of points + time_array = np.full_like(global_coords[0], time) + + return (time_array, *global_coords) + + def set_pixels( + self, pixels: tuple[np.ndarray, ...], value: int, node: int | None = None + ): """Set the given pixels in the segmentation to the given value. Args: @@ -446,18 +479,127 @@ def set_pixels(self, pixels: tuple[np.ndarray, ...], value: int) -> None: represents one dimension, containing an array of indices in that dimension). Can be used to directly index the segmentation. value (Iterable[int | None]): The value to set each pixel to + nodes (Iterable[int] | None, optional): The node ids that the pixels + correspond to. Only needed if pixels need to be removed (val=0) """ if self.segmentation is None: raise ValueError("Cannot set pixels when segmentation is None") - self.segmentation[pixels] = value + + node_id = node if node is not None else None + + if value is None: + raise ValueError("Cannot set pixels to None value") + + mask_new, area_new = pixels_to_td_mask(pixels, self.ndim, self.scale) + + if value == 0: + # val=0 means deleting the pixels from the mask + mask_old = self.graph[node_id][td.DEFAULT_ATTR_KEYS.MASK] + mask_subtracted, area_subtracted = subtract_td_masks( + mask_old, mask_new, self.scale + ) + self.graph.update_node_attrs( + attrs={ + td.DEFAULT_ATTR_KEYS.MASK: [mask_subtracted], + td.DEFAULT_ATTR_KEYS.BBOX: [mask_subtracted.bbox], + "area": [area_subtracted], + }, + node_ids=[node_id], + ) + + elif value in self.graph.node_ids(): + # if node already exists: + mask_old = self.graph[value][td.DEFAULT_ATTR_KEYS.MASK] + mask_combined, area_combined = combine_td_masks( + mask_old, mask_new, self.scale + ) + self.graph.update_node_attrs( + attrs={ + td.DEFAULT_ATTR_KEYS.MASK: [mask_combined], + td.DEFAULT_ATTR_KEYS.BBOX: [mask_combined.bbox], + "area": [area_combined], + }, + node_ids=[value], + ) + + else: + if len(np.unique(pixels[0])) > 1: + raise ValueError( + f"pixels in Tracks.set_pixels has more than 1 timepoint " + f"for node {value}. This is not implemented, so if this is " + "necessary, Tracks.set_pixels should be updated" + ) + + time = int(np.unique(pixels[0])[0]) + pos = np.array([np.mean(pixels[dim + 1]) for dim in range(self.ndim - 1)]) + track_id = -1 # dummy, will be replaced in AddNodes._apply() + + node_dict = { + self.features.time_key: time, + self.features.position_key: pos, + self.features.tracklet_key: track_id, + "area": area_new, + td.DEFAULT_ATTR_KEYS.SOLUTION: 1, + td.DEFAULT_ATTR_KEYS.MASK: mask_new, + td.DEFAULT_ATTR_KEYS.BBOX: mask_new.bbox, + } + + self.graph.add_node(node_dict, index=value) + + # Invalidate cache for affected chunks + self._update_segmentation_cache(pixels) + + def _update_segmentation_cache(self, pixels: tuple[np.ndarray, ...]) -> None: + """Invalidate cached chunks that overlap with the given pixels. + + Args: + pixels: Tuple of arrays representing pixel coordinates (time, z, y, x) + or (time, y, x), formatted like the output of np.nonzero. + """ + if self.segmentation is None: + return + + cache = self.segmentation._cache + time_coords = pixels[0] + spatial_coords = pixels[1:] + + # For each unique time point + for time in np.unique(time_coords): + time = int(time) + + # Only invalidate if this time point is in the cache + if time not in cache._store: + continue + + # Get pixels at this time point and create bounding box slices + time_mask = time_coords == time + volume_slicing = tuple( + slice( + int(dim_coords[time_mask].min()), int(dim_coords[time_mask].max()) + 1 + ) + for dim_coords in spatial_coords + ) + + # Use cache's method to get chunk bounds (same logic as cache.get()) + bounds = cache._chunk_bounds(volume_slicing) + chunk_ranges = [range(lo, hi + 1) for lo, hi in bounds] + + # Invalidate all affected chunks + cache_entry = cache._store[time] + for chunk_idx in itertools.product(*chunk_ranges): + if all( + 0 <= idx < grid_size + for idx, grid_size in zip(chunk_idx, cache.grid_shape, strict=True) + ): + cache_entry.ready[chunk_idx] = False def _compute_ndim( self, - seg: np.ndarray | None, + segmentation_shape: tuple[int, ...] | None, scale: list[float] | None, provided_ndim: int | None, ): - seg_ndim = seg.ndim if seg is not None else None + seg_ndim = len(segmentation_shape) if segmentation_shape is not None else None scale_ndim = len(scale) if scale is not None else None ndims = [seg_ndim, scale_ndim, provided_ndim] ndims = [d for d in ndims if d is not None] diff --git a/src/funtracks/import_export/_tracks_builder.py b/src/funtracks/import_export/_tracks_builder.py index c9da7bde..3f264784 100644 --- a/src/funtracks/import_export/_tracks_builder.py +++ b/src/funtracks/import_export/_tracks_builder.py @@ -89,7 +89,7 @@ class TracksBuilder(ABC): TIME_ATTR = "time" - def __init__(self): + def __init__(self) -> None: """Initialize builder state.""" # State transferred between steps self.in_memory_geff: InMemoryGeff | None = None diff --git a/src/funtracks/user_actions/user_update_segmentation.py b/src/funtracks/user_actions/user_update_segmentation.py index a38bf3e2..44cfab75 100644 --- a/src/funtracks/user_actions/user_update_segmentation.py +++ b/src/funtracks/user_actions/user_update_segmentation.py @@ -50,9 +50,10 @@ def __init__( continue time = pixels[0][0] # check if all pixels of old_value are removed - # TODO: this assumes the segmentation is already updated, but then we can't - # recover the pixels, so we have to pass them here for undo purposes - if np.sum(self.tracks.segmentation[time] == old_value) == 0: + seg_time = np.asarray(self.tracks.segmentation[time]) + if np.unique(seg_time[pixels[1:]]) == old_value and np.sum( + seg_time == old_value + ) == len(pixels[0]): self.actions.append(UserDeleteNode(tracks, old_value, pixels=pixels)) else: self.actions.append(UpdateNodeSeg(tracks, old_value, pixels, added=False)) @@ -69,6 +70,7 @@ def __init__( self.actions.append( UpdateNodeSeg(tracks, new_value, all_pixels, added=True) ) + tracks.graph[new_value][tracks.features.tracklet_key] = current_track_id else: time_key = tracks.features.time_key tracklet_key = tracks.features.tracklet_key diff --git a/src/funtracks/utils/tracksdata_utils.py b/src/funtracks/utils/tracksdata_utils.py index 8e028b8e..a17502d6 100644 --- a/src/funtracks/utils/tracksdata_utils.py +++ b/src/funtracks/utils/tracksdata_utils.py @@ -90,6 +90,11 @@ def create_empty_graphview_graph( if "z" in position_attrs: graph_sql.add_node_attr_key("z", default_value=0.0) + if "mask" in (node_attributes or []): + graph_sql.add_node_attr_key("mask", default_value=None) + if "bbox" in (node_attributes or []): + graph_sql.add_node_attr_key("bbox", default_value=None) + for attr in node_attributes or []: if attr not in graph_sql.node_attr_keys(): graph_sql.add_node_attr_key( @@ -106,10 +111,6 @@ def create_empty_graphview_graph( graph_sql.add_node_attr_key(td.DEFAULT_ATTR_KEYS.SOLUTION, default_value=1) graph_sql.add_edge_attr_key(td.DEFAULT_ATTR_KEYS.SOLUTION, default_value=1) - # TODO Teun: segmentation - # graph_sql.add_node_attr_key(td.DEFAULT_ATTR_KEYS.MASK, default_value=None) - # graph_sql.add_node_attr_key(td.DEFAULT_ATTR_KEYS.BBOX, default_value=None) - graph_td_sub = graph_sql.filter( td.NodeAttr(td.DEFAULT_ATTR_KEYS.SOLUTION) == 1, td.EdgeAttr(td.DEFAULT_ATTR_KEYS.SOLUTION) == 1, @@ -138,24 +139,19 @@ def assert_node_attrs_equal_with_masks( "Both objects must be either tracksdata graphs or polars DataFrames" ) - # TODO Teun: enable this when segmentation/masks are part of node_attrs - # assert_frame_equal( - # node_attrs1.drop("mask"), - # node_attrs2.drop("mask"), - # check_column_order=check_column_order, - # check_row_order=check_row_order, - # ) - # for node in node_attrs1["node_id"]: - # mask1 = node_attrs1.filter(pl.col("node_id") == node)["mask"].item() - # mask2 = node_attrs2.filter(pl.col("node_id") == node)["mask"].item() - # assert np.array_equal(mask1.bbox, mask2.bbox) - # assert np.array_equal(mask1.mask, mask2.mask) + # Check all fields, except masks assert_frame_equal( - node_attrs1, - node_attrs2, + node_attrs1.drop("mask"), + node_attrs2.drop("mask"), check_column_order=check_column_order, check_row_order=check_row_order, ) + # Check masks separately + for node in node_attrs1["node_id"]: + mask1 = node_attrs1.filter(pl.col("node_id") == node)["mask"].item() + mask2 = node_attrs2.filter(pl.col("node_id") == node)["mask"].item() + assert np.array_equal(mask1.bbox, mask2.bbox) + assert np.array_equal(mask1.mask, mask2.mask) def compute_node_attrs_from_masks( @@ -284,6 +280,226 @@ def pixels_to_td_mask( return mask, area +def td_mask_to_pixels(mask: Mask, time: int, ndim: int) -> tuple[np.ndarray, ...]: + """ + Convert tracksdata mask to pixel coordinates. + + This is the inverse of pixels_to_td_mask. + + Args: + mask: Tracksdata Mask object with .mask (boolean array) and .bbox attributes + time: Time point for this mask + ndim: Number of dimensions (3 for 2D+time, 4 for 3D+time) + + Returns: + Tuple of numpy arrays: (time_array, *spatial_coords) + For 2D: (t, y, x) where each is a 1D array of pixel coordinates + For 3D: (t, z, y, x) where each is a 1D array of pixel coordinates + + Example: + >>> mask = Mask(np.array([[True, False], [False, True]]), + ... bbox=np.array([10, 20, 12, 22])) + >>> pixels = td_mask_to_pixels(mask, time=5, ndim=3) + >>> # Returns: (array([5, 5]), array([10, 11]), array([20, 21])) + """ + spatial_dims = ndim - 1 + + # Find all True pixels in the local mask + local_coords = np.nonzero(mask.mask) + + # Convert local coordinates to global coordinates by adding bbox offset + global_coords = [] + for dim in range(spatial_dims): + global_coords.append(local_coords[dim] + mask.bbox[dim]) + + # Create time array with same length as spatial coordinates + num_pixels = len(local_coords[0]) + time_array = np.full(num_pixels, time, dtype=int) + + # Return as tuple: (time, spatial_dim_0, spatial_dim_1, ...) + return (time_array, *global_coords) + + +def combine_td_masks( + mask1: Mask, mask2: Mask, scale: list[float] | None +) -> tuple[Mask, float]: + """ + Combine two tracksdata mask objects into a single mask object. + The resulting mask will encompass both input masks. + + Args: + mask1: First Mask object with .mask and .bbox attributes + mask2: Second Mask object with .mask and .bbox attributes + scale: Scale factors for each dimension, used for area calculation + + Returns: + Mask: A new Mask object containing the union of both masks + """ + # Get spatial dimensions from first bbox + spatial_dims = len(mask1.bbox) // 2 + + # Calculate the combined bounding box + combined_bbox = np.zeros(2 * spatial_dims, dtype=int) + + # Find the minimum and maximum coordinates for the new bbox + for dim in range(spatial_dims): + combined_bbox[dim] = min(mask1.bbox[dim], mask2.bbox[dim]) + combined_bbox[dim + spatial_dims] = max( + mask1.bbox[dim + spatial_dims], mask2.bbox[dim + spatial_dims] + ) + + # Calculate the shape of the combined mask + combined_shape = combined_bbox[spatial_dims:] - combined_bbox[:spatial_dims] + combined_mask = np.zeros(combined_shape, dtype=bool) + + # Create slicing for first mask + slices1 = tuple( + slice(offset1_start, offset1_end) + for offset1_start, offset1_end in zip( + [mask1.bbox[d] - combined_bbox[d] for d in range(spatial_dims)], + [ + mask1.bbox[d] - combined_bbox[d] + mask1.mask.shape[d] + for d in range(spatial_dims) + ], + strict=True, + ) + ) + + # Place second mask in the combined mask + slices2 = tuple( + slice(offset2_start, offset2_end) + for offset2_start, offset2_end in zip( + [mask2.bbox[d] - combined_bbox[d] for d in range(spatial_dims)], + [ + mask2.bbox[d] - combined_bbox[d] + mask2.mask.shape[d] + for d in range(spatial_dims) + ], + strict=True, + ) + ) + + # Combine the masks using logical OR + combined_mask[slices1] |= mask1.mask + combined_mask[slices2] |= mask2.mask + + area = np.sum(combined_mask) + if scale is not None: + area *= np.prod(scale[1:]) + + return Mask(combined_mask, bbox=combined_bbox), float(area) + + +def subtract_td_masks( + mask_old: Mask, mask_new: Mask, scale: list[float] | None +) -> tuple[Mask, float]: + """ + Subtract mask_new from mask_old, creating a new mask with the difference. + Will throw an error if mask_new contains True pixels that are not True in mask_old. + + Args: + mask_old: Original Mask object that pixels will be removed from + mask_new: Mask object containing pixels to remove + scale: Scale factors for each dimension, used for area calculation + + Returns: + Tuple[Mask, float]: A new Mask object containing the result of + mask_old - mask_new, and the new area after subtraction + """ + # Get spatial dimensions from first bbox + spatial_dims = len(mask_old.bbox) // 2 + + # First verify that all True pixels in mask_new are also True in mask_old + # We do this by placing both masks in a common coordinate system + + # Calculate the combined bounding box + combined_bbox = np.zeros(2 * spatial_dims, dtype=int) + for dim in range(spatial_dims): + combined_bbox[dim] = min(mask_old.bbox[dim], mask_new.bbox[dim]) + combined_bbox[dim + spatial_dims] = max( + mask_old.bbox[dim + spatial_dims], mask_new.bbox[dim + spatial_dims] + ) + + # Place both masks in the combined coordinate system + combined_shape = combined_bbox[spatial_dims:] - combined_bbox[:spatial_dims] + old_mask_full = np.zeros(combined_shape, dtype=bool) + new_mask_full = np.zeros(combined_shape, dtype=bool) + + # Create slicing for old mask + slices_old = tuple( + slice(offset_start, offset_end) + for offset_start, offset_end in zip( + [mask_old.bbox[d] - combined_bbox[d] for d in range(spatial_dims)], + [ + mask_old.bbox[d] - combined_bbox[d] + mask_old.mask.shape[d] + for d in range(spatial_dims) + ], + strict=True, + ) + ) + + # Create slicing for new mask + slices_new = tuple( + slice(offset_start, offset_end) + for offset_start, offset_end in zip( + [mask_new.bbox[d] - combined_bbox[d] for d in range(spatial_dims)], + [ + mask_new.bbox[d] - combined_bbox[d] + mask_new.mask.shape[d] + for d in range(spatial_dims) + ], + strict=True, + ) + ) + + old_mask_full[slices_old] = mask_old.mask + new_mask_full[slices_new] = mask_new.mask + + # Check if all True pixels in mask_new are also True in mask_old + if not np.all(new_mask_full <= old_mask_full): + raise ValueError("mask_new contains True pixels that are not True in mask_old") + + # Perform the subtraction + result_mask = old_mask_full & ~new_mask_full + + # Find the new bounding box based on remaining True pixels + if not np.any(result_mask): + # If no pixels remain, return minimal empty mask + # result_bbox = np.zeros(2 * spatial_dims, dtype=int) + result_bbox = np.array([0] * spatial_dims + [1] * spatial_dims) + return Mask(np.zeros((1,) * spatial_dims, dtype=bool), bbox=result_bbox), 0.0 + + true_indices = np.nonzero(result_mask) + result_bbox = np.zeros(2 * spatial_dims, dtype=int) + + for dim in range(spatial_dims): + result_bbox[dim] = np.min(true_indices[dim]) + combined_bbox[dim] + result_bbox[dim + spatial_dims] = ( + np.max(true_indices[dim]) + combined_bbox[dim] + 1 + ) + + # Extract the final mask within the new bbox + final_shape = result_bbox[spatial_dims:] - result_bbox[:spatial_dims] + final_mask = np.zeros(final_shape, dtype=bool) + + # Create slicing from result_mask to final_mask space + slices_final = tuple( + slice( + result_bbox[dim] - combined_bbox[dim], + result_bbox[dim] - combined_bbox[dim] + final_shape[dim], + ) + for dim in range(spatial_dims) + ) + + # Copy the relevant portion of the result_mask to final_mask + final_mask[:] = result_mask[slices_final] + + # Calculate area + area = np.sum(final_mask) + if scale is not None: + area *= np.prod(scale[1:]) + + return Mask(final_mask, bbox=result_bbox), float(area) + + def td_get_single_attr_from_edge(graph, edge: tuple[int, int], attrs: Sequence[str]): """Get a single attribute from a edge in a tracksdata graph.""" diff --git a/tests/actions/test_add_delete_edge.py b/tests/actions/test_add_delete_edge.py index 78bbae83..a792bd51 100644 --- a/tests/actions/test_add_delete_edge.py +++ b/tests/actions/test_add_delete_edge.py @@ -1,5 +1,4 @@ -import copy - +import numpy as np import pytest from numpy.testing import assert_array_almost_equal from polars.testing import assert_frame_equal @@ -21,7 +20,7 @@ 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 = copy.deepcopy(tracks.segmentation) + reference_seg = np.asarray(tracks.segmentation).copy() # Create an empty tracks with just nodes (no edges) for edge in tracks.graph.edge_list(): diff --git a/tests/actions/test_add_delete_nodes.py b/tests/actions/test_add_delete_nodes.py index 3578fcf0..5a1481d4 100644 --- a/tests/actions/test_add_delete_nodes.py +++ b/tests/actions/test_add_delete_nodes.py @@ -1,9 +1,9 @@ -import copy - import numpy as np import pytest -from numpy.testing import assert_array_almost_equal +from numpy.testing import assert_array_almost_equal, assert_array_equal from polars.testing import assert_frame_equal +from tests.conftest import make_2d_disk_mask, make_3d_sphere_mask +from tracksdata.array import GraphArrayView from funtracks.actions import ( ActionGroup, @@ -12,6 +12,7 @@ from funtracks.utils.tracksdata_utils import ( assert_node_attrs_equal_with_masks, create_empty_graphview_graph, + td_mask_to_pixels, ) @@ -21,7 +22,7 @@ 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 = copy.deepcopy(tracks.segmentation) + reference_seg = np.asarray(tracks.segmentation).copy() if with_seg else None # Start with an empty Tracks node_attributes = [ @@ -31,13 +32,19 @@ def test_add_delete_nodes(get_tracks, ndim, with_seg): ] edge_attributes = ["iou"] if with_seg else [] empty_graph = create_empty_graphview_graph( - node_attributes=node_attributes + (["area"] if with_seg else []), + node_attributes=node_attributes + (["area", "bbox", "mask"] if with_seg else []), edge_attributes=edge_attributes, ) empty_seg = np.zeros_like(tracks.segmentation) if with_seg else None tracks.graph = empty_graph - if with_seg: - tracks.segmentation = empty_seg + 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()) @@ -59,6 +66,9 @@ def test_add_delete_nodes(get_tracks, ndim, with_seg): attrs[tracks.features.tracklet_key] = reference_graph[node][ tracks.features.tracklet_key ] + if with_seg: + attrs["bbox"] = reference_graph[node]["bbox"] + attrs["mask"] = reference_graph[node]["mask"] actions.append(AddNode(tracks, node, attributes=attrs, pixels=pixels)) action = ActionGroup(tracks=tracks, actions=actions) @@ -171,16 +181,17 @@ def test_custom_attributes_preserved(get_tracks, ndim, with_seg): # Create segmentation if needed if with_seg: - from conftest import sphere - from skimage.draw import disk - if ndim == 3: - rr, cc = disk(center=(50, 50), radius=5, shape=(100, 100)) - pixels = (np.array([2]), rr, cc) + # Create 2D mask centered at (50, 50) with radius 5 + mask_obj = make_2d_disk_mask(center=(50, 50), radius=5) + pixels = td_mask_to_pixels(mask_obj, time=custom_attrs["t"], ndim=ndim) else: - mask = sphere(center=(50, 50, 50), radius=5, shape=(100, 100, 100)) # Create proper 4D pixel coordinates (t, z, y, x) - pixels = (np.array([2]), *np.nonzero(mask)) + mask_obj = make_3d_sphere_mask(center=(50, 50, 50), radius=5) + pixels = td_mask_to_pixels(mask_obj, time=custom_attrs["t"], ndim=ndim) + custom_attrs["mask"] = mask_obj + # TODO Teun: are these lines necessary? Because we provide pixels to AddNode + custom_attrs["bbox"] = mask_obj.bbox custom_attrs.pop("pos") # pos will be computed from segmentation else: pixels = None @@ -194,6 +205,10 @@ def test_custom_attributes_preserved(get_tracks, ndim, with_seg): for key, value in custom_attrs.items(): if key == "pos": assert_array_almost_equal(tracks.graph[node_id][key], np.array(value)) + elif key == "mask": + continue + elif key == "bbox": + assert_array_equal(np.asarray(tracks.graph[node_id][key]), value) else: assert tracks.graph[node_id][key] == value, ( f"Attribute {key} not preserved after add" @@ -211,6 +226,10 @@ def test_custom_attributes_preserved(get_tracks, ndim, with_seg): for key, value in custom_attrs.items(): if key == "pos": assert_array_almost_equal(tracks.graph[node_id][key], np.array(value)) + elif key == "mask": + continue + elif key == "bbox": + assert_array_equal(np.asarray(tracks.graph[node_id][key]), value) else: assert tracks.graph[node_id][key] == value, ( f"Attribute {key} not preserved after delete/re-add cycle" diff --git a/tests/actions/test_update_node_segs.py b/tests/actions/test_update_node_segs.py index 49e1e345..1eede865 100644 --- a/tests/actions/test_update_node_segs.py +++ b/tests/actions/test_update_node_segs.py @@ -14,12 +14,12 @@ def test_update_node_segs(get_tracks, ndim): tracks = get_tracks(ndim=ndim, with_seg=True, is_solution=True) reference_graph = tracks.graph.detach().filter().subgraph() - original_seg = tracks.segmentation.copy() + original_seg = np.asarray(tracks.segmentation).copy() original_area = tracks.graph[1]["area"] original_pos = tracks.graph[1]["pos"] # Add a couple pixels to the first node - new_seg = tracks.segmentation.copy() + new_seg = np.asarray(tracks.segmentation).copy() if ndim == 3: new_seg[0][0][0] = 1 # 2D spatial else: diff --git a/tests/annotators/test_annotator_registry.py b/tests/annotators/test_annotator_registry.py index f44eab45..ebbb4d32 100644 --- a/tests/annotators/test_annotator_registry.py +++ b/tests/annotators/test_annotator_registry.py @@ -7,13 +7,13 @@ def test_annotator_registry_init_with_segmentation( - graph_2d_with_computed_features, segmentation_2d + graph_2d_with_segmentation, ): """Test AnnotatorRegistry initializes regionprops and edge annotators with segmentation.""" tracks = Tracks( - graph_2d_with_computed_features, - segmentation=segmentation_2d, + graph_2d_with_segmentation, + segmentation_shape=(5, 100, 100), ndim=3, **track_attrs, ) @@ -26,7 +26,7 @@ def test_annotator_registry_init_with_segmentation( 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, segmentation=None, ndim=3, **track_attrs) + 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 @@ -35,13 +35,13 @@ def test_annotator_registry_init_without_segmentation(graph_2d_with_position): def test_annotator_registry_init_solution_tracks( - graph_2d_with_computed_features, segmentation_2d + graph_2d_with_segmentation, ): """Test AnnotatorRegistry creates all annotators for SolutionTracks with segmentation.""" tracks = SolutionTracks( - graph_2d_with_computed_features, - segmentation=segmentation_2d, + graph_2d_with_segmentation, + segmentation_shape=(5, 100, 100), ndim=3, **track_attrs, ) @@ -52,10 +52,10 @@ def test_annotator_registry_init_solution_tracks( assert TrackAnnotator in annotator_types -def test_enable_disable_features(graph_2d_with_computed_features, segmentation_2d): +def test_enable_disable_features(graph_2d_with_segmentation): tracks = Tracks( - graph_2d_with_computed_features, - segmentation=segmentation_2d, + graph_2d_with_segmentation, + segmentation_shape=(5, 100, 100), ndim=3, **track_attrs, ) @@ -105,11 +105,11 @@ def test_enable_disable_features(graph_2d_with_computed_features, segmentation_2 assert "circularity" not in tracks.features -def test_get_available_features(graph_2d_with_computed_features, segmentation_2d): +def test_get_available_features(graph_2d_with_segmentation): """Test get_available_features returns all features from all annotators.""" tracks = SolutionTracks( - graph_2d_with_computed_features, - segmentation=segmentation_2d, + graph_2d_with_segmentation, + segmentation_shape=(5, 100, 100), ndim=3, **track_attrs, ) @@ -123,25 +123,30 @@ def test_get_available_features(graph_2d_with_computed_features, segmentation_2d assert "track_id" in available # tracks -def test_enable_nonexistent_feature(graph_clean, segmentation_2d): +def test_enable_nonexistent_feature(graph_clean): """Test enabling a nonexistent feature raises KeyError.""" - tracks = Tracks(graph_clean, segmentation=segmentation_2d, ndim=3, **track_attrs) + 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, segmentation_2d): +def test_disable_nonexistent_feature(graph_clean): """Test disabling a nonexistent feature raises KeyError.""" - tracks = Tracks(graph_clean, segmentation=segmentation_2d, ndim=3, **track_attrs) + 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_clean, segmentation_2d): +def test_compute_strict_validation(graph_2d_with_segmentation): """Test that compute() strictly validates feature keys.""" - tracks = Tracks(graph_clean, segmentation=segmentation_2d, ndim=3, **track_attrs) + tracks = Tracks( + graph_2d_with_segmentation, + segmentation_shape=(5, 100, 100), + ndim=3, + **track_attrs, + ) # Get the RegionpropsAnnotator from the annotators rp_ann = next( diff --git a/tests/annotators/test_edge_annotator.py b/tests/annotators/test_edge_annotator.py index 495b3e13..06dffbcb 100644 --- a/tests/annotators/test_edge_annotator.py +++ b/tests/annotators/test_edge_annotator.py @@ -10,11 +10,15 @@ @pytest.mark.parametrize("ndim", [3, 4]) class TestEdgeAnnotator: - def test_init(self, get_graph, get_segmentation, ndim): + def test_init(self, get_graph, ndim): # Start with clean graph, no existing features - graph = get_graph(ndim, with_features="clean") - seg = get_segmentation(ndim) - tracks = Tracks(graph, segmentation=seg, ndim=ndim, **track_attrs) + graph = get_graph(ndim, with_features="segmentation") + tracks = Tracks( + graph, + segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), + ndim=ndim, + **track_attrs, + ) ann = EdgeAnnotator(tracks) # Features start disabled by default assert len(ann.all_features) == 1 @@ -23,10 +27,14 @@ def test_init(self, get_graph, get_segmentation, ndim): ann.activate_features(list(ann.all_features.keys())) assert len(ann.features) == 1 - def test_compute_all(self, get_graph, get_segmentation, ndim): - graph = get_graph(ndim, with_features="clean") - seg = get_segmentation(ndim) - tracks = Tracks(graph, segmentation=seg, ndim=ndim, **track_attrs) + def test_compute_all(self, get_graph, ndim): + graph = get_graph(ndim, with_features="segmentation") + tracks = Tracks( + graph, + segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), + ndim=ndim, + **track_attrs, + ) ann = EdgeAnnotator(tracks) # Enable features ann.activate_features(list(ann.all_features.keys())) @@ -37,10 +45,14 @@ def test_compute_all(self, get_graph, get_segmentation, ndim): for key in all_features: assert key in tracks.graph.edge_attr_keys() - def test_update_all(self, get_graph, get_segmentation, ndim) -> None: - graph = get_graph(ndim, with_features="clean") - seg = get_segmentation(ndim) - tracks = Tracks(graph, segmentation=seg, ndim=ndim, **track_attrs) # type: ignore + def test_update_all(self, get_graph, ndim) -> None: + graph = get_graph(ndim, with_features="segmentation") + tracks = Tracks( + graph, + segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), + 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) @@ -70,10 +82,14 @@ def test_update_all(self, get_graph, get_segmentation, ndim) -> None: assert td_get_single_attr_from_edge(tracks.graph, edge_id, "iou") == 0 - def test_add_remove_feature(self, get_graph, get_segmentation, ndim): - graph = get_graph(ndim, with_features="clean") - seg = get_segmentation(ndim) - tracks = Tracks(graph, segmentation=seg, ndim=ndim, **track_attrs) + def test_add_remove_feature(self, get_graph, ndim): + graph = get_graph(ndim, with_features="segmentation") + tracks = Tracks( + graph, + segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), + 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 @@ -89,7 +105,6 @@ def test_add_remove_feature(self, get_graph, get_segmentation, ndim): orig_pixels = tracks.get_pixels(node_id) assert orig_pixels is not None pixels_to_remove = tuple(orig_pixels[d][1:] for d in range(len(orig_pixels))) - tracks.set_pixels(pixels_to_remove, 0) # Compute at tracks level - this should not update the removed feature for a in tracks.annotators: @@ -109,33 +124,28 @@ def test_add_remove_feature(self, get_graph, get_segmentation, ndim): def test_missing_seg(self, get_graph, ndim) -> None: """Test that EdgeAnnotator gracefully handles missing segmentation.""" graph = get_graph(ndim, with_features="clean") - tracks = Tracks(graph, segmentation=None, ndim=ndim, **track_attrs) # type: ignore + 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, get_segmentation, ndim): + def test_ignores_irrelevant_actions(self, get_graph, ndim): """Test that EdgeAnnotator ignores actions that don't affect edges.""" - graph = get_graph(ndim, with_features="clean") - seg = get_segmentation(ndim) - tracks = SolutionTracks(graph, segmentation=seg, ndim=ndim, **track_attrs) + graph = get_graph(ndim, with_features="segmentation") + tracks = SolutionTracks( + graph, + segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), + ndim=ndim, + **track_attrs, + ) tracks.enable_features(["iou", track_attrs["tracklet_attr"]]) + node_id = 3 edge_id = (1, 3) initial_iou = td_get_single_attr_from_edge(tracks.graph, edge_id, "iou") - # Manually modify segmentation (without triggering an action) - # Remove half the pixels from node 3 (target of the edge) - node_id = 3 - orig_pixels = tracks.get_pixels(node_id) - assert orig_pixels is not None - pixels_to_remove = tuple( - orig_pixels[d][: len(orig_pixels[d]) // 2] for d in range(len(orig_pixels)) - ) - tracks.set_pixels(pixels_to_remove, 0) - # If we recomputed IoU now, it would be different # But we won't - we'll just call UpdateTrackID on node 1 diff --git a/tests/annotators/test_graph_annotator.py b/tests/annotators/test_graph_annotator.py index 34dd4404..6cd9c9ef 100644 --- a/tests/annotators/test_graph_annotator.py +++ b/tests/annotators/test_graph_annotator.py @@ -8,8 +8,10 @@ track_attrs = {"time_attr": "t", "tracklet_attr": "track_id"} -def test_base_graph_annotator(graph_clean, segmentation_2d): - tracks = Tracks(graph_clean, segmentation=segmentation_2d, **track_attrs) +def test_base_graph_annotator(graph_2d_with_segmentation): + tracks = Tracks( + graph_2d_with_segmentation, segmentation_shape=(5, 100, 100), **track_attrs + ) ann = GraphAnnotator(tracks, {}) assert len(ann.features) == 0 diff --git a/tests/annotators/test_regionprops_annotator.py b/tests/annotators/test_regionprops_annotator.py index 1627e395..fa52c775 100644 --- a/tests/annotators/test_regionprops_annotator.py +++ b/tests/annotators/test_regionprops_annotator.py @@ -9,10 +9,14 @@ @pytest.mark.parametrize("ndim", [3, 4]) class TestRegionpropsAnnotator: - def test_init(self, get_graph, get_segmentation, ndim): - graph = get_graph(ndim, with_features="clean") - seg = get_segmentation(ndim) - tracks = Tracks(graph, segmentation=seg, ndim=ndim, **track_attrs) + def test_init(self, get_graph, ndim): + graph = get_graph(ndim, with_features="segmentation") + tracks = Tracks( + graph, + segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), + ndim=ndim, + **track_attrs, + ) rp_ann = RegionpropsAnnotator(tracks) # Features start disabled by default assert len(rp_ann.all_features) == 5 @@ -23,10 +27,14 @@ def test_init(self, get_graph, get_segmentation, ndim): len(rp_ann.features) == 5 ) # pos, area, ellipse_axis_radii, circularity, perimeter - def test_compute_all(self, get_graph, get_segmentation, ndim): - graph = get_graph(ndim, with_features="clean") - seg = get_segmentation(ndim) - tracks = Tracks(graph, segmentation=seg, ndim=ndim, **track_attrs) + def test_compute_all(self, get_graph, ndim): + graph = get_graph(ndim, with_features="segmentation") + tracks = Tracks( + graph, + segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), + ndim=ndim, + **track_attrs, + ) rp_ann = RegionpropsAnnotator(tracks) # Enable features rp_ann.activate_features(list(rp_ann.all_features.keys())) @@ -39,10 +47,14 @@ def test_compute_all(self, get_graph, get_segmentation, ndim): value = tracks.graph[node_id][key] assert value is not None - def test_update_all(self, get_graph, get_segmentation, ndim): - graph = get_graph(ndim, with_features="clean") - seg = get_segmentation(ndim) - tracks = Tracks(graph, segmentation=seg, ndim=ndim, **track_attrs) + def test_update_all(self, get_graph, ndim): + graph = get_graph(ndim, with_features="segmentation") + tracks = Tracks( + graph, + segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), + ndim=ndim, + **track_attrs, + ) node_id = 3 # Get the RegionpropsAnnotator from the registry @@ -74,10 +86,14 @@ def test_update_all(self, get_graph, get_segmentation, ndim): for key in rp_ann.features: assert tracks.graph[node_id][key] is None - def test_add_remove_feature(self, get_graph, get_segmentation, ndim): - graph = get_graph(ndim, with_features="clean") - seg = get_segmentation(ndim) - tracks = Tracks(graph, segmentation=seg, ndim=ndim, **track_attrs) + def test_add_remove_feature(self, get_graph, ndim): + graph = get_graph(ndim, with_features="segmentation") + tracks = Tracks( + graph, + segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), + ndim=ndim, + **track_attrs, + ) # Get the RegionpropsAnnotator from the registry rp_ann = next( ann for ann in tracks.annotators if isinstance(ann, RegionpropsAnnotator) @@ -114,19 +130,23 @@ def test_add_remove_feature(self, get_graph, get_segmentation, ndim): def test_missing_seg(self, get_graph, ndim): """Test that RegionpropsAnnotator gracefully handles missing segmentation.""" graph = get_graph(ndim, with_features="clean") - tracks = Tracks(graph, segmentation=None, ndim=ndim, **track_attrs) + 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_ignores_irrelevant_actions(self, get_graph, get_segmentation, ndim): + def test_ignores_irrelevant_actions(self, get_graph, ndim): """Test that RegionpropsAnnotator ignores actions that don't affect segmentation. """ - graph = get_graph(ndim, with_features="clean") - seg = get_segmentation(ndim) - tracks = SolutionTracks(graph, segmentation=seg, ndim=ndim, **track_attrs) + graph = get_graph(ndim, with_features="segmentation") + tracks = SolutionTracks( + graph, + segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), + ndim=ndim, + **track_attrs, + ) tracks.enable_features(["area", "track_id"]) node_id = 1 @@ -136,10 +156,6 @@ def test_ignores_irrelevant_actions(self, get_graph, get_segmentation, ndim): # Remove half the pixels from node 1 orig_pixels = tracks.get_pixels(node_id) assert orig_pixels is not None - pixels_to_remove = tuple( - orig_pixels[d][: len(orig_pixels[d]) // 2] for d in range(len(orig_pixels)) - ) - tracks.set_pixels(pixels_to_remove, 0) # If we recomputed area now, it would be different # But we won't - we'll just call UpdateTrackID diff --git a/tests/conftest.py b/tests/conftest.py index f950ccf6..6967e6cc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ import pytest import tracksdata as td from skimage.draw import disk +from tracksdata.nodes._mask import Mask from funtracks.utils.tracksdata_utils import ( create_empty_graphview_graph, @@ -13,42 +14,121 @@ if TYPE_CHECKING: from typing import Any - from numpy.typing import NDArray - from funtracks.data_model import SolutionTracks, Tracks # Feature list constants for consistent test usage -FEATURES_WITH_SEG = ["pos", "area", "iou"] +# WITH_SEG means segmentation stored as mask/bbox node attributes +FEATURES_WITH_SEG = ["pos", "area", "iou", "mask", "bbox"] FEATURES_NO_SEG = ["pos"] -SOLUTION_FEATURES_WITH_SEG = ["pos", "area", "iou", "track_id"] +SOLUTION_FEATURES_WITH_SEG = ["pos", "area", "iou", "track_id", "mask", "bbox"] SOLUTION_FEATURES_NO_SEG = ["pos", "track_id"] -@pytest.fixture -def segmentation_2d() -> "NDArray[np.int32]": - frame_shape = (100, 100) - total_shape = (5, *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 +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 - # make frame with two cells - # first cell centered at (20, 80) with label 2 - # second cell centered at (60, 45) with label 3 - rr, cc = disk(center=(20, 80), radius=10, shape=frame_shape) - segmentation[1][rr, cc] = 2 - rr, cc = disk(center=(60, 45), radius=15, shape=frame_shape) - segmentation[1][rr, cc] = 3 + 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, + ] + ), + ) - # continue track 3 with squares from 0 to 4 in x and y with label 3 - segmentation[2, 0:4, 0:4] = 4 - segmentation[4, 0:4, 0:4] = 5 - # unconnected node - segmentation[4, 96:100, 96:100] = 6 +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, + ] + ), + ) - return segmentation + +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( @@ -58,6 +138,7 @@ def _make_graph( 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. @@ -68,6 +149,7 @@ def _make_graph( 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: @@ -84,6 +166,9 @@ def _make_graph( node_attributes.append("area") if with_iou: edge_attributes.append("iou") + if with_masks: + node_attributes.append(td.DEFAULT_ATTR_KEYS.MASK) + node_attributes.append(td.DEFAULT_ATTR_KEYS.BBOX) graph = create_empty_graphview_graph( node_attributes=node_attributes, @@ -129,6 +214,26 @@ def _make_graph( # Track IDs track_ids = {1: 1, 2: 2, 3: 3, 4: 3, 5: 3, 6: 5} + # Mask data (matches segmentation structure) + 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), + } + 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), + } + # Build nodes with requested features nodes_id_list = [] nodes_attrs_list = [] @@ -144,6 +249,10 @@ def _make_graph( 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) @@ -189,15 +298,16 @@ def graph_2d_with_track_id(tmp_path) -> td.graph.GraphView: @pytest.fixture -def graph_2d_with_computed_features(tmp_path) -> td.graph.GraphView: - """Graph with all computed features - for SolutionTracks with segmentation.""" - db_path = str(tmp_path / "graph_2d_computed.db") +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, ) @@ -217,26 +327,27 @@ def graph_3d_with_track_id(tmp_path) -> td.graph.GraphView: @pytest.fixture -def graph_3d_with_computed_features(tmp_path) -> td.graph.GraphView: - """Graph with all computed features - for SolutionTracks with segmentation.""" - db_path = str(tmp_path / "graph_3d_computed.db") +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, get_segmentation) -> Callable[..., "Tracks | SolutionTracks"]: +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 + with_seg: Whether to include segmentation (mask/bbox as node attributes) is_solution: Whether to return SolutionTracks instead of Tracks Example: @@ -259,9 +370,10 @@ def _make_tracks( # Determine which graph to use based on requirements if with_seg: - # With segmentation: use fully computed features (pos + track_id + area + iou) - graph = get_graph(ndim=ndim, with_features="computed") - seg = get_segmentation(ndim=ndim) + # With segmentation: use graph with mask/bbox node attrs + # and all computed features + graph = get_graph(ndim=ndim, with_features="segmentation") + segmentation_shape = (5, 100, 100) if ndim == 3 else (5, 100, 100, 100) else: # Without segmentation if is_solution: @@ -270,7 +382,7 @@ def _make_tracks( else: # Regular Tracks: use graph with just pos graph = get_graph(ndim=ndim, with_features="position") - seg = None + segmentation_shape = None # Build FeatureDict based on what exists in the graph features_dict = { @@ -279,12 +391,12 @@ def _make_tracks( } if with_seg: - # Graph has pre-computed features (area, iou, track_id) + # Graph has pre-computed features (area, iou, track_id, mask, bbox) features_dict["area"] = Area(ndim=ndim) features_dict["iou"] = IoU() features_dict["track_id"] = TrackletID() elif is_solution: - # SolutionTracks without seg: has track_id but not area/iou + # SolutionTracks without seg: has track_id but not area/iou/mask/bbox features_dict["track_id"] = TrackletID() feature_dict = FeatureDict( @@ -298,14 +410,14 @@ def _make_tracks( if is_solution: return SolutionTracks( graph, - segmentation=seg, + segmentation_shape=segmentation_shape, ndim=ndim, features=feature_dict, ) else: return Tracks( graph, - segmentation=seg, + segmentation_shape=segmentation_shape, ndim=ndim, features=feature_dict, ) @@ -350,32 +462,6 @@ def sphere(center, radius, shape): return mask -@pytest.fixture -def segmentation_3d() -> "NDArray[np.int32]": - frame_shape = (100, 100, 100) - total_shape = (5, *frame_shape) - segmentation = np.zeros(total_shape, dtype="int32") - # make frame with one cell in center with label 1 - mask = sphere(center=(50, 50, 50), radius=20, shape=frame_shape) - segmentation[0][mask] = 1 - - # make frame with two cells - # first cell centered at (20, 50, 80) with label 2 - # second cell centered at (60, 50, 45) with label 3 - mask = sphere(center=(20, 50, 80), radius=10, shape=frame_shape) - segmentation[1][mask] = 2 - mask = sphere(center=(60, 50, 45), radius=15, shape=frame_shape) - segmentation[1][mask] = 3 - - # continue track 3 with squares from 0 to 4 in x and y with label 3 - segmentation[2, 0:4, 0:4, 0:4] = 4 - segmentation[4, 0:4, 0:4, 0:4] = 5 - - # unconnected node - segmentation[4, 96:100, 96:100, 96:100] = 6 - return segmentation - - @pytest.fixture def get_graph(request) -> Callable[..., td.graph.GraphView]: """Factory fixture to get graph by ndim and feature level. @@ -386,13 +472,13 @@ def get_graph(request) -> Callable[..., td.graph.GraphView]: - "clean": time only - "position": time + pos - "track_id": time + pos + track_id (for SolutionTracks without seg) - - "computed": time + pos + track_id + area + iou (full features) + - "segmentation": time + pos + track_id + area + iou + mask + bbox Returns: A deep copy of the requested graph Example: - graph = get_graph(ndim=3, with_features="track_id") + graph = get_graph(ndim=3, with_features="segmentation") """ def _get_graph(ndim: int, with_features: str = "clean") -> td.graph.GraphView: @@ -408,41 +494,18 @@ def _get_graph(ndim: int, with_features: str = "clean") -> td.graph.GraphView: graph = request.getfixturevalue("graph_2d_with_track_id") else: # ndim == 4 graph = request.getfixturevalue("graph_3d_with_track_id") - elif with_features == "computed": + elif with_features == "segmentation": if ndim == 3: - graph = request.getfixturevalue("graph_2d_with_computed_features") + graph = request.getfixturevalue("graph_2d_with_segmentation") else: # ndim == 4 - graph = request.getfixturevalue("graph_3d_with_computed_features") + graph = request.getfixturevalue("graph_3d_with_segmentation") else: raise ValueError( - f"with_features must be 'clean', 'position', 'track_id', or 'computed', " - f"got {with_features}" + f"with_features must be 'clean', 'position', 'track_id', " + f"or 'segmentation', got {with_features}" ) # Deepcopy alternative for tracksdata graph return graph.detach().filter().subgraph() return _get_graph - - -@pytest.fixture -def get_segmentation(request) -> Callable[..., "NDArray[np.int32]"]: - """Factory fixture to get segmentation by ndim. - - Args: - ndim: 3 for 2D spatial + time, 4 for 3D spatial + time - - Returns: - The segmentation array (not copied since it's not typically modified) - - Example: - seg = get_segmentation(ndim=3) - """ - - def _get_segmentation(ndim: int) -> "NDArray[np.int32]": - if ndim == 3: - return request.getfixturevalue("segmentation_2d") - else: # ndim == 4 - return request.getfixturevalue("segmentation_3d") - - return _get_segmentation diff --git a/tests/data_model/test_solution_tracks.py b/tests/data_model/test_solution_tracks.py index 24801dbc..df4965b9 100644 --- a/tests/data_model/test_solution_tracks.py +++ b/tests/data_model/test_solution_tracks.py @@ -1,5 +1,3 @@ -import numpy as np - from funtracks.actions import AddNode from funtracks.data_model import SolutionTracks, Tracks from funtracks.utils.tracksdata_utils import create_empty_graphview_graph @@ -16,8 +14,8 @@ def test_recompute_track_ids(graph_2d_with_track_id): assert tracks.get_next_track_id() == 6 -def test_next_track_id(graph_2d_with_computed_features): - tracks = SolutionTracks(graph_2d_with_computed_features, ndim=3, **track_attrs) +def test_next_track_id(graph_2d_with_segmentation): + tracks = SolutionTracks(graph_2d_with_segmentation, ndim=3, **track_attrs) assert tracks.get_next_track_id() == 6 AddNode( tracks, @@ -27,9 +25,9 @@ def test_next_track_id(graph_2d_with_computed_features): assert tracks.get_next_track_id() == 11 -def test_from_tracks_cls(graph_2d_with_computed_features): +def test_from_tracks_cls(graph_2d_with_segmentation): tracks = Tracks( - graph_2d_with_computed_features, + graph_2d_with_segmentation, ndim=3, pos_attr="POSITION", time_attr="TIME", @@ -46,9 +44,9 @@ def test_from_tracks_cls(graph_2d_with_computed_features): assert solution_tracks.get_node_attr(6, tracks.features.tracklet_key) == 5 -def test_from_tracks_cls_recompute(graph_2d_with_computed_features): +def test_from_tracks_cls_recompute(graph_2d_with_segmentation): tracks = Tracks( - graph_2d_with_computed_features, + graph_2d_with_segmentation, ndim=3, pos_attr="POSITION", time_attr="TIME", @@ -71,19 +69,18 @@ def test_next_track_id_empty(): node_attributes=["pos", "track_id"], edge_attributes=[], ) - seg = np.zeros(shape=(10, 100, 100, 100), dtype=np.uint64) - tracks = SolutionTracks(graph, segmentation=seg, **track_attrs) + tracks = SolutionTracks(graph, segmentation_shape=(5, 100, 100, 100), **track_attrs) assert tracks.get_next_track_id() == 1 def test_export_to_csv_with_display_names( - graph_2d_with_computed_features, graph_3d_with_computed_features, tmp_path + graph_2d_with_segmentation, graph_3d_with_segmentation, tmp_path ): """Test CSV export with use_display_names=True option.""" from funtracks.import_export import export_to_csv # Test 2D with display names - tracks = SolutionTracks(graph_2d_with_computed_features, **track_attrs, ndim=3) + tracks = SolutionTracks(graph_2d_with_segmentation, **track_attrs, ndim=3) temp_file = tmp_path / "test_export_2d_display.csv" export_to_csv(tracks, temp_file, use_display_names=True) with open(temp_file) as f: @@ -96,7 +93,7 @@ def test_export_to_csv_with_display_names( assert lines[0].strip().split(",") == header # Test 3D with display names - tracks = SolutionTracks(graph_3d_with_computed_features, **track_attrs, ndim=4) + tracks = SolutionTracks(graph_3d_with_segmentation, **track_attrs, ndim=4) temp_file = tmp_path / "test_export_3d_display.csv" export_to_csv(tracks, temp_file, use_display_names=True) with open(temp_file) as f: diff --git a/tests/data_model/test_tracks.py b/tests/data_model/test_tracks.py index fb77295f..441d9e16 100644 --- a/tests/data_model/test_tracks.py +++ b/tests/data_model/test_tracks.py @@ -10,9 +10,7 @@ track_attrs = {"time_attr": "t", "tracklet_attr": "track_id"} -def test_create_tracks( - graph_3d_with_computed_features: td.graph.GraphView, segmentation_3d -): +def test_create_tracks(graph_3d_with_segmentation: td.graph.GraphView): # create empty tracks empty_graph = create_empty_graphview_graph() tracks = Tracks(graph=empty_graph, ndim=3, **track_attrs) # type: ignore[arg-type] @@ -23,7 +21,7 @@ def test_create_tracks( # create tracks with graph only tracks = Tracks( - graph=graph_3d_with_computed_features, + graph=graph_3d_with_segmentation, ndim=4, **track_attrs, # type: ignore[arg-type] ) @@ -38,8 +36,8 @@ def test_create_tracks( # create track with graph and seg tracks = Tracks( - graph=graph_3d_with_computed_features, - segmentation=segmentation_3d, + graph=graph_3d_with_segmentation, + segmentation_shape=(5, 100, 100, 100), **track_attrs, # type: ignore[arg-type] ) pos_key = tracks.features.position_key @@ -54,33 +52,31 @@ def test_create_tracks( # assert tracks.get_positions([1], incl_time=True).tolist() == [[1, 50, 50, 50]] tracks_wrong_attr = Tracks( - graph=graph_3d_with_computed_features, - segmentation=segmentation_3d, + graph=graph_3d_with_segmentation, + segmentation_shape=(5, 100, 100, 100), time_attr="test", ) with pytest.raises(KeyError): # raises error at access if time is wrong tracks_wrong_attr.get_times([1]) - tracks_wrong_attr = Tracks( - graph=graph_3d_with_computed_features, pos_attr="test", ndim=3 - ) + tracks_wrong_attr = Tracks(graph=graph_3d_with_segmentation, pos_attr="test", ndim=3) with pytest.raises(KeyError): # raises error at access if pos is wrong tracks_wrong_attr.get_positions([1]) # test multiple position attrs pos_attr = ("z", "y", "x") - graph_3d_with_computed_features.add_node_attr_key(key="z", default_value=0) - graph_3d_with_computed_features.add_node_attr_key(key="y", default_value=0) - graph_3d_with_computed_features.add_node_attr_key(key="x", default_value=0) - for node in graph_3d_with_computed_features.node_ids(): - pos = graph_3d_with_computed_features[node]["pos"] + graph_3d_with_segmentation.add_node_attr_key(key="z", default_value=0) + graph_3d_with_segmentation.add_node_attr_key(key="y", default_value=0) + graph_3d_with_segmentation.add_node_attr_key(key="x", default_value=0) + for node in graph_3d_with_segmentation.node_ids(): + pos = graph_3d_with_segmentation[node]["pos"] z, y, x = pos - graph_3d_with_computed_features[node]["z"] = z - graph_3d_with_computed_features[node]["y"] = y - graph_3d_with_computed_features[node]["x"] = x + graph_3d_with_segmentation[node]["z"] = z + graph_3d_with_segmentation[node]["y"] = y + graph_3d_with_segmentation[node]["x"] = x tracks = Tracks( - graph=graph_3d_with_computed_features, + graph=graph_3d_with_segmentation, pos_attr=pos_attr, ndim=4, **track_attrs, # type: ignore[arg-type] @@ -90,10 +86,12 @@ def test_create_tracks( assert tracks.get_position(1) == [55, 56, 57] -def test_pixels_and_seg_id(graph_3d_with_computed_features, segmentation_3d): +def test_pixels_and_seg_id(graph_3d_with_segmentation): # create track with graph and seg tracks = Tracks( - graph=graph_3d_with_computed_features, segmentation=segmentation_3d, **track_attrs + graph=graph_3d_with_segmentation, + segmentation_shape=(5, 100, 100, 100), + **track_attrs, ) # changing a segmentation id changes it in the mapping @@ -102,8 +100,8 @@ def test_pixels_and_seg_id(graph_3d_with_computed_features, segmentation_3d): tracks.set_pixels(pix, new_seg_id) -def test_nodes_edges(graph_2d_with_computed_features): - tracks = Tracks(graph_2d_with_computed_features, ndim=3, **track_attrs) +def test_nodes_edges(graph_2d_with_segmentation): + tracks = Tracks(graph_2d_with_segmentation, ndim=3, **track_attrs) assert set(tracks.nodes()) == {1, 2, 3, 4, 5, 6} assert set(tracks.edges()) == {1, 2, 3, 4} assert set(map(tuple, tracks.graph.edge_list())) == { @@ -114,8 +112,8 @@ def test_nodes_edges(graph_2d_with_computed_features): } -def test_degrees(graph_2d_with_computed_features): - tracks = Tracks(graph_2d_with_computed_features, ndim=3, **track_attrs) +def test_degrees(graph_2d_with_segmentation): + tracks = Tracks(graph_2d_with_segmentation, ndim=3, **track_attrs) assert tracks.in_degree(np.array([1])) == 0 assert tracks.in_degree(np.array([4])) == 1 assert np.array_equal(tracks.in_degree(None), np.array([0, 1, 1, 1, 1, 0])) @@ -126,16 +124,16 @@ def test_degrees(graph_2d_with_computed_features): ) -def test_predecessors_successors(graph_2d_with_computed_features): - tracks = Tracks(graph_2d_with_computed_features, ndim=3, **track_attrs) +def test_predecessors_successors(graph_2d_with_segmentation): + tracks = Tracks(graph_2d_with_segmentation, ndim=3, **track_attrs) assert tracks.predecessors(2) == [1] assert set(tracks.successors(1)) == {2, 3} assert tracks.predecessors(1) == [] assert tracks.successors(2) == [] -def test_get_set_node_attr(graph_2d_with_computed_features): - tracks = Tracks(graph_2d_with_computed_features, ndim=3, **track_attrs) +def test_get_set_node_attr(graph_2d_with_segmentation): + tracks = Tracks(graph_2d_with_segmentation, ndim=3, **track_attrs) tracks._set_node_attr(1, "area", 42) @@ -157,8 +155,8 @@ def test_get_set_node_attr(graph_2d_with_computed_features): tracks._set_nodes_attr((1, 2), "pos", np.array(([1, 2], [4, 5]))) -def test_get_set_edge_attr(graph_2d_with_computed_features): - tracks = Tracks(graph_2d_with_computed_features, ndim=3, **track_attrs) +def test_get_set_edge_attr(graph_2d_with_segmentation): + tracks = Tracks(graph_2d_with_segmentation, ndim=3, **track_attrs) tracks._set_edge_attr((1, 2), "iou", 99) assert tracks.get_edge_attr((1, 2), "iou") == 99 assert tracks.get_edge_attr((1, 2), "iou", required=True) == 99 @@ -176,8 +174,8 @@ def test_get_set_edge_attr(graph_2d_with_computed_features): ) -def test_set_positions_str(graph_2d_with_computed_features): - tracks = Tracks(graph_2d_with_computed_features, ndim=3, **track_attrs) +def test_set_positions_str(graph_2d_with_segmentation): + tracks = Tracks(graph_2d_with_segmentation, ndim=3, **track_attrs) tracks.set_positions((1, 2), [(1, 2), (3, 4)]) assert np.array_equal( tracks.get_positions((1, 2), incl_time=False), np.array([[1, 2], [3, 4]]) @@ -202,27 +200,26 @@ def test_set_positions_list(graph_2d_list): ) -def test_get_pixels_and_set_pixels(graph_2d_with_computed_features, segmentation_2d): +def test_get_pixels_and_set_pixels(graph_2d_with_segmentation): tracks = Tracks( - graph_2d_with_computed_features, segmentation_2d, ndim=3, **track_attrs + graph_2d_with_segmentation, + segmentation_shape=(5, 100, 100), + ndim=3, + **track_attrs, ) pix = tracks.get_pixels(1) assert isinstance(pix, tuple) tracks.set_pixels(pix, 99) - assert tracks.segmentation[0, 50, 50] == 99 + assert np.asarray(tracks.segmentation)[0, 50, 50] == 99 -def test_get_pixels_none(graph_2d_with_computed_features): - tracks = Tracks( - graph_2d_with_computed_features, segmentation=None, ndim=3, **track_attrs - ) +def test_get_pixels_none(graph_2d_with_segmentation): + tracks = Tracks(graph_2d_with_segmentation, ndim=3, **track_attrs) assert tracks.get_pixels([1]) is None -def test_set_pixels_no_segmentation(graph_2d_with_computed_features): - tracks = Tracks( - graph_2d_with_computed_features, segmentation=None, ndim=3, **track_attrs - ) +def test_set_pixels_no_segmentation(graph_2d_with_segmentation): + tracks = Tracks(graph_2d_with_segmentation, ndim=3, **track_attrs) pix = [(np.array([0]), np.array([10]), np.array([20]))] with pytest.raises(ValueError): tracks.set_pixels(pix, [1]) @@ -233,9 +230,8 @@ def test_compute_ndim_errors(): g.add_node_attr_key("pos", default_value=None) g.add_node(index=1, attrs={"t": 0, "pos": [0, 0, 0], "solution": True}) # seg ndim = 3, scale ndim = 2, provided ndim = 4 -> mismatch - seg = np.zeros((2, 2, 2)) - with pytest.raises(ValueError, match="Dimensions from segmentation"): - Tracks(g, segmentation=seg, scale=[1, 2], ndim=4) + with pytest.raises(ValueError, match="segmentation_shape is incompatible with graph"): + Tracks(g, segmentation_shape=(2, 2, 2), scale=[1, 2], ndim=4) with pytest.raises( ValueError, match="Cannot compute dimensions from segmentation or scale" diff --git a/tests/import_export/test_internal_format.py b/tests/import_export/test_internal_format.py index 5de30e25..8112cc60 100644 --- a/tests/import_export/test_internal_format.py +++ b/tests/import_export/test_internal_format.py @@ -107,7 +107,7 @@ def test_delete( # for backward compatibility -def test_load_without_features(tmp_path, graph_2d_with_computed_features): +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 diff --git a/tests/import_export/test_name_mapping.py b/tests/import_export/test_name_mapping.py index 3d7d3b46..fe436ed3 100644 --- a/tests/import_export/test_name_mapping.py +++ b/tests/import_export/test_name_mapping.py @@ -148,7 +148,7 @@ def test_empty_available_props(self): class TestMatchDisplayNamesExact: """Test exact matching between properties and feature display names.""" - def test_exact_display_name_match(self): + def test_exact_display_name_match(self) -> None: """Test exact matching with display names.""" available_props = ["Area", "Circularity", "time"] display_name_to_key = { @@ -164,7 +164,7 @@ def test_exact_display_name_match(self): assert mapping == {"area": "Area", "circularity": "Circularity"} assert remaining == ["time"] - def test_no_matches(self): + def test_no_matches(self) -> None: """Test when no properties match display names.""" available_props = ["t", "x", "y"] display_name_to_key = { @@ -180,14 +180,14 @@ def test_no_matches(self): assert mapping == {} assert remaining == ["t", "x", "y"] - def test_empty_inputs(self): + 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): + def test_case_sensitive(self) -> None: """Test that exact matching is case-sensitive.""" available_props = ["area", "AREA"] display_name_to_key = {"Area": ("area", 0)} @@ -200,7 +200,7 @@ def test_case_sensitive(self): assert mapping == {} # Neither "area" nor "AREA" matches "Area" exactly assert set(remaining) == {"area", "AREA"} - def test_multi_value_feature(self): + 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 = { @@ -225,7 +225,7 @@ def test_multi_value_feature(self): class TestMatchDisplayNamesFuzzy: """Test fuzzy matching between properties and feature display names.""" - def test_case_insensitive_match(self): + def test_case_insensitive_match(self) -> None: """Test case-insensitive fuzzy matching.""" available_props = ["area", "CIRC"] display_name_to_key = { @@ -239,7 +239,7 @@ def test_case_insensitive_match(self): assert "area" in mapping assert "circularity" in mapping - def test_abbreviation_match(self): + def test_abbreviation_match(self) -> None: """Test matching abbreviations to display names.""" available_props = ["Circ", "Ecc"] display_name_to_key = { @@ -253,7 +253,7 @@ def test_abbreviation_match(self): assert "circularity" in mapping assert "eccentricity" in mapping - def test_no_matches(self): + def test_no_matches(self) -> None: """Test when no fuzzy matches found.""" available_props = ["xyz", "abc"] display_name_to_key = {"Area": ("area", 0)} @@ -266,7 +266,7 @@ def test_no_matches(self): assert mapping == {} assert set(remaining) == {"xyz", "abc"} - def test_empty_available_props(self): + def test_empty_available_props(self) -> None: """Test with empty available properties.""" mapping: dict = {} remaining = _match_display_names_fuzzy([], {"Area": ("area", 0)}, mapping) @@ -274,7 +274,7 @@ def test_empty_available_props(self): assert mapping == {} assert remaining == [] - def test_custom_cutoff(self): + def test_custom_cutoff(self) -> None: """Test with custom cutoff value.""" available_props = ["Ar"] display_name_to_key = {"Area": ("area", 0)} @@ -293,7 +293,7 @@ def test_custom_cutoff(self): ) assert mapping_high == {} - def test_multi_value_feature(self): + 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 = { diff --git a/tests/user_actions/test_user_add_delete_node.py b/tests/user_actions/test_user_add_delete_node.py index c4dbaeac..abfca563 100644 --- a/tests/user_actions/test_user_add_delete_node.py +++ b/tests/user_actions/test_user_add_delete_node.py @@ -46,7 +46,7 @@ def test_user_add_node(self, get_tracks, ndim, with_seg): "t": time, } if with_seg: - seg_copy = tracks.segmentation.copy() + seg_copy = np.asarray(tracks.segmentation).copy() if ndim == 3: seg_copy[time, position[0], position[1]] = node_id else: diff --git a/tests/user_actions/test_user_update_segmentation.py b/tests/user_actions/test_user_update_segmentation.py index 5aac7653..93e950c9 100644 --- a/tests/user_actions/test_user_update_segmentation.py +++ b/tests/user_actions/test_user_update_segmentation.py @@ -114,9 +114,7 @@ def test_user_erase_seg(self, get_tracks, ndim): # remove all pixels pixels_to_remove = orig_pixels - # set the pixels in the array first - # (to reflect that the user directly changes the segmentation array) - tracks.set_pixels(pixels_to_remove, 0) + # setting of pixels no longer necessary, done in UpdateNodeSeg action = UserUpdateSegmentation( tracks, new_value=0, @@ -125,7 +123,6 @@ def test_user_erase_seg(self, get_tracks, ndim): ) assert node_id not in tracks.graph.node_ids() - tracks.set_pixels(pixels_to_remove, node_id) inverse = action.inverse() assert node_id in tracks.graph.node_ids() self.pixel_equals(tracks.get_pixels(node_id), orig_pixels) @@ -133,7 +130,6 @@ def test_user_erase_seg(self, get_tracks, ndim): 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) - tracks.set_pixels(pixels_to_remove, 0) inverse.inverse() assert node_id not in tracks.graph.node_ids() @@ -155,14 +151,13 @@ def test_user_add_seg(self, get_tracks, ndim): assert node_id not in tracks.graph.node_ids() assert np.sum(tracks.segmentation == node_id) == 0 - tracks.set_pixels(pixels_to_add, node_id) action = UserUpdateSegmentation( tracks, new_value=node_id, updated_pixels=[(pixels_to_add, 0)], current_track_id=10, ) - assert np.sum(tracks.segmentation == node_id) == len(pixels_to_add[0]) + assert np.sum(np.asarray(tracks.segmentation) == node_id) == len(pixels_to_add[0]) assert node_id in tracks.graph.node_ids() assert tracks.get_position(node_id) == position assert tracks.get_node_attr(node_id, "area") == area diff --git a/tests/utils/test_tracksdata_utils.py b/tests/utils/test_tracksdata_utils.py new file mode 100644 index 00000000..02fe1be9 --- /dev/null +++ b/tests/utils/test_tracksdata_utils.py @@ -0,0 +1,131 @@ +"""Tests for tracksdata utility functions.""" + +import numpy as np +import pytest + +# Import from conftest (pytest makes it available automatically) +from conftest import ( + make_2d_disk_mask, + make_2d_square_mask, + make_3d_cube_mask, + make_3d_sphere_mask, +) + +from funtracks.utils.tracksdata_utils import pixels_to_td_mask, td_mask_to_pixels + + +@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=None) + + # 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) + + # 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._mask 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 From fe767aeb174cd281c0da5217612a3277d7ca4d1a Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Wed, 7 Jan 2026 16:54:04 -0800 Subject: [PATCH 19/44] updated tracksdata version --- pyproject.toml | 2 +- src/funtracks/data_model/tracks.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d46f4161..fbc3b8bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies =[ "pandas>=2.3.3", "zarr>=2.18,<4", "numcodecs>=0.13,<0.16", - "tracksdata[spatial]@git+https://github.com/royerlab/tracksdata@b35a0f111f8d39baec00245a457d65941222174b", + "tracksdata[spatial]@git+https://github.com/royerlab/tracksdata@24d93ef8d3c4c44cedc346dc3229c98c1d3eaa5e", ] [project.urls] diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index ca6e81e0..e55b9b6f 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -592,6 +592,15 @@ def _update_segmentation_cache(self, pixels: tuple[np.ndarray, ...]) -> None: for idx, grid_size in zip(chunk_idx, cache.grid_shape, strict=True) ): cache_entry.ready[chunk_idx] = False + # Clear the buffer to ensure stale data isn't used + # when the chunk is recomputed + chunk_slc = tuple( + slice(ci * cs, min((ci + 1) * cs, fs)) + for ci, cs, fs in zip( + chunk_idx, cache.chunk_shape, cache.shape, strict=True + ) + ) + cache_entry.buffer[chunk_slc] = 0 def _compute_ndim( self, From 736daeb00ef3a95bf62b64b1d0254b743ed93cb6 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Wed, 7 Jan 2026 17:08:28 -0800 Subject: [PATCH 20/44] use graph.has_node, instead of: node in graph.node_ids() --- src/funtracks/actions/add_delete_edge.py | 2 +- .../annotators/_regionprops_annotator.py | 2 +- src/funtracks/data_model/tracks.py | 2 +- src/funtracks/user_actions/user_add_edge.py | 4 ++-- src/funtracks/user_actions/user_add_node.py | 2 +- .../user_actions/user_update_segmentation.py | 2 +- tests/actions/test_add_delete_nodes.py | 2 +- .../import_export/test_import_segmentation.py | 6 ++--- tests/user_actions/test_user_actions_force.py | 2 +- .../user_actions/test_user_add_delete_node.py | 24 +++++++++---------- .../test_user_update_segmentation.py | 24 +++++++++---------- 11 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/funtracks/actions/add_delete_edge.py b/src/funtracks/actions/add_delete_edge.py index 4376d5b8..2aa4af52 100644 --- a/src/funtracks/actions/add_delete_edge.py +++ b/src/funtracks/actions/add_delete_edge.py @@ -49,7 +49,7 @@ def _apply(self) -> None: """ # Check that both endpoints exist before computing edge attributes for node in self.edge: - if node not in self.tracks.graph.node_ids(): + if not self.tracks.graph.has_node(node): raise ValueError( f"Cannot add edge {self.edge}: endpoint {node} not in graph yet" ) diff --git a/src/funtracks/annotators/_regionprops_annotator.py b/src/funtracks/annotators/_regionprops_annotator.py index f4c669c2..ed4db68c 100644 --- a/src/funtracks/annotators/_regionprops_annotator.py +++ b/src/funtracks/annotators/_regionprops_annotator.py @@ -182,7 +182,7 @@ def _regionprops_update(self, seg_frame: np.ndarray, feature_keys: list[str]) -> for region in regionprops_extended(seg_frame, spacing=spacing): node = region.label # Skip labels that aren't nodes in the graph (e.g., unselected detections) - if node not in self.tracks.graph.node_ids(): + if not self.tracks.graph.has_node(node): continue for key in feature_keys: value = getattr(region, self.regionprops_names[key]) diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index e55b9b6f..1ded4f72 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -507,7 +507,7 @@ def set_pixels( node_ids=[node_id], ) - elif value in self.graph.node_ids(): + elif self.graph.has_node(value): # if node already exists: mask_old = self.graph[value][td.DEFAULT_ATTR_KEYS.MASK] mask_combined, area_combined = combine_td_masks( diff --git a/src/funtracks/user_actions/user_add_edge.py b/src/funtracks/user_actions/user_add_edge.py index 2e96aa0f..c830fbe7 100644 --- a/src/funtracks/user_actions/user_add_edge.py +++ b/src/funtracks/user_actions/user_add_edge.py @@ -33,11 +33,11 @@ def __init__( super().__init__(tracks, actions=[]) self.tracks: SolutionTracks # Narrow type from base class source, target = edge - if source not in tracks.graph.node_ids(): + if not tracks.graph.has_node(source): raise InvalidActionError( f"Source node {source} not in solution yet - must be added before edge" ) - if target not in tracks.graph.node_ids(): + if not tracks.graph.has_node(target): raise InvalidActionError( f"Target node {target} not in solution yet - must be added before edge" ) diff --git a/src/funtracks/user_actions/user_add_node.py b/src/funtracks/user_actions/user_add_node.py index 5246ba10..89bf68ad 100644 --- a/src/funtracks/user_actions/user_add_node.py +++ b/src/funtracks/user_actions/user_add_node.py @@ -70,7 +70,7 @@ def __init__( raise InvalidActionError( f"Cannot add node without track id. Please add {track_id_key} attribute" ) - if node in self.tracks.graph.node_ids(): + if self.tracks.graph.has_node(node): raise InvalidActionError( f"Node {node} already exists in the tracks, cannot add." ) diff --git a/src/funtracks/user_actions/user_update_segmentation.py b/src/funtracks/user_actions/user_update_segmentation.py index 44cfab75..c81aa6cc 100644 --- a/src/funtracks/user_actions/user_update_segmentation.py +++ b/src/funtracks/user_actions/user_update_segmentation.py @@ -66,7 +66,7 @@ def __init__( "Can only update one time point at a time" ) time = all_pixels[0][0] - if new_value in self.tracks.graph.node_ids(): + if self.tracks.graph.has_node(new_value): self.actions.append( UpdateNodeSeg(tracks, new_value, all_pixels, added=True) ) diff --git a/tests/actions/test_add_delete_nodes.py b/tests/actions/test_add_delete_nodes.py index 5a1481d4..9f2fca23 100644 --- a/tests/actions/test_add_delete_nodes.py +++ b/tests/actions/test_add_delete_nodes.py @@ -201,7 +201,7 @@ def test_custom_attributes_preserved(get_tracks, ndim, with_seg): action = AddNode(tracks, node_id, custom_attrs, pixels=pixels) # Verify all attributes are present after adding - assert node_id in tracks.graph.node_ids() + assert tracks.graph.has_node(node_id) for key, value in custom_attrs.items(): if key == "pos": assert_array_almost_equal(tracks.graph[node_id][key], np.array(value)) diff --git a/tests/import_export/test_import_segmentation.py b/tests/import_export/test_import_segmentation.py index cc22dd6d..8f0999c5 100644 --- a/tests/import_export/test_import_segmentation.py +++ b/tests/import_export/test_import_segmentation.py @@ -86,9 +86,9 @@ def test_relabeling_with_node_id_zero(self): assert result[1, 2, 2] == 2 # Graph should also be relabeled - assert 1 in graph.node_ids() - assert 2 in graph.node_ids() - assert 0 not in graph.node_ids() + 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).""" diff --git a/tests/user_actions/test_user_actions_force.py b/tests/user_actions/test_user_actions_force.py index 23a209cb..1096b958 100644 --- a/tests/user_actions/test_user_actions_force.py +++ b/tests/user_actions/test_user_actions_force.py @@ -44,5 +44,5 @@ def test_auto_assign_new_track_id(get_tracks): attrs = {"t": 1, "track_id": 2, "pos": [3, 4]} # combination exists already UserAddNode(tracks, node=7, attributes=attrs) - assert 7 in tracks.graph.node_ids() + assert tracks.graph.has_node(7) assert tracks.get_track_id(7) == 6 # new assigned track id diff --git a/tests/user_actions/test_user_add_delete_node.py b/tests/user_actions/test_user_add_delete_node.py index abfca563..82d65d42 100644 --- a/tests/user_actions/test_user_add_delete_node.py +++ b/tests/user_actions/test_user_add_delete_node.py @@ -56,10 +56,10 @@ def test_user_add_node(self, get_tracks, ndim, with_seg): else: pixels = None graph = tracks.graph - assert node_id not in graph.node_ids() + assert not graph.has_node(node_id) assert graph.has_edge(4, 5) action = UserAddNode(tracks, node_id, attributes, pixels=pixels) - assert node_id in graph.node_ids() + 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) @@ -69,11 +69,11 @@ def test_user_add_node(self, get_tracks, ndim, with_seg): assert tracks.get_node_attr(node_id, "area") == 1 inverse = action.inverse() - assert node_id not in graph.node_ids() + assert not graph.has_node(node_id) assert graph.has_edge(4, 5) inverse.inverse() - assert node_id in graph.node_ids() + 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) @@ -89,25 +89,25 @@ def test_user_delete_node(self, get_tracks, ndim, with_seg): node_id = 4 graph = tracks.graph - assert node_id in graph.node_ids() + 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 node_id not in graph.node_ids() + 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 node_id in graph.node_ids() + 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 node_id not in graph.node_ids() + 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) @@ -122,7 +122,7 @@ def test_user_delete_node_after_division(self, get_tracks, ndim, with_seg): sib = 3 graph = tracks.graph - assert node_id in graph.node_ids() + 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) @@ -132,18 +132,18 @@ def test_user_delete_node_after_division(self, get_tracks, ndim, with_seg): assert node_track_id != sib_track_id action = UserDeleteNode(tracks, node_id) - assert node_id not in graph.node_ids() + 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 node_id in graph.node_ids() + 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 node_id not in graph.node_ids() + assert not graph.has_node(node_id) assert graph.has_edge(parent_node, sib) assert tracks.get_track_id(sib) == parent_track_id diff --git a/tests/user_actions/test_user_update_segmentation.py b/tests/user_actions/test_user_update_segmentation.py index 93e950c9..479f0fd9 100644 --- a/tests/user_actions/test_user_update_segmentation.py +++ b/tests/user_actions/test_user_update_segmentation.py @@ -39,14 +39,14 @@ def test_user_update_seg_smaller(self, get_tracks, ndim): updated_pixels=[(pixels_to_remove, node_id)], current_track_id=1, ) - assert node_id in tracks.graph.node_ids() + assert tracks.graph.has_node(node_id) assert self.pixel_equals(tracks.get_pixels(node_id), remaining_pixels) 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 node_id in tracks.graph.node_ids() + assert tracks.graph.has_node(node_id) assert self.pixel_equals(tracks.get_pixels(node_id), orig_pixels) assert tracks.get_position(node_id) == orig_position assert tracks.get_node_attr(node_id, "area") == orig_area @@ -84,20 +84,20 @@ def test_user_update_seg_bigger(self, get_tracks, ndim): action = UserUpdateSegmentation( tracks, new_value=3, updated_pixels=[(pixels_to_add, 0)], current_track_id=1 ) - assert node_id in tracks.graph.node_ids() + assert tracks.graph.has_node(node_id) assert self.pixel_equals(all_pixels, tracks.get_pixels(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 node_id in tracks.graph.node_ids() + assert tracks.graph.has_node(node_id) assert self.pixel_equals(orig_pixels, tracks.get_pixels(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 node_id in tracks.graph.node_ids() + assert tracks.graph.has_node(node_id) assert self.pixel_equals(all_pixels, tracks.get_pixels(node_id)) assert tracks.get_node_attr(node_id, "area") == orig_area + 1 assert tracks.get_edge_attr(edge, iou_key) != orig_iou @@ -121,17 +121,17 @@ def test_user_erase_seg(self, get_tracks, ndim): updated_pixels=[(pixels_to_remove, node_id)], current_track_id=1, ) - assert node_id not in tracks.graph.node_ids() + assert not tracks.graph.has_node(node_id) inverse = action.inverse() - assert node_id in tracks.graph.node_ids() + assert tracks.graph.has_node(node_id) self.pixel_equals(tracks.get_pixels(node_id), orig_pixels) 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 node_id not in tracks.graph.node_ids() + assert not tracks.graph.has_node(node_id) def test_user_add_seg(self, get_tracks, ndim): tracks = get_tracks(ndim=ndim, with_seg=True, is_solution=True) @@ -148,7 +148,7 @@ def test_user_add_seg(self, get_tracks, ndim): position = tracks.get_position(old_node_id) area = tracks.get_node_attr(old_node_id, "area") - assert node_id not in tracks.graph.node_ids() + assert not tracks.graph.has_node(node_id) assert np.sum(tracks.segmentation == node_id) == 0 action = UserUpdateSegmentation( @@ -158,16 +158,16 @@ def test_user_add_seg(self, get_tracks, ndim): current_track_id=10, ) assert np.sum(np.asarray(tracks.segmentation) == node_id) == len(pixels_to_add[0]) - assert node_id in tracks.graph.node_ids() + 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 node_id not in tracks.graph.node_ids() + assert not tracks.graph.has_node(node_id) inverse.inverse() - assert node_id in tracks.graph.node_ids() + 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 From ec952bc670bcbc9b6ddb999151545cdd60c13574 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Thu, 8 Jan 2026 11:23:42 -0800 Subject: [PATCH 21/44] all tests passing with td backend and segmentation on graph! --- .../import_export/_tracks_builder.py | 13 ++- src/funtracks/import_export/_v1_format.py | 17 +++- src/funtracks/utils/tracksdata_utils.py | 93 +++++++++++++++++++ tests/import_export/test_csv_import.py | 4 +- tests/import_export/test_export_to_geff.py | 12 ++- tests/import_export/test_import_from_geff.py | 2 +- 6 files changed, 127 insertions(+), 14 deletions(-) diff --git a/src/funtracks/import_export/_tracks_builder.py b/src/funtracks/import_export/_tracks_builder.py index 3f264784..74d85938 100644 --- a/src/funtracks/import_export/_tracks_builder.py +++ b/src/funtracks/import_export/_tracks_builder.py @@ -38,6 +38,7 @@ validate_spatial_dims, ) from funtracks.utils.tracksdata_utils import ( + add_masks_and_bboxes_to_graph, create_empty_graphview_graph, ) @@ -697,17 +698,23 @@ def build( graph, segmentation, scale ) - # 6. Create SolutionTracks + # 6. Add segmentation to the graph + if segmentation_array is not None: + graph = add_masks_and_bboxes_to_graph(graph, segmentation_array) + + # 7. Create SolutionTracks tracks = SolutionTracks( graph=graph, - segmentation=segmentation_array, + segmentation_shape=None + if segmentation_array is None + else segmentation_array.shape, pos_attr="pos", time_attr=self.TIME_ATTR, ndim=self.ndim, scale=scale, ) - # 7. Enable and register features + # 8. Enable and register features if node_features is not None: self.enable_features(tracks, node_features, feature_type="node") if edge_features is not None: diff --git a/src/funtracks/import_export/_v1_format.py b/src/funtracks/import_export/_v1_format.py index dcfe5e3c..21725ca3 100644 --- a/src/funtracks/import_export/_v1_format.py +++ b/src/funtracks/import_export/_v1_format.py @@ -9,7 +9,10 @@ import numpy as np from funtracks.features import FeatureDict -from funtracks.utils.tracksdata_utils import convert_graph_nx_to_td +from funtracks.utils.tracksdata_utils import ( + add_masks_and_bboxes_to_graph, + convert_graph_nx_to_td, +) if TYPE_CHECKING: from ..data_model import SolutionTracks, Tracks @@ -48,6 +51,12 @@ def load_v1_tracks( graph_td = convert_graph_nx_to_td(graph_nx) + # Add mask and bbox attributes to graph if segmentation is available + if seg is not None: + graph_td = add_masks_and_bboxes_to_graph(graph_td, seg) + + segmentation_shape = seg.shape if seg is not None else None + # filtering the warnings because the default values of time_attr and pos_attr are # not None. Therefore, new style Tracks attrs that have features instead of # pos_attr and time_attr will always trigger the warning. Updating default values @@ -62,9 +71,11 @@ def load_v1_tracks( ) tracks: Tracks if solution: - tracks = SolutionTracks(graph_td, seg, **attrs) + tracks = SolutionTracks( + graph_td, segmentation_shape=segmentation_shape, **attrs + ) else: - tracks = Tracks(graph_td, seg, **attrs) + tracks = Tracks(graph_td, segmentation_shape=segmentation_shape, **attrs) return tracks diff --git a/src/funtracks/utils/tracksdata_utils.py b/src/funtracks/utils/tracksdata_utils.py index a17502d6..dbcfffea 100644 --- a/src/funtracks/utils/tracksdata_utils.py +++ b/src/funtracks/utils/tracksdata_utils.py @@ -6,6 +6,7 @@ import networkx as nx import numpy as np import polars as pl +import scipy.ndimage as ndi import tracksdata as td from polars.testing import assert_frame_equal from skimage import measure @@ -500,6 +501,98 @@ def subtract_td_masks( return Mask(final_mask, bbox=result_bbox), float(area) +def segmentation_to_masks_and_bboxes( + segmentation: np.ndarray, +) -> list[tuple[int, int, Mask]]: + """Convert a segmentation array to individual masks and bounding boxes. + + Parameters + ---------- + segmentation : np.ndarray + Segmentation array of shape (T, Z, Y, X) or (T, Y, X) + Each unique value represents a different segment/object. + + Returns + ------- + list[tuple[int, int, Mask]] + List of tuples, one per segment, containing: + - label (int): original label ID + - time (int): time point + - mask (Mask): tracksdata Mask object with boolean mask and bbox + """ + results = [] + + # Process each time point + for t in range(segmentation.shape[0]): + time_slice = segmentation[t] + + # Get unique labels + labels = np.unique(time_slice) + labels = labels[labels != 0] + + # Find objects for each label + for label in labels: + # Create binary mask for this label + binary_mask = time_slice == label + + # Find bounding box using scipy (same as Ultrack uses) + slices = ndi.find_objects(binary_mask.astype(int))[0] + + if slices is None: + continue + + # Extract the cropped mask and ensure C-contiguous for blosc2 serialization + cropped_mask = np.ascontiguousarray(binary_mask[slices]) + + # Convert slices to bbox format (min_*, max_*) + ndim = len(slices) + bbox = np.array( + [slices[i].start for i in range(ndim)] # min coordinates + + [slices[i].stop for i in range(ndim)] # max coordinates + ) + + # Create Mask object + mask = Mask(cropped_mask, bbox=bbox) + + results.append((int(label), t, mask)) + + return results + + +def add_masks_and_bboxes_to_graph( + graph: td.graph.GraphView, + segmentation: np.ndarray, +) -> td.graph.GraphView: + """Add mask and bbox attributes to graph nodes from segmentation. + + Parameters + ---------- + graph : td.graph.GraphView + Graph to add attributes to + segmentation : np.ndarray + Segmentation array of shape (T, Z, Y, X) or (T, Y, X) + + Returns + ------- + td.graph.GraphView + Graph with 'mask' and 'bbox' attributes added to nodes + """ + + # Convert segmentation to masks and bounding boxes + list_of_masks = segmentation_to_masks_and_bboxes(segmentation) + + # Add 'mask' and 'bbox' attributes to graph nodes + graph.add_node_attr_key("mask", default_value=None) + graph.add_node_attr_key("bbox", default_value=None) + + for label, _, mask in list_of_masks: + if graph.has_node(label): + graph[label]["mask"] = [mask] + graph[label]["bbox"] = [mask.bbox] + + return graph + + def td_get_single_attr_from_edge(graph, edge: tuple[int, int], attrs: Sequence[str]): """Get a single attribute from a edge in a tracksdata graph.""" diff --git a/tests/import_export/test_csv_import.py b/tests/import_export/test_csv_import.py index c2c36324..241948f9 100644 --- a/tests/import_export/test_csv_import.py +++ b/tests/import_export/test_csv_import.py @@ -123,7 +123,7 @@ def test_seg_id_matches_id(self, simple_df_2d): tracks = tracks_from_df(df, seg) assert tracks.segmentation is not None # Segmentation should not be relabeled - assert tracks.segmentation[0, 10, 15] == 1 + assert np.asarray(tracks.segmentation)[0, 10, 15] == 1 class TestEdgeCases: @@ -356,7 +356,7 @@ def test_duplicate_mapping_with_segmentation(self, simple_df_2d): assert tracks.segmentation is not None # Segmentation should not be relabeled since seg_id == id - assert tracks.segmentation[0, 10, 15] == 1 + assert np.asarray(tracks.segmentation)[0, 10, 15] == 1 class TestValidationErrors: diff --git a/tests/import_export/test_export_to_geff.py b/tests/import_export/test_export_to_geff.py index c7eb831e..af25c195 100644 --- a/tests/import_export/test_export_to_geff.py +++ b/tests/import_export/test_export_to_geff.py @@ -13,7 +13,6 @@ def test_export_to_geff( get_tracks, get_graph, - get_segmentation, ndim, with_seg, is_solution, @@ -33,8 +32,11 @@ def test_export_to_geff( if pos_attr_type is list: # For split pos, we need to manually create tracks since get_tracks # doesn't support this - graph = get_graph(ndim, with_features="computed") - segmentation = get_segmentation(ndim) if with_seg else None + graph_type = "segmentation" if with_seg else "position" + graph = get_graph(ndim, with_features=graph_type) + segmentation_shape = None + if with_seg: + segmentation_shape = (5, 20, 20) if ndim == 3 else (3, 5, 20, 20) # Determine position attribute keys based on dimensions pos_keys = ["y", "x"] if ndim == 3 else ["z", "y", "x"] @@ -51,7 +53,7 @@ def test_export_to_geff( tracks_cls = SolutionTracks if is_solution else Tracks tracks = tracks_cls( graph, - segmentation=segmentation, + segmentation_shape=segmentation_shape, time_attr="t", pos_attr=pos_keys, tracklet_attr="track_id", @@ -128,7 +130,7 @@ def test_export_to_geff( seg_zarr = zarr.open(str(seg_path), mode="r") assert isinstance(seg_zarr, zarr.Array) - filtered_seg = tracks.segmentation.copy() + filtered_seg = np.asarray(tracks.segmentation).copy() mask = np.isin(filtered_seg, [1, 3, 4, 6]) filtered_seg[~mask] = 0 np.testing.assert_array_equal(seg_zarr[:], filtered_seg) diff --git a/tests/import_export/test_import_from_geff.py b/tests/import_export/test_import_from_geff.py index 9b36a533..f0b17a75 100644 --- a/tests/import_export/test_import_from_geff.py +++ b/tests/import_export/test_import_from_geff.py @@ -326,7 +326,7 @@ def test_tracks_with_segmentation(valid_geff, invalid_geff, valid_segmentation, valid_segmentation[tuple(coords)] == 50 ) # in original segmentation, the pixel value is equal to seg_id assert ( - tracks.segmentation[tuple(coords)] == last_node + np.asarray(tracks.segmentation)[tuple(coords)] == last_node ) # test that the seg id has been relabeled # Check that only required/requested features are present, and that area is recomputed From 3c4c535ecbcbe4813a8dcfed82dd75292f8c39b9 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Thu, 8 Jan 2026 12:00:49 -0800 Subject: [PATCH 22/44] function name change --- src/funtracks/import_export/csv/_import.py | 3 +++ src/funtracks/utils/tracksdata_utils.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/funtracks/import_export/csv/_import.py b/src/funtracks/import_export/csv/_import.py index 457a5471..09fc6ce1 100644 --- a/src/funtracks/import_export/csv/_import.py +++ b/src/funtracks/import_export/csv/_import.py @@ -268,6 +268,9 @@ def tracks_from_df( # Auto-infer name mapping from DataFrame columns builder.prepare(df) + # TODO Teun: allow the masks and bboxes to be on the dataframe, + # instead of a separate segmentation array + return builder.build( df, segmentation, diff --git a/src/funtracks/utils/tracksdata_utils.py b/src/funtracks/utils/tracksdata_utils.py index dbcfffea..679b4d45 100644 --- a/src/funtracks/utils/tracksdata_utils.py +++ b/src/funtracks/utils/tracksdata_utils.py @@ -501,7 +501,7 @@ def subtract_td_masks( return Mask(final_mask, bbox=result_bbox), float(area) -def segmentation_to_masks_and_bboxes( +def segmentation_to_masks( segmentation: np.ndarray, ) -> list[tuple[int, int, Mask]]: """Convert a segmentation array to individual masks and bounding boxes. @@ -579,7 +579,7 @@ def add_masks_and_bboxes_to_graph( """ # Convert segmentation to masks and bounding boxes - list_of_masks = segmentation_to_masks_and_bboxes(segmentation) + list_of_masks = segmentation_to_masks(segmentation) # Add 'mask' and 'bbox' attributes to graph nodes graph.add_node_attr_key("mask", default_value=None) From c8d819fbc4118586d12517177c7021921fddb89e Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Thu, 8 Jan 2026 14:19:01 -0800 Subject: [PATCH 23/44] fix github testing ci --- tests/__init__.py | 2 ++ tests/actions/__init__.py | 1 + tests/actions/test_add_delete_nodes.py | 3 ++- tests/annotators/__init__.py | 2 ++ tests/data_model/__init__.py | 2 ++ tests/features/__init__.py | 2 ++ tests/import_export/__init__.py | 2 ++ tests/user_actions/__init__.py | 2 ++ tests/utils/__init__.py | 2 ++ tests/utils/test_tracksdata_utils.py | 8 ++++---- 10 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/actions/__init__.py create mode 100644 tests/annotators/__init__.py create mode 100644 tests/data_model/__init__.py create mode 100644 tests/features/__init__.py create mode 100644 tests/import_export/__init__.py create mode 100644 tests/user_actions/__init__.py create mode 100644 tests/utils/__init__.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..818e0478 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +# This file makes the tests directory a Python package +# to support relative imports diff --git a/tests/actions/__init__.py b/tests/actions/__init__.py new file mode 100644 index 00000000..1b68a100 --- /dev/null +++ b/tests/actions/__init__.py @@ -0,0 +1 @@ +# This file makes the tests/actions directory a Python package to support relative imports diff --git a/tests/actions/test_add_delete_nodes.py b/tests/actions/test_add_delete_nodes.py index 9f2fca23..65f03bc6 100644 --- a/tests/actions/test_add_delete_nodes.py +++ b/tests/actions/test_add_delete_nodes.py @@ -2,7 +2,6 @@ import pytest from numpy.testing import assert_array_almost_equal, assert_array_equal from polars.testing import assert_frame_equal -from tests.conftest import make_2d_disk_mask, make_3d_sphere_mask from tracksdata.array import GraphArrayView from funtracks.actions import ( @@ -15,6 +14,8 @@ td_mask_to_pixels, ) +from ..conftest import make_2d_disk_mask, make_3d_sphere_mask + @pytest.mark.parametrize("ndim", [3, 4]) @pytest.mark.parametrize("with_seg", [True, False]) diff --git a/tests/annotators/__init__.py b/tests/annotators/__init__.py new file mode 100644 index 00000000..cb930077 --- /dev/null +++ b/tests/annotators/__init__.py @@ -0,0 +1,2 @@ +# This file makes the tests/annotators directory a Python package +# to support relative imports diff --git a/tests/data_model/__init__.py b/tests/data_model/__init__.py new file mode 100644 index 00000000..315c8552 --- /dev/null +++ b/tests/data_model/__init__.py @@ -0,0 +1,2 @@ +# This file makes the tests/data_model directory a Python package +# to support relative imports diff --git a/tests/features/__init__.py b/tests/features/__init__.py new file mode 100644 index 00000000..8a122a91 --- /dev/null +++ b/tests/features/__init__.py @@ -0,0 +1,2 @@ +# This file makes the tests/features directory a Python package +# to support relative imports diff --git a/tests/import_export/__init__.py b/tests/import_export/__init__.py new file mode 100644 index 00000000..7b33f386 --- /dev/null +++ b/tests/import_export/__init__.py @@ -0,0 +1,2 @@ +# This file makes the tests/import_export directory a Python package +# to support relative imports diff --git a/tests/user_actions/__init__.py b/tests/user_actions/__init__.py new file mode 100644 index 00000000..5c7fde60 --- /dev/null +++ b/tests/user_actions/__init__.py @@ -0,0 +1,2 @@ +# This file makes the tests/user_actions directory a Python package +# to support relative imports diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 00000000..9782ccf2 --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1,2 @@ +# This file makes the tests/utils directory a Python package +# to support relative imports diff --git a/tests/utils/test_tracksdata_utils.py b/tests/utils/test_tracksdata_utils.py index 02fe1be9..5d6d0a06 100644 --- a/tests/utils/test_tracksdata_utils.py +++ b/tests/utils/test_tracksdata_utils.py @@ -3,16 +3,16 @@ import numpy as np import pytest -# Import from conftest (pytest makes it available automatically) -from conftest import ( +from funtracks.utils.tracksdata_utils import 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, ) -from funtracks.utils.tracksdata_utils import pixels_to_td_mask, td_mask_to_pixels - @pytest.mark.parametrize( "mask_func,ndim", From f55268ccae1632a14e543cb74eb7bd2c05b4f263 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Thu, 8 Jan 2026 15:22:13 -0800 Subject: [PATCH 24/44] minor textual changes --- tests/actions/test_add_delete_edge.py | 1 - tests/actions/test_add_delete_nodes.py | 1 - 2 files changed, 2 deletions(-) diff --git a/tests/actions/test_add_delete_edge.py b/tests/actions/test_add_delete_edge.py index a792bd51..06296636 100644 --- a/tests/actions/test_add_delete_edge.py +++ b/tests/actions/test_add_delete_edge.py @@ -45,7 +45,6 @@ def test_add_delete_edges(get_tracks, ndim, with_seg): if with_seg: assert_array_almost_equal(tracks.segmentation, reference_seg) - # TODO Teun: the next line fails: inverse = action.inverse() assert set(tracks.graph.edge_ids()) == set() diff --git a/tests/actions/test_add_delete_nodes.py b/tests/actions/test_add_delete_nodes.py index 65f03bc6..9a462e07 100644 --- a/tests/actions/test_add_delete_nodes.py +++ b/tests/actions/test_add_delete_nodes.py @@ -191,7 +191,6 @@ def test_custom_attributes_preserved(get_tracks, ndim, with_seg): mask_obj = make_3d_sphere_mask(center=(50, 50, 50), radius=5) pixels = td_mask_to_pixels(mask_obj, time=custom_attrs["t"], ndim=ndim) custom_attrs["mask"] = mask_obj - # TODO Teun: are these lines necessary? Because we provide pixels to AddNode custom_attrs["bbox"] = mask_obj.bbox custom_attrs.pop("pos") # pos will be computed from segmentation else: From e020d747cbe1b64bbc4836575cd158288da5d75e Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Fri, 16 Jan 2026 09:46:09 -0800 Subject: [PATCH 25/44] latest tracksdata --- docs/features.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/features.md b/docs/features.md index 24ed1e53..b15b3814 100644 --- a/docs/features.md +++ b/docs/features.md @@ -276,7 +276,7 @@ tracks.disable_features(["area"]) if "custom" in self.features: for node in self.tracks.graph.node_ids(): value = self._compute_custom(node) - self.tracks.graph[node]["custom"] = value + self.tracks[node]["custom"] = value def update(self, action): # Incremental update when graph changes diff --git a/pyproject.toml b/pyproject.toml index fbc3b8bf..a123b79a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies =[ "pandas>=2.3.3", "zarr>=2.18,<4", "numcodecs>=0.13,<0.16", - "tracksdata[spatial]@git+https://github.com/royerlab/tracksdata@24d93ef8d3c4c44cedc346dc3229c98c1d3eaa5e", + "tracksdata[spatial]@git+https://github.com/royerlab/tracksdata@d7a060d8c64ed186c98d2fc17a6f7a6c1bc5cf0b", ] [project.urls] From fb84122ac04fabd71467a25ac8bc5cce04749e5d Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Tue, 20 Jan 2026 13:18:27 -0800 Subject: [PATCH 26/44] answered Carolines comments, use Mask methods, iou from masks --- src/funtracks/actions/add_delete_node.py | 14 +- src/funtracks/annotators/_compute_ious.py | 36 +--- src/funtracks/annotators/_edge_annotator.py | 45 ++--- .../annotators/_regionprops_annotator.py | 9 +- src/funtracks/annotators/_track_annotator.py | 3 +- src/funtracks/data_model/tracks.py | 21 +- .../import_export/_import_segmentation.py | 1 - src/funtracks/import_export/_utils.py | 18 +- src/funtracks/import_export/csv/_import.py | 1 - .../user_actions/user_update_segmentation.py | 2 +- src/funtracks/utils/tracksdata_utils.py | 180 ------------------ tests/annotators/test_edge_annotator.py | 4 +- .../annotators/test_regionprops_annotator.py | 3 - tests/data_model/test_tracks.py | 4 - 14 files changed, 55 insertions(+), 286 deletions(-) diff --git a/src/funtracks/actions/add_delete_node.py b/src/funtracks/actions/add_delete_node.py index c522dbf9..41b2722b 100644 --- a/src/funtracks/actions/add_delete_node.py +++ b/src/funtracks/actions/add_delete_node.py @@ -186,13 +186,13 @@ def _apply(self) -> None: if self.pixels is not None: self.tracks.set_pixels(self.pixels, self.node) - if type(self.tracks).__name__ == "SolutionTracks": - tracklet_key = self.tracks.features.tracklet_key - if tracklet_key is not None and tracklet_key in attrs: - track_id = attrs[tracklet_key] - if track_id not in self.tracks.track_id_to_node: - self.tracks.track_id_to_node[track_id] = [] - self.tracks.track_id_to_node[track_id].append(self.node) + # if type(self.tracks).__name__ == "SolutionTracks": + # tracklet_key = self.tracks.features.tracklet_key + # if tracklet_key is not None and tracklet_key in attrs: + # track_id = attrs[tracklet_key] + # if track_id not in self.tracks.track_id_to_node: + # self.tracks.track_id_to_node[track_id] = [] + # self.tracks.track_id_to_node[track_id].append(self.node) # Always notify annotators - they will check their own preconditions self.tracks.notify_annotators(self) diff --git a/src/funtracks/annotators/_compute_ious.py b/src/funtracks/annotators/_compute_ious.py index c6522561..0ffb10f0 100644 --- a/src/funtracks/annotators/_compute_ious.py +++ b/src/funtracks/annotators/_compute_ious.py @@ -1,35 +1,15 @@ -import numpy as np +from tracksdata.nodes._mask import Mask -def _compute_ious(frame1: np.ndarray, frame2: np.ndarray) -> list[tuple[int, int, float]]: - """Compute label IOUs between two label arrays of the same shape. Ignores background - (label 0). +def _compute_iou(mask1: Mask, mask2: Mask) -> list[tuple[int, int, float]]: + """Compute label IOUs between two Mask objects. Args: - frame1 (np.ndarray): Array with integer labels - frame2 (np.ndarray): Array with integer labels + mask1 (Mask): First mask object + mask2 (Mask): Second mask object Returns: - list[tuple[int, int, float]]: List of tuples of label in frame 1, label in - frame 2, and iou values. Labels that have no overlap are not included. + iou (int): IOU value between the two masks """ - frame1 = frame1.flatten() - frame2 = frame2.flatten() - # get indices where both are not zero (ignore background) - # this speeds up computation significantly - non_zero_indices = np.logical_and(frame1, frame2) - flattened_stacked = np.array([frame1[non_zero_indices], frame2[non_zero_indices]]) - - values, counts = np.unique(flattened_stacked, axis=1, return_counts=True) - frame1_values, frame1_counts = np.unique(frame1, return_counts=True) - frame1_label_sizes = dict(zip(frame1_values, frame1_counts, strict=True)) - frame2_values, frame2_counts = np.unique(frame2, return_counts=True) - frame2_label_sizes = dict(zip(frame2_values, frame2_counts, strict=True)) - ious: list[tuple[int, int, float]] = [] - for index in range(values.shape[1]): - pair = values[:, index] - intersection = counts[index] - id1, id2 = pair - union = frame1_label_sizes[id1] + frame2_label_sizes[id2] - intersection - ious.append((int(id1), int(id2), intersection / union)) - return ious + iou = mask1.iou(mask2) + return iou diff --git a/src/funtracks/annotators/_edge_annotator.py b/src/funtracks/annotators/_edge_annotator.py index 5cfd3c5b..b9b65f37 100644 --- a/src/funtracks/annotators/_edge_annotator.py +++ b/src/funtracks/annotators/_edge_annotator.py @@ -4,13 +4,11 @@ from collections import defaultdict from typing import TYPE_CHECKING -import numpy as np - from funtracks.actions.add_delete_edge import AddEdge from funtracks.actions.update_segmentation import UpdateNodeSeg from funtracks.features import Feature, IoU -from ._compute_ious import _compute_ious +from ._compute_ious import _compute_iou from ._graph_annotator import GraphAnnotator if TYPE_CHECKING: @@ -95,34 +93,24 @@ def compute(self, feature_keys: list[str] | None = None) -> None: for node in nodes_in_t: for succ in self.tracks.graph.successors(node): edges.append((node, succ)) - self._iou_update(edges, np.asarray(seg[t]), np.asarray(seg[t + 1])) + self._iou_update(edges) def _iou_update( self, edges: list[tuple[int, int]], - seg_frame: np.ndarray, - seg_next_frame: np.ndarray, ) -> None: """Perform the IoU computation and update all feature values for a - single pair of frames of segmentation data. + list of edges. Args: edges (list[tuple[int, int]]): A list of edges between two frames - seg_frame (np.ndarray): A 2D or 3D numpy array representing the seg for the - starting time of the edges - seg_next_frame (np.ndarray): A 2D or 3D numpy array representing the seg for - the ending time of the edges """ - ious = _compute_ious(seg_frame, seg_next_frame) # list of (id1, id2, iou) - for id1, id2, iou in ious: - edge = (id1, id2) - if edge in edges: - self.tracks._set_edge_attr(edge, self.iou_key, iou) - edges.remove(edge) - - # anything left has IOU of 0 for edge in edges: - self.tracks._set_edge_attr(edge, self.iou_key, 0.0) + source, target = edge + mask1 = self.tracks.graph[source]["mask"] + mask2 = self.tracks.graph[target]["mask"] + iou = _compute_iou(mask1, mask2) + self.tracks._set_edge_attr(edge, self.iou_key, iou) def update(self, action: BasicAction): """Update the edge features based on the action. @@ -162,22 +150,17 @@ def update(self, action: BasicAction): # Update IoU for each edge for edge in edges_to_update: source, target = edge - start_time = self.tracks.get_time(source) - end_time = self.tracks.get_time(target) - start_seg = np.asarray(self.tracks.segmentation[start_time]) - end_seg = np.asarray(self.tracks.segmentation[end_time]) - masked_start = np.where(start_seg == source, source, 0) - masked_end = np.where(end_seg == target, target, 0) - if np.max(masked_start) == 0 or np.max(masked_end) == 0: + mask1 = self.tracks.graph[source]["mask"] + mask2 = self.tracks.graph[target]["mask"] + if mask1.mask.sum() == 0 or mask2.mask.sum() == 0: warnings.warn( - f"Cannot find label {source} in frame {start_time} or label {target} " - f"in frame {end_time}: updating edge IOU value to 0", + f"Cannot find label {source} in segmentation" + f": updating edge IOU value to 0", stacklevel=2, ) self.tracks._set_edge_attr(edge, self.iou_key, 0.0) else: - iou_list = _compute_ious(masked_start, masked_end) - iou = 0.0 if len(iou_list) == 0 else iou_list[0][2] + iou = _compute_iou(mask1, mask2) self.tracks._set_edge_attr(edge, self.iou_key, iou) def change_key(self, old_key: str, new_key: str) -> None: diff --git a/src/funtracks/annotators/_regionprops_annotator.py b/src/funtracks/annotators/_regionprops_annotator.py index ed4db68c..e4721829 100644 --- a/src/funtracks/annotators/_regionprops_annotator.py +++ b/src/funtracks/annotators/_regionprops_annotator.py @@ -178,6 +178,8 @@ def _regionprops_update(self, seg_frame: np.ndarray, feature_keys: list[str]) -> of segmentation data. feature_keys: List of feature keys to compute (already filtered to enabled). """ + # TODO: can we do all regionprops on the masks? + # So we don't have to materialize the entire frame/volume spacing = None if self.tracks.scale is None else tuple(self.tracks.scale[1:]) for region in regionprops_extended(seg_frame, spacing=spacing): node = region.label @@ -218,10 +220,8 @@ def update(self, action: BasicAction): return time = self.tracks.get_time(node) - seg_frame = np.asarray(self.tracks.segmentation[time]) - masked_frame = np.where(seg_frame == node, node, 0) - if np.max(masked_frame) == 0: + if self.tracks.graph[node]["mask"].mask.sum() == 0: warnings.warn( f"Cannot find label {node} in frame {time}: " "updating regionprops values to None", @@ -229,8 +229,11 @@ def update(self, action: BasicAction): ) for key in keys_to_compute: value = None + # TODO Teun: this somehow goes wrong when pos is an array self.tracks._set_node_attr(node, key, value) else: + seg_frame = np.asarray(self.tracks.segmentation[time]) + masked_frame = np.where(seg_frame == node, node, 0) self._regionprops_update(masked_frame, keys_to_compute) def change_key(self, old_key: str, new_key: str) -> None: diff --git a/src/funtracks/annotators/_track_annotator.py b/src/funtracks/annotators/_track_annotator.py index c89c273f..9af4dd31 100644 --- a/src/funtracks/annotators/_track_annotator.py +++ b/src/funtracks/annotators/_track_annotator.py @@ -134,6 +134,8 @@ def _get_max_id_and_map(self, key: str) -> tuple[int, dict[int, list[int]]]: id_to_nodes = defaultdict(list) max_id = 0 for node in self.tracks.graph.node_ids(): + if key not in self.tracks.graph.node_attr_keys(): + continue _id: int = self.tracks.get_node_attr(node, key) if _id is None: continue @@ -230,7 +232,6 @@ def _assign_tracklet_ids(self) -> None: daughters = [edge[1] for edge in all_edges if edge[0] == parent] for daughter in daughters: - # remove edge from graph, by setting solution to 0 + subgraphing graph_copy.remove_edge(parent, daughter) track_id = 1 diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index 1ded4f72..50d96db9 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -17,9 +17,7 @@ from funtracks.features import Feature, FeatureDict, Position, Time from funtracks.utils.tracksdata_utils import ( - combine_td_masks, pixels_to_td_mask, - subtract_td_masks, td_get_single_attr_from_edge, ) @@ -281,7 +279,7 @@ def _check_existing_feature(self, key: str) -> bool: if self.graph.num_nodes() == 0: return True - # Get a sample node to check which attributes exist + # Check which attributes exist node_attrs = set(self.graph.node_attr_keys()) return key in node_attrs @@ -495,14 +493,12 @@ def set_pixels( if value == 0: # val=0 means deleting the pixels from the mask mask_old = self.graph[node_id][td.DEFAULT_ATTR_KEYS.MASK] - mask_subtracted, area_subtracted = subtract_td_masks( - mask_old, mask_new, self.scale - ) + mask_subtracted = mask_old.__isub__(mask_new) self.graph.update_node_attrs( attrs={ td.DEFAULT_ATTR_KEYS.MASK: [mask_subtracted], td.DEFAULT_ATTR_KEYS.BBOX: [mask_subtracted.bbox], - "area": [area_subtracted], + # "area": [area_subtracted], }, node_ids=[node_id], ) @@ -510,14 +506,11 @@ def set_pixels( elif self.graph.has_node(value): # if node already exists: mask_old = self.graph[value][td.DEFAULT_ATTR_KEYS.MASK] - mask_combined, area_combined = combine_td_masks( - mask_old, mask_new, self.scale - ) + mask_combined = mask_old.__or__(mask_new) self.graph.update_node_attrs( attrs={ td.DEFAULT_ATTR_KEYS.MASK: [mask_combined], td.DEFAULT_ATTR_KEYS.BBOX: [mask_combined.bbox], - "area": [area_combined], }, node_ids=[value], ) @@ -628,17 +621,13 @@ def _compute_ndim( def _set_node_attr(self, node: Node, attr: str, value: Any): if isinstance(value, np.ndarray): value = list(value) - self.graph.update_node_attrs(attrs={attr: [value]}, node_ids=[node]) + self.graph[node][attr] = [value] def _set_nodes_attr(self, nodes: Iterable[Node], attr: str, values: Iterable[Any]): for node, value in zip(nodes, values, strict=False): self.graph[node][attr] = [value] def get_node_attr(self, node: Node, attr: str, required: bool = False): - if attr not in self.graph.node_attr_keys(): - if required: - raise KeyError(attr) - return None return self.graph[int(node)][attr] def get_nodes_attr(self, nodes: Iterable[Node], attr: str, required: bool = False): diff --git a/src/funtracks/import_export/_import_segmentation.py b/src/funtracks/import_export/_import_segmentation.py index 5b6b6b72..f750a741 100644 --- a/src/funtracks/import_export/_import_segmentation.py +++ b/src/funtracks/import_export/_import_segmentation.py @@ -81,7 +81,6 @@ def relabel_segmentation( if offset: mapping = {old_id: old_id + offset for old_id in graph.node_ids()} # nx.relabel_nodes modified graph in-place, but td_relabel_nodes returns new graph - # Note: This modifies the graph reference but caller must handle reassignment graph = td_relabel_nodes(graph, mapping) # Update node_ids array to match node_ids = node_ids + offset diff --git a/src/funtracks/import_export/_utils.py b/src/funtracks/import_export/_utils.py index f022fc19..334cf42d 100644 --- a/src/funtracks/import_export/_utils.py +++ b/src/funtracks/import_export/_utils.py @@ -86,14 +86,18 @@ def filter_graph_with_ancestors( all_nodes_to_keep = set(nodes_to_keep) import rustworkx as rx - for node in nodes_to_keep: - # Map external node ID to internal RustWorkX index - internal_node = graph._external_to_local[node] - # Get ancestors using internal indices + # Map external node ID to internal RustWorkX index + nodes_to_keep_internal = graph._vectorized_map_to_local(list(nodes_to_keep)) + + # Collect all internal ancestor IDs + all_ancestors_internal = set() + for internal_node in nodes_to_keep_internal: ancestors = rx.ancestors(graph.rx_graph, internal_node) - # Convert ancestor indices back to external node IDs - ancestors_external = [graph._local_to_external[nid] for nid in ancestors] - all_nodes_to_keep.update(ancestors_external) + all_ancestors_internal.update(ancestors) + + # Convert ancestor indices back to external node IDs + ancestors_external = graph._vectorized_map_to_external(list(all_ancestors_internal)) + all_nodes_to_keep.update(int(a) for a in ancestors_external) return list(all_nodes_to_keep) diff --git a/src/funtracks/import_export/csv/_import.py b/src/funtracks/import_export/csv/_import.py index 09fc6ce1..f4025bf2 100644 --- a/src/funtracks/import_export/csv/_import.py +++ b/src/funtracks/import_export/csv/_import.py @@ -268,7 +268,6 @@ def tracks_from_df( # Auto-infer name mapping from DataFrame columns builder.prepare(df) - # TODO Teun: allow the masks and bboxes to be on the dataframe, # instead of a separate segmentation array return builder.build( diff --git a/src/funtracks/user_actions/user_update_segmentation.py b/src/funtracks/user_actions/user_update_segmentation.py index c81aa6cc..d1ba7081 100644 --- a/src/funtracks/user_actions/user_update_segmentation.py +++ b/src/funtracks/user_actions/user_update_segmentation.py @@ -50,6 +50,7 @@ def __init__( continue time = pixels[0][0] # check if all pixels of old_value are removed + # TODO Teun: do this from the mask seg_time = np.asarray(self.tracks.segmentation[time]) if np.unique(seg_time[pixels[1:]]) == old_value and np.sum( seg_time == old_value @@ -70,7 +71,6 @@ def __init__( self.actions.append( UpdateNodeSeg(tracks, new_value, all_pixels, added=True) ) - tracks.graph[new_value][tracks.features.tracklet_key] = current_track_id else: time_key = tracks.features.time_key tracklet_key = tracks.features.tracklet_key diff --git a/src/funtracks/utils/tracksdata_utils.py b/src/funtracks/utils/tracksdata_utils.py index 679b4d45..c642401b 100644 --- a/src/funtracks/utils/tracksdata_utils.py +++ b/src/funtracks/utils/tracksdata_utils.py @@ -321,186 +321,6 @@ def td_mask_to_pixels(mask: Mask, time: int, ndim: int) -> tuple[np.ndarray, ... return (time_array, *global_coords) -def combine_td_masks( - mask1: Mask, mask2: Mask, scale: list[float] | None -) -> tuple[Mask, float]: - """ - Combine two tracksdata mask objects into a single mask object. - The resulting mask will encompass both input masks. - - Args: - mask1: First Mask object with .mask and .bbox attributes - mask2: Second Mask object with .mask and .bbox attributes - scale: Scale factors for each dimension, used for area calculation - - Returns: - Mask: A new Mask object containing the union of both masks - """ - # Get spatial dimensions from first bbox - spatial_dims = len(mask1.bbox) // 2 - - # Calculate the combined bounding box - combined_bbox = np.zeros(2 * spatial_dims, dtype=int) - - # Find the minimum and maximum coordinates for the new bbox - for dim in range(spatial_dims): - combined_bbox[dim] = min(mask1.bbox[dim], mask2.bbox[dim]) - combined_bbox[dim + spatial_dims] = max( - mask1.bbox[dim + spatial_dims], mask2.bbox[dim + spatial_dims] - ) - - # Calculate the shape of the combined mask - combined_shape = combined_bbox[spatial_dims:] - combined_bbox[:spatial_dims] - combined_mask = np.zeros(combined_shape, dtype=bool) - - # Create slicing for first mask - slices1 = tuple( - slice(offset1_start, offset1_end) - for offset1_start, offset1_end in zip( - [mask1.bbox[d] - combined_bbox[d] for d in range(spatial_dims)], - [ - mask1.bbox[d] - combined_bbox[d] + mask1.mask.shape[d] - for d in range(spatial_dims) - ], - strict=True, - ) - ) - - # Place second mask in the combined mask - slices2 = tuple( - slice(offset2_start, offset2_end) - for offset2_start, offset2_end in zip( - [mask2.bbox[d] - combined_bbox[d] for d in range(spatial_dims)], - [ - mask2.bbox[d] - combined_bbox[d] + mask2.mask.shape[d] - for d in range(spatial_dims) - ], - strict=True, - ) - ) - - # Combine the masks using logical OR - combined_mask[slices1] |= mask1.mask - combined_mask[slices2] |= mask2.mask - - area = np.sum(combined_mask) - if scale is not None: - area *= np.prod(scale[1:]) - - return Mask(combined_mask, bbox=combined_bbox), float(area) - - -def subtract_td_masks( - mask_old: Mask, mask_new: Mask, scale: list[float] | None -) -> tuple[Mask, float]: - """ - Subtract mask_new from mask_old, creating a new mask with the difference. - Will throw an error if mask_new contains True pixels that are not True in mask_old. - - Args: - mask_old: Original Mask object that pixels will be removed from - mask_new: Mask object containing pixels to remove - scale: Scale factors for each dimension, used for area calculation - - Returns: - Tuple[Mask, float]: A new Mask object containing the result of - mask_old - mask_new, and the new area after subtraction - """ - # Get spatial dimensions from first bbox - spatial_dims = len(mask_old.bbox) // 2 - - # First verify that all True pixels in mask_new are also True in mask_old - # We do this by placing both masks in a common coordinate system - - # Calculate the combined bounding box - combined_bbox = np.zeros(2 * spatial_dims, dtype=int) - for dim in range(spatial_dims): - combined_bbox[dim] = min(mask_old.bbox[dim], mask_new.bbox[dim]) - combined_bbox[dim + spatial_dims] = max( - mask_old.bbox[dim + spatial_dims], mask_new.bbox[dim + spatial_dims] - ) - - # Place both masks in the combined coordinate system - combined_shape = combined_bbox[spatial_dims:] - combined_bbox[:spatial_dims] - old_mask_full = np.zeros(combined_shape, dtype=bool) - new_mask_full = np.zeros(combined_shape, dtype=bool) - - # Create slicing for old mask - slices_old = tuple( - slice(offset_start, offset_end) - for offset_start, offset_end in zip( - [mask_old.bbox[d] - combined_bbox[d] for d in range(spatial_dims)], - [ - mask_old.bbox[d] - combined_bbox[d] + mask_old.mask.shape[d] - for d in range(spatial_dims) - ], - strict=True, - ) - ) - - # Create slicing for new mask - slices_new = tuple( - slice(offset_start, offset_end) - for offset_start, offset_end in zip( - [mask_new.bbox[d] - combined_bbox[d] for d in range(spatial_dims)], - [ - mask_new.bbox[d] - combined_bbox[d] + mask_new.mask.shape[d] - for d in range(spatial_dims) - ], - strict=True, - ) - ) - - old_mask_full[slices_old] = mask_old.mask - new_mask_full[slices_new] = mask_new.mask - - # Check if all True pixels in mask_new are also True in mask_old - if not np.all(new_mask_full <= old_mask_full): - raise ValueError("mask_new contains True pixels that are not True in mask_old") - - # Perform the subtraction - result_mask = old_mask_full & ~new_mask_full - - # Find the new bounding box based on remaining True pixels - if not np.any(result_mask): - # If no pixels remain, return minimal empty mask - # result_bbox = np.zeros(2 * spatial_dims, dtype=int) - result_bbox = np.array([0] * spatial_dims + [1] * spatial_dims) - return Mask(np.zeros((1,) * spatial_dims, dtype=bool), bbox=result_bbox), 0.0 - - true_indices = np.nonzero(result_mask) - result_bbox = np.zeros(2 * spatial_dims, dtype=int) - - for dim in range(spatial_dims): - result_bbox[dim] = np.min(true_indices[dim]) + combined_bbox[dim] - result_bbox[dim + spatial_dims] = ( - np.max(true_indices[dim]) + combined_bbox[dim] + 1 - ) - - # Extract the final mask within the new bbox - final_shape = result_bbox[spatial_dims:] - result_bbox[:spatial_dims] - final_mask = np.zeros(final_shape, dtype=bool) - - # Create slicing from result_mask to final_mask space - slices_final = tuple( - slice( - result_bbox[dim] - combined_bbox[dim], - result_bbox[dim] - combined_bbox[dim] + final_shape[dim], - ) - for dim in range(spatial_dims) - ) - - # Copy the relevant portion of the result_mask to final_mask - final_mask[:] = result_mask[slices_final] - - # Calculate area - area = np.sum(final_mask) - if scale is not None: - area *= np.prod(scale[1:]) - - return Mask(final_mask, bbox=result_bbox), float(area) - - def segmentation_to_masks( segmentation: np.ndarray, ) -> list[tuple[int, int, Mask]]: diff --git a/tests/annotators/test_edge_annotator.py b/tests/annotators/test_edge_annotator.py index 06dffbcb..c6a1808c 100644 --- a/tests/annotators/test_edge_annotator.py +++ b/tests/annotators/test_edge_annotator.py @@ -75,9 +75,7 @@ def test_update_all(self, get_graph, ndim) -> None: node_id = 1 pixels = tracks.get_pixels(node_id) assert pixels is not None - with pytest.warns( - match="Cannot find label 1 in frame .*: updating edge IOU value to 0" - ): + with pytest.warns(match="Cannot find label 1 in frame .*"): UpdateNodeSeg(tracks, node_id, pixels, added=False) assert td_get_single_attr_from_edge(tracks.graph, edge_id, "iou") == 0 diff --git a/tests/annotators/test_regionprops_annotator.py b/tests/annotators/test_regionprops_annotator.py index fa52c775..a0587eb1 100644 --- a/tests/annotators/test_regionprops_annotator.py +++ b/tests/annotators/test_regionprops_annotator.py @@ -116,14 +116,11 @@ def test_add_remove_feature(self, get_graph, ndim): # remove all but one pixel node_id = 3 - prev_value = tracks.get_node_attr(node_id, second_remove_key) orig_pixels = tracks.get_pixels(node_id) assert orig_pixels is not None pixels_to_remove = tuple(orig_pixels[d][1:] for d in range(len(orig_pixels))) # Use UpdateNodeSeg action to modify segmentation and update features UpdateNodeSeg(tracks, node_id, pixels_to_remove, added=False) - # the new one we removed is not updated - assert tracks.get_node_attr(node_id, second_remove_key) == prev_value # the one we added back in is now present assert tracks.get_node_attr(node_id, to_remove_key) is not None diff --git a/tests/data_model/test_tracks.py b/tests/data_model/test_tracks.py index 441d9e16..ccfc2d44 100644 --- a/tests/data_model/test_tracks.py +++ b/tests/data_model/test_tracks.py @@ -143,12 +143,8 @@ def test_get_set_node_attr(graph_2d_with_segmentation): assert tracks.get_nodes_attr([1, 2], "track_id", required=False) == [7, 8] with pytest.raises(KeyError): tracks.get_node_attr(1, "not_present", required=True) - assert tracks.get_node_attr(1, "not_present", required=False) is None with pytest.raises(KeyError): tracks.get_nodes_attr([1, 2], "not_present", required=True) - assert all( - x is None for x in tracks.get_nodes_attr([1, 2], "not_present", required=False) - ) # test array attributes tracks._set_node_attr(1, "pos", np.array([1, 2])) From ba6bc1075af8b34de760280ff2ee71539615acde Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Tue, 20 Jan 2026 13:39:44 -0800 Subject: [PATCH 27/44] UserUpdateSegmentation uses masks, not segmentation --- .../user_actions/user_update_segmentation.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/funtracks/user_actions/user_update_segmentation.py b/src/funtracks/user_actions/user_update_segmentation.py index d1ba7081..c17e5a8f 100644 --- a/src/funtracks/user_actions/user_update_segmentation.py +++ b/src/funtracks/user_actions/user_update_segmentation.py @@ -12,6 +12,8 @@ if TYPE_CHECKING: from funtracks.data_model import SolutionTracks +from funtracks.utils.tracksdata_utils import pixels_to_td_mask + class UserUpdateSegmentation(ActionGroup): def __init__( @@ -50,11 +52,12 @@ def __init__( continue time = pixels[0][0] # check if all pixels of old_value are removed - # TODO Teun: do this from the mask - seg_time = np.asarray(self.tracks.segmentation[time]) - if np.unique(seg_time[pixels[1:]]) == old_value and np.sum( - seg_time == old_value - ) == len(pixels[0]): + mask_pixels, _ = pixels_to_td_mask( + pixels, self.tracks.ndim, self.tracks.scale + ) + mask_old_value = self.tracks.graph[old_value]["mask"] + # If pixels fully overlaps with old_value mask, delete node + if mask_pixels.intersection(mask_old_value) == mask_old_value.mask.sum(): self.actions.append(UserDeleteNode(tracks, old_value, pixels=pixels)) else: self.actions.append(UpdateNodeSeg(tracks, old_value, pixels, added=False)) From 17088b6e04caa1cdceb19e2b339fa5fb5473900c Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Tue, 20 Jan 2026 16:01:45 -0800 Subject: [PATCH 28/44] regionprops computed from masks --- src/funtracks/annotators/_edge_annotator.py | 3 +- .../annotators/_regionprops_annotator.py | 33 ++++++++++--------- .../annotators/_regionprops_extended.py | 29 ++++++++++++---- 3 files changed, 40 insertions(+), 25 deletions(-) diff --git a/src/funtracks/annotators/_edge_annotator.py b/src/funtracks/annotators/_edge_annotator.py index b9b65f37..421644ba 100644 --- a/src/funtracks/annotators/_edge_annotator.py +++ b/src/funtracks/annotators/_edge_annotator.py @@ -80,14 +80,13 @@ def compute(self, feature_keys: list[str] | None = None) -> None: if not keys_to_compute: return - seg = self.tracks.segmentation # TODO: add skip edges if self.iou_key in keys_to_compute: nodes_by_frame = defaultdict(list) for n in self.tracks.graph.node_ids(): nodes_by_frame[self.tracks.get_time(n)].append(n) - for t in range(seg.shape[0] - 1): + for t in range(self.tracks.segmentation_shape[0] - 1): nodes_in_t = nodes_by_frame[t] edges = [] for node in nodes_in_t: diff --git a/src/funtracks/annotators/_regionprops_annotator.py b/src/funtracks/annotators/_regionprops_annotator.py index e4721829..c7fe66d3 100644 --- a/src/funtracks/annotators/_regionprops_annotator.py +++ b/src/funtracks/annotators/_regionprops_annotator.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, NamedTuple import numpy as np +from tracksdata.nodes._mask import Mask from funtracks.actions.add_delete_node import AddNode from funtracks.actions.update_segmentation import UpdateNodeSeg @@ -165,26 +166,27 @@ def compute(self, feature_keys: list[str] | None = None) -> None: if not keys_to_compute: return - seg = self.tracks.segmentation - for t in range(seg.shape[0]): - self._regionprops_update(np.asarray(seg[t]), keys_to_compute) + for node_id in self.tracks.graph.node_ids(): + mask = self.tracks.graph[node_id]["mask"] + self._regionprops_update(node_id, mask, keys_to_compute) - def _regionprops_update(self, seg_frame: np.ndarray, feature_keys: list[str]) -> None: + def _regionprops_update( + self, node_id: int, mask: Mask, feature_keys: list[str] + ) -> None: """Perform the regionprops computation and update all feature values for a - single frame of segmentation data. + single mask. Args: - seg_frame (np.ndarray): A 2D or 3D numpy array representing one time point + node_id (int): The node ID to update features for. + mask (Mask): A Mask object representing one time point of segmentation data. - feature_keys: List of feature keys to compute (already filtered to enabled). + feature_keys (list): List of feature keys to compute + (already filtered to enabled). """ - # TODO: can we do all regionprops on the masks? - # So we don't have to materialize the entire frame/volume spacing = None if self.tracks.scale is None else tuple(self.tracks.scale[1:]) - for region in regionprops_extended(seg_frame, spacing=spacing): - node = region.label + for region in regionprops_extended(mask, spacing=spacing): # Skip labels that aren't nodes in the graph (e.g., unselected detections) - if not self.tracks.graph.has_node(node): + if not self.tracks.graph.has_node(node_id): continue for key in feature_keys: value = getattr(region, self.regionprops_names[key]) @@ -194,7 +196,7 @@ def _regionprops_update(self, seg_frame: np.ndarray, feature_keys: list[str]) -> ] # cannot be a list of np.arrays with single values elif isinstance(value, np.floating): value = float(value) - self.tracks._set_node_attr(node, key, value) + self.tracks._set_node_attr(node_id, key, value) def update(self, action: BasicAction): """Update the regionprops features based on the action. @@ -232,9 +234,8 @@ def update(self, action: BasicAction): # TODO Teun: this somehow goes wrong when pos is an array self.tracks._set_node_attr(node, key, value) else: - seg_frame = np.asarray(self.tracks.segmentation[time]) - masked_frame = np.where(seg_frame == node, node, 0) - self._regionprops_update(masked_frame, keys_to_compute) + mask = self.tracks.graph[node]["mask"] + self._regionprops_update(node, mask, keys_to_compute) def change_key(self, old_key: str, new_key: str) -> None: """Rename a feature key in this annotator, and related mappings. diff --git a/src/funtracks/annotators/_regionprops_extended.py b/src/funtracks/annotators/_regionprops_extended.py index 3f4fa473..1685e4f3 100644 --- a/src/funtracks/annotators/_regionprops_extended.py +++ b/src/funtracks/annotators/_regionprops_extended.py @@ -3,6 +3,7 @@ import numpy as np from skimage.measure import marching_cubes, mesh_surface_area, regionprops from skimage.measure._regionprops import RegionProperties +from tracksdata.nodes._mask import Mask class ExtendedRegionProperties(RegionProperties): @@ -138,8 +139,15 @@ def perimeter(self): if self._label_image.ndim == 2: return super().perimeter else: # 3D + # Create binary mask and pad with background to ensure a surface boundary + # exists. This prevents marching_cubes from failing when the mask fills + # the entire volume + binary_mask = self._label_image == self.label + padded_mask = np.pad( + binary_mask, pad_width=1, mode="constant", constant_values=False + ) verts, faces, _, _ = marching_cubes( - self._label_image == self.label, level=0.5, spacing=self._spacing + padded_mask, level=0.5, spacing=self._spacing ) return mesh_surface_area(verts, faces) @@ -170,24 +178,30 @@ def voxel_count(self): def regionprops_extended( - img: np.ndarray, + mask: Mask, spacing: tuple[float, ...] | None, - intensity_image: np.ndarray | None = None, ) -> list[ExtendedRegionProperties]: """ Create instances of ExtendedRegionProperties that extend skimage.measure.RegionProperties. Args: - img (np.ndarray): The labeled image. + mask (Mask): The labeled mask. spacing (tuple[float, ...]| None): The spacing between voxels in each dimension. If None, each voxel is assumed to be 1 in all dimensions. - intensity_image (np.ndarray, optional): The intensity image. - + q Returns: list[ExtendedRegionProperties]: A list of ExtendedRegionProperties instances. """ - results = regionprops(img, intensity_image=intensity_image, spacing=spacing) + + results = regionprops( + mask._mask.astype(np.uint16), + cache=True, + spacing=spacing, + offset=tuple(mask._bbox[: mask._mask.ndim]), + ) + + # results = regionprops(img, intensity_image=intensity_image, spacing=spacing) for i, _ in enumerate(results): a = results[i] b = ExtendedRegionProperties( @@ -197,6 +211,7 @@ def regionprops_extended( intensity_image=a._intensity_image, cache_active=a._cache_active, spacing=a._spacing, + offset=a._offset, ) results[i] = b From 33635ecc9b6b5640f94b4cb8f0afaaa2ea1d8a38 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Tue, 20 Jan 2026 16:10:36 -0800 Subject: [PATCH 29/44] use regionprops on mask from td --- pyproject.toml | 2 +- .../annotators/_regionprops_extended.py | 35 +++++++------------ 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a123b79a..623f5936 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies =[ "pandas>=2.3.3", "zarr>=2.18,<4", "numcodecs>=0.13,<0.16", - "tracksdata[spatial]@git+https://github.com/royerlab/tracksdata@d7a060d8c64ed186c98d2fc17a6f7a6c1bc5cf0b", + "tracksdata[spatial]@git+https://github.com/royerlab/tracksdata@ef5ad56594eba9723349f2a357029b87267bc330", ] [project.urls] diff --git a/src/funtracks/annotators/_regionprops_extended.py b/src/funtracks/annotators/_regionprops_extended.py index 1685e4f3..9a1a780d 100644 --- a/src/funtracks/annotators/_regionprops_extended.py +++ b/src/funtracks/annotators/_regionprops_extended.py @@ -1,7 +1,7 @@ import math import numpy as np -from skimage.measure import marching_cubes, mesh_surface_area, regionprops +from skimage.measure import marching_cubes, mesh_surface_area from skimage.measure._regionprops import RegionProperties from tracksdata.nodes._mask import Mask @@ -189,30 +189,21 @@ def regionprops_extended( mask (Mask): The labeled mask. spacing (tuple[float, ...]| None): The spacing between voxels in each dimension. If None, each voxel is assumed to be 1 in all dimensions. - q + Returns: list[ExtendedRegionProperties]: A list of ExtendedRegionProperties instances. """ - results = regionprops( - mask._mask.astype(np.uint16), - cache=True, - spacing=spacing, - offset=tuple(mask._bbox[: mask._mask.ndim]), + region = mask.regionprops(spacing=spacing) + + extended_region = ExtendedRegionProperties( + slice=region.slice, + label=region.label, + label_image=region._label_image, + intensity_image=region._intensity_image, + cache_active=region._cache_active, + spacing=region._spacing, + offset=region._offset, ) - # results = regionprops(img, intensity_image=intensity_image, spacing=spacing) - for i, _ in enumerate(results): - a = results[i] - b = ExtendedRegionProperties( - slice=a.slice, - label=a.label, - label_image=a._label_image, - intensity_image=a._intensity_image, - cache_active=a._cache_active, - spacing=a._spacing, - offset=a._offset, - ) - results[i] = b - - return results + return [extended_region] From 518df4ec4523e99024bb29b1aba15589b655a9f7 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Tue, 20 Jan 2026 18:30:55 -0800 Subject: [PATCH 30/44] add segmentation_shape to graph.metadata, and remove all its instances --- src/funtracks/actions/add_delete_node.py | 27 ++++++++++++------ src/funtracks/annotators/_edge_annotator.py | 2 +- src/funtracks/data_model/solution_tracks.py | 10 +++---- src/funtracks/data_model/tracks.py | 28 +++++++++++-------- .../import_export/_tracks_builder.py | 4 +-- src/funtracks/import_export/_v1_format.py | 9 ++---- tests/actions/test_add_delete_nodes.py | 2 +- tests/annotators/test_annotator_registry.py | 5 ---- tests/annotators/test_edge_annotator.py | 5 ---- tests/annotators/test_graph_annotator.py | 4 +-- .../annotators/test_regionprops_annotator.py | 5 ---- tests/conftest.py | 9 +++--- tests/data_model/test_solution_tracks.py | 8 +++--- tests/data_model/test_tracks.py | 27 ++++++++---------- tests/import_export/test_export_to_geff.py | 4 --- 15 files changed, 67 insertions(+), 82 deletions(-) diff --git a/src/funtracks/actions/add_delete_node.py b/src/funtracks/actions/add_delete_node.py index 41b2722b..72be67dd 100644 --- a/src/funtracks/actions/add_delete_node.py +++ b/src/funtracks/actions/add_delete_node.py @@ -12,6 +12,7 @@ import numpy as np import tracksdata as td +from tracksdata.nodes._mask import Mask from funtracks.utils.tracksdata_utils import ( compute_node_attrs_from_masks, @@ -110,6 +111,22 @@ def _apply(self) -> None: ) # Extract single values from lists (since we passed one mask) computed_attrs = {key: value[0] for key, value in computed_attrs.items()} + else: + # TODO Teun: remove this defaulting behavior, see new tracksdata PR + if len(self.tracks.segmentation.shape) == 3: + attrs[td.DEFAULT_ATTR_KEYS.MASK] = Mask( + np.array([[False]]), bbox=[0, 0, 1, 1] + ) + attrs[td.DEFAULT_ATTR_KEYS.BBOX] = [0, 0, 1, 1] + elif len(self.tracks.segmentation.shape) == 4: + attrs[td.DEFAULT_ATTR_KEYS.MASK] = Mask( + np.array([[[False]]]), bbox=[0, 0, 0, 1, 1, 1] + ) + attrs[td.DEFAULT_ATTR_KEYS.BBOX] = [0, 0, 0, 1, 1, 1] + else: + raise ValueError( + "Must provide pixels or mask when adding node to tracks with seg" + ) # Handle position_key safely using the same pattern as in tracks.py if isinstance(self.tracks.features.position_key, list): # Multi-axis position keys - check if any are missing from attrs @@ -140,7 +157,7 @@ def _apply(self) -> None: else: raise ValueError("Position key is None") # Set area using string literal since FeatureDict doesn't have area_key - attrs["area"] = computed_attrs["area"] + # TODO Teun: remove all computed stuff, because annotators will handle this else: # No segmentation - handle position_key safely if isinstance(self.tracks.features.position_key, list): @@ -186,14 +203,6 @@ def _apply(self) -> None: if self.pixels is not None: self.tracks.set_pixels(self.pixels, self.node) - # if type(self.tracks).__name__ == "SolutionTracks": - # tracklet_key = self.tracks.features.tracklet_key - # if tracklet_key is not None and tracklet_key in attrs: - # track_id = attrs[tracklet_key] - # if track_id not in self.tracks.track_id_to_node: - # self.tracks.track_id_to_node[track_id] = [] - # self.tracks.track_id_to_node[track_id].append(self.node) - # Always notify annotators - they will check their own preconditions self.tracks.notify_annotators(self) diff --git a/src/funtracks/annotators/_edge_annotator.py b/src/funtracks/annotators/_edge_annotator.py index 421644ba..8082a869 100644 --- a/src/funtracks/annotators/_edge_annotator.py +++ b/src/funtracks/annotators/_edge_annotator.py @@ -86,7 +86,7 @@ def compute(self, feature_keys: list[str] | None = None) -> None: for n in self.tracks.graph.node_ids(): nodes_by_frame[self.tracks.get_time(n)].append(n) - for t in range(self.tracks.segmentation_shape[0] - 1): + for t in range(self.tracks.segmentation.shape[0] - 1): nodes_in_t = nodes_by_frame[t] edges = [] for node in nodes_in_t: diff --git a/src/funtracks/data_model/solution_tracks.py b/src/funtracks/data_model/solution_tracks.py index 9377b6d9..aa03bd8d 100644 --- a/src/funtracks/data_model/solution_tracks.py +++ b/src/funtracks/data_model/solution_tracks.py @@ -20,13 +20,13 @@ class SolutionTracks(Tracks): def __init__( self, graph: td.graph.GraphView, - segmentation_shape: tuple[int, ...] | None = None, time_attr: str | None = None, pos_attr: str | tuple[str] | list[str] | None = None, tracklet_attr: str | None = None, scale: list[float] | None = None, ndim: int | None = None, features: FeatureDict | None = None, + _segmentation: td.array.GraphArrayView | None = None, ): """Initialize a SolutionTracks object. @@ -36,8 +36,6 @@ def __init__( Args: graph (td.graph.GraphView): NetworkX directed graph with nodes as detections and edges as links. - segmentation_shape (tuple[int, ...] | None): Shape of the segmentation - volume. If None, segmentation-related features cannot be computed. time_attr (str | None): Graph attribute name for time. Defaults to "time" if None. pos_attr (str | tuple[str, ...] | list[str] | None): Graph attribute @@ -56,16 +54,18 @@ def __init__( Assumes that all features in the dict already exist on the graph (will be activated but not recomputed). If None, core computed features (pos, area, track_id) are auto-detected by checking if they exist on the graph. + _segmentation (GraphArrayView | None): Internal parameter for reusing an + existing GraphArrayView instance. Not intended for public use. """ super().__init__( graph, - segmentation_shape=segmentation_shape, time_attr=time_attr, pos_attr=pos_attr, tracklet_attr=tracklet_attr, scale=scale, ndim=ndim, features=features, + _segmentation=_segmentation, ) self.track_annotator = self._get_track_annotator() @@ -104,10 +104,10 @@ def from_tracks(cls, tracks: Tracks): soln_tracks = cls( tracks.graph, - segmentation_shape=tracks.segmentation_shape, scale=tracks.scale, ndim=tracks.ndim, features=tracks.features, + _segmentation=tracks.segmentation, ) if force_recompute: soln_tracks.enable_features([soln_tracks.features.tracklet_key]) # type: ignore diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index 50d96db9..5bbcb962 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -46,10 +46,6 @@ class Tracks: Attributes: graph (td.graph.GraphView): A graph with nodes representing detections and and edges representing links across time. - segmentation_shape (tuple[int, ...] | None): An optional segmentation shape that - accompanies the tracking graph. If a segmentation_shape is provided, - the node ids in the graph must match the segmentation labels. - Providing None assumes no segmentation on the graph. features (FeatureDict): Dictionary of features tracked on graph nodes/edges. annotators (AnnotatorRegistry): List of annotators that compute features. scale (list[float] | None): How much to scale each dimension by, including time. @@ -61,21 +57,19 @@ class Tracks: def __init__( self, graph: td.graph.GraphView, - segmentation_shape: tuple[int, ...] | None = None, time_attr: str | None = None, pos_attr: str | tuple[str, ...] | list[str] | None = None, tracklet_attr: str | None = None, scale: list[float] | None = None, ndim: int | None = None, features: FeatureDict | None = None, + _segmentation: GraphArrayView | None = None, ): """Initialize a Tracks object. Args: graph (td.graph.GraphView): NetworkX directed graph with nodes as detections and edges as links. - segmentation_shape (tuple[int, ...] | None): Optional segmentation shape where - labels match node IDs. Required for computing region props (area, etc.). time_attr (str | None): Graph attribute name for time. Defaults to "time" if None. pos_attr (str | tuple[str, ...] | list[str] | None): Graph attribute @@ -94,13 +88,21 @@ def __init__( Assumes that all features in the dict already exist on the graph (will be activated but not recomputed). If None, core computed features (pos, area, track_id) are auto-detected by checking if they exist on the graph. + _segmentation (GraphArrayView | None): Internal parameter for reusing an + existing GraphArrayView instance. Not intended for public use. """ self.graph = graph - self.segmentation_shape = segmentation_shape - if segmentation_shape is not None: + if _segmentation is not None: + # Reuse provided segmentation instance (internal use only) + self.segmentation = _segmentation + elif "mask" in graph.node_attr_keys(): + # Create new GraphArrayView from graph metadata try: array_view = GraphArrayView( - graph=graph, shape=segmentation_shape, attr_key="node_id", offset=0 + graph=graph, + shape=graph.metadata()["segmentation_shape"], + attr_key="node_id", + offset=0, ) self.segmentation = array_view except (ValueError, KeyError) as err: @@ -111,7 +113,11 @@ def __init__( else: self.segmentation = None self.scale = scale - self.ndim = self._compute_ndim(self.segmentation_shape, scale, ndim) + self.ndim = self._compute_ndim( + self.segmentation.shape if self.segmentation is not None else None, + scale, + ndim, + ) self.axis_names = ["z", "y", "x"] if self.ndim == 4 else ["y", "x"] # initialization steps: diff --git a/src/funtracks/import_export/_tracks_builder.py b/src/funtracks/import_export/_tracks_builder.py index 74d85938..19dbb2b5 100644 --- a/src/funtracks/import_export/_tracks_builder.py +++ b/src/funtracks/import_export/_tracks_builder.py @@ -701,13 +701,11 @@ def build( # 6. Add segmentation to the graph if segmentation_array is not None: graph = add_masks_and_bboxes_to_graph(graph, segmentation_array) + graph.update_metadata(segmentation_shape=segmentation_array.shape) # 7. Create SolutionTracks tracks = SolutionTracks( graph=graph, - segmentation_shape=None - if segmentation_array is None - else segmentation_array.shape, pos_attr="pos", time_attr=self.TIME_ATTR, ndim=self.ndim, diff --git a/src/funtracks/import_export/_v1_format.py b/src/funtracks/import_export/_v1_format.py index 21725ca3..a32166a0 100644 --- a/src/funtracks/import_export/_v1_format.py +++ b/src/funtracks/import_export/_v1_format.py @@ -54,8 +54,7 @@ def load_v1_tracks( # Add mask and bbox attributes to graph if segmentation is available if seg is not None: graph_td = add_masks_and_bboxes_to_graph(graph_td, seg) - - segmentation_shape = seg.shape if seg is not None else None + graph_td.update_metadata(segmentation_shape=seg.shape) # filtering the warnings because the default values of time_attr and pos_attr are # not None. Therefore, new style Tracks attrs that have features instead of @@ -71,11 +70,9 @@ def load_v1_tracks( ) tracks: Tracks if solution: - tracks = SolutionTracks( - graph_td, segmentation_shape=segmentation_shape, **attrs - ) + tracks = SolutionTracks(graph_td, **attrs) else: - tracks = Tracks(graph_td, segmentation_shape=segmentation_shape, **attrs) + tracks = Tracks(graph_td, **attrs) return tracks diff --git a/tests/actions/test_add_delete_nodes.py b/tests/actions/test_add_delete_nodes.py index 9a462e07..e73f4335 100644 --- a/tests/actions/test_add_delete_nodes.py +++ b/tests/actions/test_add_delete_nodes.py @@ -198,7 +198,7 @@ def test_custom_attributes_preserved(get_tracks, ndim, with_seg): # Add a node with custom attributes node_id = 100 - action = AddNode(tracks, node_id, custom_attrs, pixels=pixels) + action = AddNode(tracks, node_id, custom_attrs.copy(), pixels=pixels) # Verify all attributes are present after adding assert tracks.graph.has_node(node_id) diff --git a/tests/annotators/test_annotator_registry.py b/tests/annotators/test_annotator_registry.py index ebbb4d32..f60db7de 100644 --- a/tests/annotators/test_annotator_registry.py +++ b/tests/annotators/test_annotator_registry.py @@ -13,7 +13,6 @@ def test_annotator_registry_init_with_segmentation( segmentation.""" tracks = Tracks( graph_2d_with_segmentation, - segmentation_shape=(5, 100, 100), ndim=3, **track_attrs, ) @@ -41,7 +40,6 @@ def test_annotator_registry_init_solution_tracks( segmentation.""" tracks = SolutionTracks( graph_2d_with_segmentation, - segmentation_shape=(5, 100, 100), ndim=3, **track_attrs, ) @@ -55,7 +53,6 @@ def test_annotator_registry_init_solution_tracks( def test_enable_disable_features(graph_2d_with_segmentation): tracks = Tracks( graph_2d_with_segmentation, - segmentation_shape=(5, 100, 100), ndim=3, **track_attrs, ) @@ -109,7 +106,6 @@ 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, - segmentation_shape=(5, 100, 100), ndim=3, **track_attrs, ) @@ -143,7 +139,6 @@ def test_compute_strict_validation(graph_2d_with_segmentation): """Test that compute() strictly validates feature keys.""" tracks = Tracks( graph_2d_with_segmentation, - segmentation_shape=(5, 100, 100), ndim=3, **track_attrs, ) diff --git a/tests/annotators/test_edge_annotator.py b/tests/annotators/test_edge_annotator.py index c6a1808c..ce7ede03 100644 --- a/tests/annotators/test_edge_annotator.py +++ b/tests/annotators/test_edge_annotator.py @@ -15,7 +15,6 @@ def test_init(self, get_graph, ndim): graph = get_graph(ndim, with_features="segmentation") tracks = Tracks( graph, - segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), ndim=ndim, **track_attrs, ) @@ -31,7 +30,6 @@ def test_compute_all(self, get_graph, ndim): graph = get_graph(ndim, with_features="segmentation") tracks = Tracks( graph, - segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), ndim=ndim, **track_attrs, ) @@ -49,7 +47,6 @@ def test_update_all(self, get_graph, ndim) -> None: graph = get_graph(ndim, with_features="segmentation") tracks = Tracks( graph, - segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), ndim=ndim, **track_attrs, ) # type: ignore @@ -84,7 +81,6 @@ def test_add_remove_feature(self, get_graph, ndim): graph = get_graph(ndim, with_features="segmentation") tracks = Tracks( graph, - segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), ndim=ndim, **track_attrs, ) @@ -134,7 +130,6 @@ def test_ignores_irrelevant_actions(self, get_graph, ndim): graph = get_graph(ndim, with_features="segmentation") tracks = SolutionTracks( graph, - segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), ndim=ndim, **track_attrs, ) diff --git a/tests/annotators/test_graph_annotator.py b/tests/annotators/test_graph_annotator.py index 6cd9c9ef..77a294b2 100644 --- a/tests/annotators/test_graph_annotator.py +++ b/tests/annotators/test_graph_annotator.py @@ -9,9 +9,7 @@ def test_base_graph_annotator(graph_2d_with_segmentation): - tracks = Tracks( - graph_2d_with_segmentation, segmentation_shape=(5, 100, 100), **track_attrs - ) + tracks = Tracks(graph_2d_with_segmentation, **track_attrs) ann = GraphAnnotator(tracks, {}) assert len(ann.features) == 0 diff --git a/tests/annotators/test_regionprops_annotator.py b/tests/annotators/test_regionprops_annotator.py index a0587eb1..1aac7038 100644 --- a/tests/annotators/test_regionprops_annotator.py +++ b/tests/annotators/test_regionprops_annotator.py @@ -13,7 +13,6 @@ def test_init(self, get_graph, ndim): graph = get_graph(ndim, with_features="segmentation") tracks = Tracks( graph, - segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), ndim=ndim, **track_attrs, ) @@ -31,7 +30,6 @@ def test_compute_all(self, get_graph, ndim): graph = get_graph(ndim, with_features="segmentation") tracks = Tracks( graph, - segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), ndim=ndim, **track_attrs, ) @@ -51,7 +49,6 @@ def test_update_all(self, get_graph, ndim): graph = get_graph(ndim, with_features="segmentation") tracks = Tracks( graph, - segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), ndim=ndim, **track_attrs, ) @@ -90,7 +87,6 @@ def test_add_remove_feature(self, get_graph, ndim): graph = get_graph(ndim, with_features="segmentation") tracks = Tracks( graph, - segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), ndim=ndim, **track_attrs, ) @@ -140,7 +136,6 @@ def test_ignores_irrelevant_actions(self, get_graph, ndim): graph = get_graph(ndim, with_features="segmentation") tracks = SolutionTracks( graph, - segmentation_shape=(5, 100, 100) if ndim == 3 else (5, 100, 100, 100), ndim=ndim, **track_attrs, ) diff --git a/tests/conftest.py b/tests/conftest.py index 6967e6cc..6042013d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -215,6 +215,7 @@ def _make_graph( track_ids = {1: 1, 2: 2, 3: 3, 4: 3, 5: 3, 6: 5} # 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), @@ -224,6 +225,7 @@ def _make_graph( 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), @@ -233,6 +235,7 @@ def _make_graph( 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 = [] @@ -265,6 +268,8 @@ def _make_graph( 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: @@ -373,7 +378,6 @@ def _make_tracks( # With segmentation: use graph with mask/bbox node attrs # and all computed features graph = get_graph(ndim=ndim, with_features="segmentation") - segmentation_shape = (5, 100, 100) if ndim == 3 else (5, 100, 100, 100) else: # Without segmentation if is_solution: @@ -382,7 +386,6 @@ def _make_tracks( else: # Regular Tracks: use graph with just pos graph = get_graph(ndim=ndim, with_features="position") - segmentation_shape = None # Build FeatureDict based on what exists in the graph features_dict = { @@ -410,14 +413,12 @@ def _make_tracks( if is_solution: return SolutionTracks( graph, - segmentation_shape=segmentation_shape, ndim=ndim, features=feature_dict, ) else: return Tracks( graph, - segmentation_shape=segmentation_shape, ndim=ndim, features=feature_dict, ) diff --git a/tests/data_model/test_solution_tracks.py b/tests/data_model/test_solution_tracks.py index df4965b9..27babdeb 100644 --- a/tests/data_model/test_solution_tracks.py +++ b/tests/data_model/test_solution_tracks.py @@ -69,7 +69,7 @@ def test_next_track_id_empty(): node_attributes=["pos", "track_id"], edge_attributes=[], ) - tracks = SolutionTracks(graph, segmentation_shape=(5, 100, 100, 100), **track_attrs) + tracks = SolutionTracks(graph, ndim=4, **track_attrs) assert tracks.get_next_track_id() == 1 @@ -88,8 +88,8 @@ def test_export_to_csv_with_display_names( assert len(lines) == tracks.graph.num_nodes() + 1 # add header - # With display names: ID, Parent ID, Time, y, x, Tracklet ID - header = ["ID", "Parent ID", "Time", "y", "x", "Tracklet ID"] + # With display names: ID, Parent ID, Time, y, x, Area, Tracklet ID + header = ["ID", "Parent ID", "Time", "y", "x", "Area", "Tracklet ID"] assert lines[0].strip().split(",") == header # Test 3D with display names @@ -102,5 +102,5 @@ def test_export_to_csv_with_display_names( assert len(lines) == tracks.graph.num_nodes() + 1 # add header # With display names: ID, Parent ID, Time, z, y, x, Tracklet ID - header = ["ID", "Parent ID", "Time", "z", "y", "x", "Tracklet ID"] + header = ["ID", "Parent ID", "Time", "z", "y", "x", "Volume", "Tracklet ID"] assert lines[0].strip().split(",") == header diff --git a/tests/data_model/test_tracks.py b/tests/data_model/test_tracks.py index ccfc2d44..69bf8ea7 100644 --- a/tests/data_model/test_tracks.py +++ b/tests/data_model/test_tracks.py @@ -37,7 +37,6 @@ def test_create_tracks(graph_3d_with_segmentation: td.graph.GraphView): # create track with graph and seg tracks = Tracks( graph=graph_3d_with_segmentation, - segmentation_shape=(5, 100, 100, 100), **track_attrs, # type: ignore[arg-type] ) pos_key = tracks.features.position_key @@ -53,15 +52,16 @@ def test_create_tracks(graph_3d_with_segmentation: td.graph.GraphView): tracks_wrong_attr = Tracks( graph=graph_3d_with_segmentation, - segmentation_shape=(5, 100, 100, 100), time_attr="test", ) with pytest.raises(KeyError): # raises error at access if time is wrong tracks_wrong_attr.get_times([1]) - tracks_wrong_attr = Tracks(graph=graph_3d_with_segmentation, pos_attr="test", ndim=3) - with pytest.raises(KeyError): # raises error at access if pos is wrong - tracks_wrong_attr.get_positions([1]) + with pytest.raises(ValueError): + # Raise error is segmentation shape does not match provided ndim + tracks_wrong_attr = Tracks( + graph=graph_3d_with_segmentation, pos_attr="test", ndim=3 + ) # test multiple position attrs pos_attr = ("z", "y", "x") @@ -90,7 +90,6 @@ def test_pixels_and_seg_id(graph_3d_with_segmentation): # create track with graph and seg tracks = Tracks( graph=graph_3d_with_segmentation, - segmentation_shape=(5, 100, 100, 100), **track_attrs, ) @@ -199,7 +198,6 @@ def test_set_positions_list(graph_2d_list): def test_get_pixels_and_set_pixels(graph_2d_with_segmentation): tracks = Tracks( graph_2d_with_segmentation, - segmentation_shape=(5, 100, 100), ndim=3, **track_attrs, ) @@ -209,25 +207,22 @@ def test_get_pixels_and_set_pixels(graph_2d_with_segmentation): assert np.asarray(tracks.segmentation)[0, 50, 50] == 99 -def test_get_pixels_none(graph_2d_with_segmentation): - tracks = Tracks(graph_2d_with_segmentation, ndim=3, **track_attrs) - assert tracks.get_pixels([1]) is None +def test_get_pixels_none(graph_2d_with_track_id): + tracks = Tracks(graph_2d_with_track_id, ndim=3, **track_attrs) + assert tracks.get_pixels(1) is None -def test_set_pixels_no_segmentation(graph_2d_with_segmentation): - tracks = Tracks(graph_2d_with_segmentation, ndim=3, **track_attrs) +def test_set_pixels_no_segmentation(graph_2d_with_track_id): + tracks = Tracks(graph_2d_with_track_id, ndim=3, **track_attrs) pix = [(np.array([0]), np.array([10]), np.array([20]))] with pytest.raises(ValueError): - tracks.set_pixels(pix, [1]) + tracks.set_pixels(pix, 1) def test_compute_ndim_errors(): g = create_empty_graphview_graph() g.add_node_attr_key("pos", default_value=None) g.add_node(index=1, attrs={"t": 0, "pos": [0, 0, 0], "solution": True}) - # seg ndim = 3, scale ndim = 2, provided ndim = 4 -> mismatch - with pytest.raises(ValueError, match="segmentation_shape is incompatible with graph"): - Tracks(g, segmentation_shape=(2, 2, 2), scale=[1, 2], ndim=4) with pytest.raises( ValueError, match="Cannot compute dimensions from segmentation or scale" diff --git a/tests/import_export/test_export_to_geff.py b/tests/import_export/test_export_to_geff.py index af25c195..a628aad6 100644 --- a/tests/import_export/test_export_to_geff.py +++ b/tests/import_export/test_export_to_geff.py @@ -34,9 +34,6 @@ def test_export_to_geff( # doesn't support this graph_type = "segmentation" if with_seg else "position" graph = get_graph(ndim, with_features=graph_type) - segmentation_shape = None - if with_seg: - segmentation_shape = (5, 20, 20) if ndim == 3 else (3, 5, 20, 20) # Determine position attribute keys based on dimensions pos_keys = ["y", "x"] if ndim == 3 else ["z", "y", "x"] @@ -53,7 +50,6 @@ def test_export_to_geff( tracks_cls = SolutionTracks if is_solution else Tracks tracks = tracks_cls( graph, - segmentation_shape=segmentation_shape, time_attr="t", pos_attr=pos_keys, tracklet_attr="track_id", From ed27f84647c335148e657e3991468933bbfa436f Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Wed, 21 Jan 2026 17:44:38 -0800 Subject: [PATCH 31/44] remove computations from AddNode._apply and rely on annotators --- src/funtracks/actions/add_delete_node.py | 81 ++---------------------- 1 file changed, 6 insertions(+), 75 deletions(-) diff --git a/src/funtracks/actions/add_delete_node.py b/src/funtracks/actions/add_delete_node.py index 72be67dd..494f593b 100644 --- a/src/funtracks/actions/add_delete_node.py +++ b/src/funtracks/actions/add_delete_node.py @@ -15,8 +15,6 @@ from tracksdata.nodes._mask import Mask from funtracks.utils.tracksdata_utils import ( - compute_node_attrs_from_masks, - compute_node_attrs_from_pixels, pixels_to_td_mask, ) @@ -90,27 +88,13 @@ def _apply(self) -> None: """Apply the action, and set segmentation if provided in self.pixels""" attrs = self.attributes - final_pos: np.ndarray if self.tracks.segmentation is not None: if self.pixels is not None: - computed_attrs = compute_node_attrs_from_pixels( - [self.pixels], self.tracks.ndim, self.tracks.scale + mask_obj, _ = pixels_to_td_mask( + self.pixels, self.tracks.ndim, self.tracks.scale ) - # Extract single values from lists (since we passed one pixel set) - computed_attrs = {key: value[0] for key, value in computed_attrs.items()} - # if masks are not given, calculate them from the pixels - if "mask" not in attrs: - mask_obj, _ = pixels_to_td_mask( - self.pixels, self.tracks.ndim, self.tracks.scale - ) - attrs[td.DEFAULT_ATTR_KEYS.MASK] = mask_obj - attrs[td.DEFAULT_ATTR_KEYS.BBOX] = mask_obj.bbox - elif "mask" in attrs: - computed_attrs = compute_node_attrs_from_masks( - attrs["mask"], self.tracks.ndim, self.tracks.scale - ) - # Extract single values from lists (since we passed one mask) - computed_attrs = {key: value[0] for key, value in computed_attrs.items()} + attrs[td.DEFAULT_ATTR_KEYS.MASK] = mask_obj + attrs[td.DEFAULT_ATTR_KEYS.BBOX] = mask_obj.bbox else: # TODO Teun: remove this defaulting behavior, see new tracksdata PR if len(self.tracks.segmentation.shape) == 3: @@ -127,60 +111,6 @@ def _apply(self) -> None: raise ValueError( "Must provide pixels or mask when adding node to tracks with seg" ) - # Handle position_key safely using the same pattern as in tracks.py - if isinstance(self.tracks.features.position_key, list): - # Multi-axis position keys - check if any are missing from attrs - missing_keys = [ - k for k in self.tracks.features.position_key if k not in attrs - ] - if missing_keys: - # Use computed position from segmentation - final_pos = np.array(computed_attrs["pos"]) - # Set individual components in attrs - for i, key in enumerate(self.tracks.features.position_key): - attrs[key] = ( - final_pos[i] if final_pos.ndim == 1 else final_pos[:, i] - ) - else: - # All position components provided, combine them - final_pos = np.stack( - [attrs[key] for key in self.tracks.features.position_key], axis=0 - ) - else: - # Single position key - pos_key = self.tracks.features.position_key - if pos_key is not None and pos_key not in attrs: - final_pos = np.array(computed_attrs["pos"]) - attrs[pos_key] = final_pos - elif pos_key is not None: - final_pos = np.array(attrs[pos_key]) - else: - raise ValueError("Position key is None") - # Set area using string literal since FeatureDict doesn't have area_key - # TODO Teun: remove all computed stuff, because annotators will handle this - else: - # No segmentation - handle position_key safely - if isinstance(self.tracks.features.position_key, list): - # Multi-axis position keys - check if any are missing - missing_keys = [ - k for k in self.tracks.features.position_key if k not in attrs - ] - if missing_keys: - raise ValueError( - f"Must provide positions {missing_keys} or segmentation" - ) - # All position components provided, combine them - final_pos = np.stack( - [attrs[key] for key in self.tracks.features.position_key], axis=0 - ) - else: - # Single position key - if ( - self.tracks.features.position_key is None - or self.tracks.features.position_key not in attrs - ): - raise ValueError("Must provide positions or segmentation and ids") - final_pos = np.array(attrs[self.tracks.features.position_key]) # Position is already set in attrs above # Add nodes to td graph @@ -191,7 +121,8 @@ def _apply(self) -> None: attrs[td.DEFAULT_ATTR_KEYS.SOLUTION] = 1 for attr in required_attrs: if attr not in attrs: - attrs[attr] = None + # TODO Teun: remove this logic when td has default values (PR) + attrs[attr] = self.tracks.features[attr]["default_value"] node_dict = { attr: np.array(values) if attr == "pos" else values From 13a1bf4ad62d429611d5fb776fa16f7565cedac5 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Wed, 21 Jan 2026 19:17:24 -0800 Subject: [PATCH 32/44] use features.default in AddEdge --- src/funtracks/actions/add_delete_edge.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/funtracks/actions/add_delete_edge.py b/src/funtracks/actions/add_delete_edge.py index 2aa4af52..f3745736 100644 --- a/src/funtracks/actions/add_delete_edge.py +++ b/src/funtracks/actions/add_delete_edge.py @@ -64,7 +64,7 @@ def _apply(self) -> None: required_attrs = self.tracks.graph.edge_attr_keys() for attr in required_attrs: if attr not in attrs: - attrs[attr] = None + attrs[attr] = self.tracks.features[attr]["default_value"] # Create edge attributes for this specific edge self.tracks.graph.add_edge( From e28d99bd128a7924c93558d805bd08e0a080b89d Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Thu, 22 Jan 2026 15:45:11 -0800 Subject: [PATCH 33/44] updated api in docs/features.md --- docs/features.md | 27 ++++++++++++++++----------- src/funtracks/utils/__init__.py | 2 ++ 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/docs/features.md b/docs/features.md index b15b3814..7eae0c3c 100644 --- a/docs/features.md +++ b/docs/features.md @@ -203,25 +203,30 @@ These features are **automatically checked** during initialization: **Scenario 1: Loading tracks from CSV with pre-computed features** ```python -# CSV has columns: id, time, y, x, area, track_id -# TODO: load_graph_from_csv no longer exists! -graph = load_graph_from_csv(df) # Nodes already have area, track_id -tracks = SolutionTracks(graph, segmentation=seg) +from funtracks.import_export import tracks_from_df + +# CSV/DataFrame has columns: id, time, y, x, area, track_id, parent_id +tracks = tracks_from_df(df, segmentation=seg) # Auto-detection: pos, area, track_id exist → activate without recomputing ``` **Scenario 2: Creating tracks from raw segmentation** ```python -# Graph has no features yet -#TODO: test these examples with the new tracksdata api +from funtracks.utils import create_empty_graphview_graph +from funtracks.data_model import Tracks + +# Create empty graph and add nodes graph = create_empty_graphview_graph() -graph.add_node(index=1, attrs={"t": 0, "solution": 1}) +graph.add_node(index=1, attrs={"t": 0}) tracks = Tracks(graph, segmentation=seg) -# Auto-detection: pos, area don't exist → compute them +# Auto-detection: pos, area don't exist → compute them from segmentation ``` **Scenario 3: Explicit feature control with FeatureDict** ```python +from funtracks.features import FeatureDict, Time, Position, Area +from funtracks.data_model import Tracks + # Bypass auto-detection entirely feature_dict = FeatureDict({"t": Time(), "pos": Position(), "area": Area()}) tracks = Tracks(graph, segmentation=seg, features=feature_dict) @@ -229,8 +234,9 @@ tracks = Tracks(graph, segmentation=seg, features=feature_dict) ``` **Scenario 4: Enable a new feature** - ```python +from funtracks.data_model import Tracks + tracks = Tracks(graph, segmentation) # Initially has: time, pos, area (auto-detected or computed) @@ -242,8 +248,7 @@ print(tracks.features.keys()) # All features in FeatureDict (including static) print(tracks.annotators.features.keys()) # Only active computed features ``` -**Scenario 4: Disable a feature** - +**Scenario 5: Disable a feature** ```python tracks.disable_features(["area"]) # Removes from FeatureDict, deactivates in annotators diff --git a/src/funtracks/utils/__init__.py b/src/funtracks/utils/__init__.py index 5985c241..6635ac11 100644 --- a/src/funtracks/utils/__init__.py +++ b/src/funtracks/utils/__init__.py @@ -9,8 +9,10 @@ setup_zarr_array, setup_zarr_group, ) +from .tracksdata_utils import create_empty_graphview_graph __all__ = [ + "create_empty_graphview_graph", "detect_zarr_spec_version", "get_store_path", "is_zarr_v3", From 176cd7724c535dd17b5aa2f92239742e16e6e994 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Tue, 27 Jan 2026 11:00:30 -0800 Subject: [PATCH 34/44] tracksdata now fully handles default attribute values! --- pyproject.toml | 3 +- src/funtracks/actions/add_delete_node.py | 7 +- src/funtracks/annotators/_graph_annotator.py | 17 +- src/funtracks/data_model/solution_tracks.py | 12 +- src/funtracks/data_model/tracks.py | 13 +- .../import_export/_tracks_builder.py | 29 ++- src/funtracks/import_export/geff/_export.py | 7 +- src/funtracks/utils/tracksdata_utils.py | 180 +++++++++++++++--- tests/actions/test_add_delete_nodes.py | 3 + tests/actions/test_update_node_attrs.py | 2 +- .../annotators/test_regionprops_annotator.py | 12 +- tests/conftest.py | 11 +- tests/data_model/test_solution_tracks.py | 8 +- tests/data_model/test_tracks.py | 9 +- tests/import_export/test_export_to_geff.py | 3 +- 15 files changed, 244 insertions(+), 72 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 623f5936..46916e7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,8 @@ dependencies =[ "pandas>=2.3.3", "zarr>=2.18,<4", "numcodecs>=0.13,<0.16", - "tracksdata[spatial]@git+https://github.com/royerlab/tracksdata@ef5ad56594eba9723349f2a357029b87267bc330", + "tracksdata[spatial]@git+https://github.com/JoOkuma/tracksdata@cab9e2734d30bec681522a0ab2420dc825080fed", + # This will soon be main, and I will then update the commit hash ] [project.urls] diff --git a/src/funtracks/actions/add_delete_node.py b/src/funtracks/actions/add_delete_node.py index 494f593b..1a1caa6b 100644 --- a/src/funtracks/actions/add_delete_node.py +++ b/src/funtracks/actions/add_delete_node.py @@ -124,12 +124,7 @@ def _apply(self) -> None: # TODO Teun: remove this logic when td has default values (PR) attrs[attr] = self.tracks.features[attr]["default_value"] - node_dict = { - attr: np.array(values) if attr == "pos" else values - for attr, values in attrs.items() - } - - self.tracks.graph.add_node(attrs=node_dict, index=self.node) + self.tracks.graph.add_node(attrs=attrs, index=self.node, validate_keys=True) if self.pixels is not None: self.tracks.set_pixels(self.pixels, self.node) diff --git a/src/funtracks/annotators/_graph_annotator.py b/src/funtracks/annotators/_graph_annotator.py index 3bf33087..8d3c0dd2 100644 --- a/src/funtracks/annotators/_graph_annotator.py +++ b/src/funtracks/annotators/_graph_annotator.py @@ -3,6 +3,10 @@ import logging from typing import TYPE_CHECKING +import polars as pl + +from funtracks.utils.tracksdata_utils import to_polars_dtype + if TYPE_CHECKING: from funtracks.actions import BasicAction from funtracks.data_model import Tracks @@ -71,15 +75,24 @@ def activate_features(self, keys: list[str]) -> None: feat["feature_type"] == "node" and key not in self.tracks.graph.node_attr_keys() ): + # Get the dtype from the feature dict + # unless the feature has multiple values, in which case use Array type + dtype = to_polars_dtype(feat["value_type"]) + if feat["num_values"] is not None and feat["num_values"] > 1: + dtype = pl.Array(pl.Float64, feat["num_values"]) self.tracks.graph.add_node_attr_key( - key, default_value=feat["default_value"] + key, + default_value=feat["default_value"], + dtype=dtype, ) elif ( feat["feature_type"] == "edge" and key not in self.tracks.graph.edge_attr_keys() ): self.tracks.graph.add_edge_attr_key( - key, default_value=feat["default_value"] + key, + default_value=feat["default_value"], + dtype=to_polars_dtype(feat["value_type"]), ) def deactivate_features(self, keys: list[str]) -> None: diff --git a/src/funtracks/data_model/solution_tracks.py b/src/funtracks/data_model/solution_tracks.py index aa03bd8d..cce8779e 100644 --- a/src/funtracks/data_model/solution_tracks.py +++ b/src/funtracks/data_model/solution_tracks.py @@ -94,11 +94,13 @@ def from_tracks(cls, tracks: Tracks): # Check if all nodes have track_id before trusting existing track IDs if ( tracks.features.tracklet_key is not None - and tracks.graph.node_attrs(attr_keys=tracks.features.tracklet_key)[ - tracks.features.tracklet_key - ] - .is_null() - .any() + and ( + tracks.graph.node_attrs(attr_keys=tracks.features.tracklet_key)[ + tracks.features.tracklet_key + ] + == -1 + ).any() + # Attributes are no longer None, so 0 now means non-computed ): force_recompute = True diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index 5bbcb962..be8a727a 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -19,6 +19,7 @@ from funtracks.utils.tracksdata_utils import ( pixels_to_td_mask, td_get_single_attr_from_edge, + to_polars_dtype, ) if TYPE_CHECKING: @@ -739,9 +740,17 @@ def add_feature(self, key: str, feature: Feature) -> None: # Perform custom graph operations when a feature is added if feature["feature_type"] == "node" and key not in self.graph.node_attr_keys(): - self.graph.add_node_attr_key(key, default_value=feature["default_value"]) + self.graph.add_node_attr_key( + key, + default_value=feature["default_value"], + dtype=to_polars_dtype(feature["value_type"]), + ) elif feature["feature_type"] == "edge" and key not in self.graph.edge_attr_keys(): - self.graph.add_edge_attr_key(key, default_value=feature["default_value"]) + self.graph.add_edge_attr_key( + key, + default_value=feature["default_value"], + dtype=to_polars_dtype(feature["value_type"]), + ) def delete_feature(self, key: str) -> None: """Delete a feature from the features dictionary and perform graph operations. diff --git a/src/funtracks/import_export/_tracks_builder.py b/src/funtracks/import_export/_tracks_builder.py index 19dbb2b5..186da140 100644 --- a/src/funtracks/import_export/_tracks_builder.py +++ b/src/funtracks/import_export/_tracks_builder.py @@ -9,7 +9,7 @@ from abc import ABC, abstractmethod from pathlib import Path -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Any, Literal import numpy as np import tracksdata as td @@ -404,18 +404,31 @@ def construct_graph( if node_name_map is not None: node_attributes = list(self.in_memory_geff["node_props"].keys()) - node_default_values = [ - 0.0 - if key in node_name_map and isinstance(node_name_map[key], str) - else None + node_first_values = [ + self.in_memory_geff["node_props"][key]["values"][0] for key in node_attributes ] + + node_default_dtypes = [type(value) for value in node_first_values] + node_default_values = [] + for i, dtype in enumerate(node_default_dtypes): + default_value: Any + if issubclass(dtype, np.integer): + default_value = -1 + elif issubclass(dtype, np.floating): + default_value = 0.0 + elif issubclass(dtype, np.str_): + default_value = "" + elif issubclass(dtype, np.ndarray): + default_value = np.array([0.0 for _ in node_first_values[i]]) + else: + default_value = 0 + node_default_values.append(default_value) + graph = create_empty_graphview_graph( node_attributes=list(self.in_memory_geff["node_props"].keys()), edge_attributes=list(self.in_memory_geff["edge_props"].keys()), - node_default_values=node_default_values - if node_name_map is not None - else None, + node_default_values=node_default_values, database=":memory:", ) diff --git a/src/funtracks/import_export/geff/_export.py b/src/funtracks/import_export/geff/_export.py index c86d2aa4..2d7644b3 100644 --- a/src/funtracks/import_export/geff/_export.py +++ b/src/funtracks/import_export/geff/_export.py @@ -8,6 +8,7 @@ import geff_spec import numpy as np +import polars as pl import tracksdata as td from geff_spec import GeffMetadata @@ -176,8 +177,8 @@ def split_position_attr(tracks: Tracks) -> tuple[td.graph.GraphView, list[str] | new_graph = new_graph.filter().subgraph() # Register new attribute keys - new_graph.add_node_attr_key("x", default_value=0.0) - new_graph.add_node_attr_key("y", default_value=0.0) + new_graph.add_node_attr_key("x", default_value=0.0, dtype=pl.Float64) + new_graph.add_node_attr_key("y", default_value=0.0, dtype=pl.Float64) # Get all position values at once pos_values = new_graph.node_attrs()["pos"].to_numpy() @@ -191,7 +192,7 @@ def split_position_attr(tracks: Tracks) -> tuple[td.graph.GraphView, list[str] | ) elif ndim == 3: new_keys = ["z", "y", "x"] - new_graph.add_node_attr_key("z", default_value=0.0) + new_graph.add_node_attr_key("z", default_value=0.0, dtype=pl.Float64) new_graph.update_node_attrs( attrs={ "x": pos_values[:, 2], diff --git a/src/funtracks/utils/tracksdata_utils.py b/src/funtracks/utils/tracksdata_utils.py index c642401b..ccdcb913 100644 --- a/src/funtracks/utils/tracksdata_utils.py +++ b/src/funtracks/utils/tracksdata_utils.py @@ -13,6 +13,61 @@ from tracksdata.nodes._mask import Mask +def to_polars_dtype(dtype_or_value: str | Any) -> pl.DataType: + """Convert a type string or value to polars dtype. + + Args: + dtype_or_value: Either a type string ("int", "float", "str", "bool") + or a value whose type should be inferred + + Returns: + Corresponding polars dtype + + Raises: + ValueError: If the type is not supported + + Examples: + >>> to_polars_dtype("int") + Int64 + >>> to_polars_dtype(5) + Int64 + >>> to_polars_dtype(np.int64(5)) + Int64 + >>> to_polars_dtype("") # String value + String + """ + # Check if it's a known type string first + type_string_mapping = { + "str": pl.String, + "int": pl.Int64, + "float": pl.Float64, + "bool": pl.Boolean, + "datetime": pl.Datetime, + "date": pl.Date, + } + + if dtype_or_value in type_string_mapping: + return type_string_mapping[dtype_or_value] + + # If it's a string but not a type name, try as polars type name (backward compat) + if isinstance(dtype_or_value, str): + try: + return getattr(pl, dtype_or_value) + except AttributeError: + # It's a string value, not a type name - return String dtype + return pl.String + + # Otherwise, infer from the value's type + if isinstance(dtype_or_value, (bool, np.bool_)): + return pl.Boolean + elif isinstance(dtype_or_value, (int, np.integer)): + return pl.Int64 + elif isinstance(dtype_or_value, (float, np.floating)): + return pl.Float64 + else: + raise ValueError(f"Unsupported type: {type(dtype_or_value)}") + + def create_empty_graphview_graph( node_attributes: list[str] | None = None, edge_attributes: list[str] | None = None, @@ -20,6 +75,7 @@ def create_empty_graphview_graph( edge_default_values: list[Any] | None = None, database: str | None = None, position_attrs: list[str] | None = None, + ndim: int = 3, ) -> td.graph.GraphView: """ Create an empty tracksdata GraphView with standard node and edge attributes. @@ -41,6 +97,9 @@ def create_empty_graphview_graph( position_attrs : list[str] | None List of position attribute names, e.g. ['pos'] or ['x', 'y', 'z']. Defaults to ['pos'] if None. + ndim : int + Number of dimensions including time, so 2D+T dataset has ndim = 3. + Defaults to 3 (2D+time). Returns ------- @@ -82,35 +141,48 @@ def create_empty_graphview_graph( attr in (node_attributes or []) for attr in position_attrs ): if "pos" in position_attrs: - graph_sql.add_node_attr_key("pos", default_value=None) + graph_sql.add_node_attr_key("pos", pl.Array(pl.Float64, ndim - 1)) else: if "x" in position_attrs: - graph_sql.add_node_attr_key("x", default_value=0.0) + graph_sql.add_node_attr_key("x", default_value=0.0, dtype=pl.Float64) if "y" in position_attrs: - graph_sql.add_node_attr_key("y", default_value=0.0) + graph_sql.add_node_attr_key("y", default_value=0.0, dtype=pl.Float64) if "z" in position_attrs: - graph_sql.add_node_attr_key("z", default_value=0.0) - + graph_sql.add_node_attr_key("z", default_value=0.0, dtype=pl.Float64) if "mask" in (node_attributes or []): - graph_sql.add_node_attr_key("mask", default_value=None) + graph_sql.add_node_attr_key("mask", pl.Object) if "bbox" in (node_attributes or []): - graph_sql.add_node_attr_key("bbox", default_value=None) + graph_sql.add_node_attr_key("bbox", pl.Array(pl.Int64, 2 * (ndim - 1))) + if "track_id" in (node_attributes or []): + graph_sql.add_node_attr_key("track_id", default_value=-1, dtype=pl.Int64) for attr in node_attributes or []: if attr not in graph_sql.node_attr_keys(): + default_value = node_default_values[(node_attributes or []).index(attr)] graph_sql.add_node_attr_key( attr, - default_value=node_default_values[(node_attributes or []).index(attr)], + default_value=default_value + if not isinstance(default_value, np.ndarray) + else None, + dtype=to_polars_dtype(default_value) + if not isinstance(default_value, np.ndarray) + else pl.Array(pl.Float64, len(default_value)), # type: ignore ) for attr in edge_attributes or []: if attr not in graph_sql.edge_attr_keys(): + default_value = edge_default_values[(edge_attributes or []).index(attr)] graph_sql.add_edge_attr_key( attr, - default_value=edge_default_values[(edge_attributes or []).index(attr)], + default_value=default_value, + dtype=to_polars_dtype(default_value), ) - graph_sql.add_node_attr_key(td.DEFAULT_ATTR_KEYS.SOLUTION, default_value=1) - graph_sql.add_edge_attr_key(td.DEFAULT_ATTR_KEYS.SOLUTION, default_value=1) + graph_sql.add_node_attr_key( + td.DEFAULT_ATTR_KEYS.SOLUTION, default_value=1, dtype=pl.Int64 + ) + graph_sql.add_edge_attr_key( + td.DEFAULT_ATTR_KEYS.SOLUTION, default_value=1, dtype=pl.Int64 + ) graph_td_sub = graph_sql.filter( td.NodeAttr(td.DEFAULT_ATTR_KEYS.SOLUTION) == 1, @@ -146,6 +218,7 @@ def assert_node_attrs_equal_with_masks( node_attrs2.drop("mask"), check_column_order=check_column_order, check_row_order=check_row_order, + check_dtypes=False, ) # Check masks separately for node in node_attrs1["node_id"]: @@ -402,8 +475,8 @@ def add_masks_and_bboxes_to_graph( list_of_masks = segmentation_to_masks(segmentation) # Add 'mask' and 'bbox' attributes to graph nodes - graph.add_node_attr_key("mask", default_value=None) - graph.add_node_attr_key("bbox", default_value=None) + graph.add_node_attr_key("mask", pl.Object) + graph.add_node_attr_key("bbox", pl.Array(pl.Int64, 2 * (segmentation.ndim - 1))) for label, _, mask in list_of_masks: if graph.has_node(label): @@ -449,14 +522,20 @@ def td_relabel_nodes(graph, mapping: dict[int, int]) -> td.graph.SQLGraph: } new_graph = td.graph.SQLGraph(**kwargs) - # Copy attribute key registrations with defaults - node_defaults = get_node_attr_defaults(graph) - for key, default_val in node_defaults.items(): - new_graph.add_node_attr_key(key, default_value=default_val) + # Copy attribute key registrations with defaults and dtypes + node_schemas = graph._node_attr_schemas() + for key, schema in node_schemas.items(): + if key not in ["node_id", "t"]: # Skip system columns + new_graph.add_node_attr_key( + key, default_value=schema.default_value, dtype=schema.dtype + ) - edge_defaults = get_edge_attr_defaults(graph) - for key, default_val in edge_defaults.items(): - new_graph.add_edge_attr_key(key, default_value=default_val) + edge_schemas = graph._edge_attr_schemas() + for key, schema in edge_schemas.items(): + if key not in ["edge_id", "source_id", "target_id"]: # Skip system columns + new_graph.add_edge_attr_key( + key, default_value=schema.default_value, dtype=schema.dtype + ) # Get all data old_nodes = old_graph.node_attrs() @@ -562,37 +641,78 @@ def convert_graph_nx_to_td(graph_nx: nx.DiGraph) -> td.graph.GraphView: # Add node attribute keys to tracksdata graph for attr, value in all_nodes[0][1].items(): if attr not in graph_td.node_attr_keys(): - default_value = None if isinstance(value, list) else 0.0 - graph_td.add_node_attr_key(attr, default_value=default_value) + default_value: Any # mypy necessities + dtype: pl.DataType + if isinstance(value, list): + # Array type - always use Float64 for numeric arrays from NetworkX + # since NetworkX doesn't enforce type consistency across nodes + default_value = None + dtype = pl.Array(pl.Float64, len(value)) + else: + # Scalar type - always use Float64 for numeric types from NetworkX + # since NetworkX doesn't enforce type consistency across nodes + if isinstance(value, (int, float, np.integer, np.floating)): + default_value = 0.0 + dtype = pl.Float64 + else: + default_value = "" + dtype = pl.String + graph_td.add_node_attr_key(attr, default_value=default_value, dtype=dtype) else: if attr != "t": raise Warning( f"Node attribute '{attr}' already exists in " f"tracksdata graph. Skipping addition." ) - graph_td.add_node_attr_key(td.DEFAULT_ATTR_KEYS.SOLUTION, default_value=1) + graph_td.add_node_attr_key( + td.DEFAULT_ATTR_KEYS.SOLUTION, default_value=1, dtype=pl.Int64 + ) # Add edge attribute keys to tracksdata graph for attr, value in all_edges[0][2].items(): if attr not in graph_td.edge_attr_keys(): - default_value = None if isinstance(value, list) else 0.0 - graph_td.add_edge_attr_key(attr, default_value=default_value) + if isinstance(value, list): + # Array type - always use Float64 for numeric arrays from NetworkX + default_value = None + dtype = pl.Array(pl.Float64, len(value)) + else: + # Scalar type - always use Float64 for numeric types from NetworkX + if isinstance(value, (int, float, np.integer, np.floating)): + default_value = 0.0 + dtype = pl.Float64 + else: + default_value = "" + dtype = pl.String + graph_td.add_edge_attr_key(attr, default_value=default_value, dtype=dtype) else: raise Warning( f"Edge attribute '{attr}' already exists in tracksdata graph. " f"Skipping addition." ) - graph_td.add_edge_attr_key(td.DEFAULT_ATTR_KEYS.SOLUTION, default_value=1) + graph_td.add_edge_attr_key( + td.DEFAULT_ATTR_KEYS.SOLUTION, default_value=1, dtype=pl.Int64 + ) # Add node attributes for node_id, attrs in all_nodes: - attrs[td.DEFAULT_ATTR_KEYS.SOLUTION] = 1 - graph_td.add_node(attrs, index=node_id) + attrs_copy = dict(attrs) + # Convert lists to numpy arrays to work around tracksdata SQLGraph bug + # where Python lists with floats get truncated + for key, value in attrs_copy.items(): + if isinstance(value, list): + attrs_copy[key] = np.array(value, dtype=np.float64) + attrs_copy[td.DEFAULT_ATTR_KEYS.SOLUTION] = 1 + graph_td.add_node(attrs_copy, index=node_id) # Add edges for source_id, target_id, attrs in all_edges: - attrs[td.DEFAULT_ATTR_KEYS.SOLUTION] = 1 - graph_td.add_edge(source_id, target_id, attrs) + attrs_copy = dict(attrs) + # Convert lists to numpy arrays to work around tracksdata SQLGraph bug + for key, value in attrs_copy.items(): + if isinstance(value, list): + attrs_copy[key] = np.array(value, dtype=np.float64) + attrs_copy[td.DEFAULT_ATTR_KEYS.SOLUTION] = 1 + graph_td.add_edge(source_id, target_id, attrs_copy) # Create subgraph (GraphView) with only solution nodes and edges graph_td_sub = graph_td.filter( diff --git a/tests/actions/test_add_delete_nodes.py b/tests/actions/test_add_delete_nodes.py index e73f4335..8206250c 100644 --- a/tests/actions/test_add_delete_nodes.py +++ b/tests/actions/test_add_delete_nodes.py @@ -35,6 +35,7 @@ def test_add_delete_nodes(get_tracks, ndim, with_seg): 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 @@ -86,6 +87,7 @@ def test_add_delete_nodes(get_tracks, ndim, with_seg): 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 @@ -108,6 +110,7 @@ def test_add_delete_nodes(get_tracks, ndim, with_seg): data_tracks, # .drop(["mask", "bbox", "area"]), check_column_order=False, check_row_order=False, + check_dtypes=False, ) diff --git a/tests/actions/test_update_node_attrs.py b/tests/actions/test_update_node_attrs.py index b371cb61..4f2dfb30 100644 --- a/tests/actions/test_update_node_attrs.py +++ b/tests/actions/test_update_node_attrs.py @@ -25,7 +25,7 @@ def test_update_node_attrs(get_tracks, ndim): assert tracks.get_node_attr(node, "score") == 1.0 inverse = action.inverse() - assert tracks.get_node_attr(node, "score") is None + assert tracks.get_node_attr(node, "score") == -1.0 inverse.inverse() assert tracks.get_node_attr(node, "score") == 1.0 diff --git a/tests/annotators/test_regionprops_annotator.py b/tests/annotators/test_regionprops_annotator.py index 1aac7038..74729498 100644 --- a/tests/annotators/test_regionprops_annotator.py +++ b/tests/annotators/test_regionprops_annotator.py @@ -1,4 +1,6 @@ +import numpy as np import pytest +from tracksdata.utils._dtypes import infer_default_value_from_dtype from funtracks.actions import UpdateNodeSeg, UpdateTrackID from funtracks.annotators import RegionpropsAnnotator @@ -80,8 +82,16 @@ def test_update_all(self, get_graph, ndim): ): UpdateNodeSeg(tracks, node_id, pixels, added=False) + # all regionprops features should be the defaults, because seg doesn't exist for key in rp_ann.features: - assert tracks.graph[node_id][key] is None + actual = tracks.graph[node_id][key] + expected = infer_default_value_from_dtype( + tracks.graph._node_attr_schemas()[key].dtype + ) + # 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_features="segmentation") diff --git a/tests/conftest.py b/tests/conftest.py index 6042013d..ced38560 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING import numpy as np +import polars as pl import pytest import tracksdata as td from skimage.draw import disk @@ -175,6 +176,7 @@ def _make_graph( edge_attributes=edge_attributes, database=database, position_attrs=["pos"] if with_pos else None, + ndim=ndim, ) # Base node data (always has time) @@ -447,10 +449,11 @@ def graph_2d_list(tmp_path) -> td.graph.GraphView: "track_id": 2, }, ] - graph.add_node_attr_key("y", default_value=0) - graph.add_node_attr_key("x", default_value=0) - graph.add_node_attr_key("area", default_value=0) - graph.add_node_attr_key("track_id", default_value=0) + 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.bulk_add_nodes(nodes=nodes, indices=[1, 2]) return graph diff --git a/tests/data_model/test_solution_tracks.py b/tests/data_model/test_solution_tracks.py index 27babdeb..b8e3f08c 100644 --- a/tests/data_model/test_solution_tracks.py +++ b/tests/data_model/test_solution_tracks.py @@ -20,7 +20,7 @@ def test_next_track_id(graph_2d_with_segmentation): AddNode( tracks, node=10, - attributes={"t": 3, "pos": [0, 0, 0, 0], "track_id": 10}, + attributes={"t": 3, "pos": [0, 0], "track_id": 10}, ) assert tracks.get_next_track_id() == 11 @@ -53,9 +53,9 @@ def test_from_tracks_cls_recompute(graph_2d_with_segmentation): tracklet_attr=track_attrs["tracklet_attr"], scale=(2, 2, 2), ) - # delete track id on one node triggers reassignment of track_ids even when recompute - # is False. - tracks.graph[1][tracks.features.tracklet_key] = [None] + # delete track id (default value -1) on one node triggers reassignment of + # track_ids even when recompute is False. + tracks.graph[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 diff --git a/tests/data_model/test_tracks.py b/tests/data_model/test_tracks.py index 69bf8ea7..4ca71e71 100644 --- a/tests/data_model/test_tracks.py +++ b/tests/data_model/test_tracks.py @@ -1,4 +1,5 @@ import numpy as np +import polars as pl import pytest import tracksdata as td @@ -65,9 +66,9 @@ def test_create_tracks(graph_3d_with_segmentation: td.graph.GraphView): # test multiple position attrs pos_attr = ("z", "y", "x") - graph_3d_with_segmentation.add_node_attr_key(key="z", default_value=0) - graph_3d_with_segmentation.add_node_attr_key(key="y", default_value=0) - graph_3d_with_segmentation.add_node_attr_key(key="x", default_value=0) + graph_3d_with_segmentation.add_node_attr_key("z", default_value=0.0, dtype=pl.Float64) + graph_3d_with_segmentation.add_node_attr_key("y", default_value=0.0, dtype=pl.Float64) + graph_3d_with_segmentation.add_node_attr_key("x", default_value=0.0, dtype=pl.Float64) for node in graph_3d_with_segmentation.node_ids(): pos = graph_3d_with_segmentation[node]["pos"] z, y, x = pos @@ -221,7 +222,7 @@ def test_set_pixels_no_segmentation(graph_2d_with_track_id): def test_compute_ndim_errors(): g = create_empty_graphview_graph() - g.add_node_attr_key("pos", default_value=None) + g.add_node_attr_key("pos", default_value=[0, 0], dtype=pl.List(pl.Int64)) g.add_node(index=1, attrs={"t": 0, "pos": [0, 0, 0], "solution": True}) with pytest.raises( diff --git a/tests/import_export/test_export_to_geff.py b/tests/import_export/test_export_to_geff.py index a628aad6..cb91e735 100644 --- a/tests/import_export/test_export_to_geff.py +++ b/tests/import_export/test_export_to_geff.py @@ -1,4 +1,5 @@ import numpy as np +import polars as pl import pytest import zarr @@ -39,7 +40,7 @@ def test_export_to_geff( pos_keys = ["y", "x"] if ndim == 3 else ["z", "y", "x"] # Split the composite position attribute into separate attributes for key in pos_keys: - graph.add_node_attr_key(key, default_value=0.0) + graph.add_node_attr_key(key, default_value=0.0, dtype=pl.Float64) for node in graph.node_ids(): pos = graph[node]["pos"] for i, key in enumerate(pos_keys): From 1f63bc593d95fc355f1ba477b6c3292f949bfa36 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Tue, 27 Jan 2026 11:39:19 -0800 Subject: [PATCH 35/44] removed set_pixels from add_node --- src/funtracks/actions/add_delete_node.py | 3 --- src/funtracks/data_model/tracks.py | 1 - 2 files changed, 4 deletions(-) diff --git a/src/funtracks/actions/add_delete_node.py b/src/funtracks/actions/add_delete_node.py index 1a1caa6b..205550dd 100644 --- a/src/funtracks/actions/add_delete_node.py +++ b/src/funtracks/actions/add_delete_node.py @@ -126,9 +126,6 @@ def _apply(self) -> None: self.tracks.graph.add_node(attrs=attrs, index=self.node, validate_keys=True) - if self.pixels is not None: - self.tracks.set_pixels(self.pixels, self.node) - # Always notify annotators - they will check their own preconditions self.tracks.notify_annotators(self) diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index be8a727a..85278f22 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -505,7 +505,6 @@ def set_pixels( attrs={ td.DEFAULT_ATTR_KEYS.MASK: [mask_subtracted], td.DEFAULT_ATTR_KEYS.BBOX: [mask_subtracted.bbox], - # "area": [area_subtracted], }, node_ids=[node_id], ) From 30dfaa926da744c7fbbd67eb79234b4f6a567489 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Fri, 30 Jan 2026 00:27:59 -0800 Subject: [PATCH 36/44] remove set_pixels --- src/funtracks/actions/update_segmentation.py | 37 +++++++++- src/funtracks/data_model/tracks.py | 76 -------------------- tests/data_model/test_solution_tracks.py | 20 ++++++ tests/data_model/test_tracks.py | 34 +++------ 4 files changed, 64 insertions(+), 103 deletions(-) diff --git a/src/funtracks/actions/update_segmentation.py b/src/funtracks/actions/update_segmentation.py index d067459e..21b6cd00 100644 --- a/src/funtracks/actions/update_segmentation.py +++ b/src/funtracks/actions/update_segmentation.py @@ -2,6 +2,10 @@ from typing import TYPE_CHECKING +import tracksdata as td + +from funtracks.utils.tracksdata_utils import pixels_to_td_mask + from ._base import BasicAction if TYPE_CHECKING: @@ -48,5 +52,36 @@ def inverse(self) -> BasicAction: def _apply(self) -> None: """Set new attributes""" value = self.node if self.added else 0 - self.tracks.set_pixels(self.pixels, value, self.node) + + mask_new, area_new = pixels_to_td_mask( + self.pixels, self.tracks.ndim, self.tracks.scale + ) + + if value == 0: + # val=0 means deleting the pixels from the mask + mask_old = self.tracks.graph[self.node][td.DEFAULT_ATTR_KEYS.MASK] + mask_subtracted = mask_old.__isub__(mask_new) + self.tracks.graph.update_node_attrs( + attrs={ + td.DEFAULT_ATTR_KEYS.MASK: [mask_subtracted], + td.DEFAULT_ATTR_KEYS.BBOX: [mask_subtracted.bbox], + }, + node_ids=[self.node], + ) + + elif self.tracks.graph.has_node(value): + # if node already exists: + mask_old = self.tracks.graph[value][td.DEFAULT_ATTR_KEYS.MASK] + mask_combined = mask_old.__or__(mask_new) + self.tracks.graph.update_node_attrs( + attrs={ + td.DEFAULT_ATTR_KEYS.MASK: [mask_combined], + td.DEFAULT_ATTR_KEYS.BBOX: [mask_combined.bbox], + }, + node_ids=[value], + ) + + # Invalidate cache for affected chunks + self.tracks._update_segmentation_cache(self.pixels) + self.tracks.notify_annotators(self) diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index 85278f22..88b4d0f7 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -17,7 +17,6 @@ from funtracks.features import Feature, FeatureDict, Position, Time from funtracks.utils.tracksdata_utils import ( - pixels_to_td_mask, td_get_single_attr_from_edge, to_polars_dtype, ) @@ -473,81 +472,6 @@ def get_pixels(self, node: Node) -> tuple[np.ndarray, ...] | None: return (time_array, *global_coords) - def set_pixels( - self, pixels: tuple[np.ndarray, ...], value: int, node: int | None = None - ): - """Set the given pixels in the segmentation to the given value. - - Args: - pixels (Iterable[tuple[np.ndarray]]): The pixels that should be set, - formatted like the output of np.nonzero (each element of the tuple - represents one dimension, containing an array of indices in that - dimension). Can be used to directly index the segmentation. - value (Iterable[int | None]): The value to set each pixel to - nodes (Iterable[int] | None, optional): The node ids that the pixels - correspond to. Only needed if pixels need to be removed (val=0) - """ - if self.segmentation is None: - raise ValueError("Cannot set pixels when segmentation is None") - - node_id = node if node is not None else None - - if value is None: - raise ValueError("Cannot set pixels to None value") - - mask_new, area_new = pixels_to_td_mask(pixels, self.ndim, self.scale) - - if value == 0: - # val=0 means deleting the pixels from the mask - mask_old = self.graph[node_id][td.DEFAULT_ATTR_KEYS.MASK] - mask_subtracted = mask_old.__isub__(mask_new) - self.graph.update_node_attrs( - attrs={ - td.DEFAULT_ATTR_KEYS.MASK: [mask_subtracted], - td.DEFAULT_ATTR_KEYS.BBOX: [mask_subtracted.bbox], - }, - node_ids=[node_id], - ) - - elif self.graph.has_node(value): - # if node already exists: - mask_old = self.graph[value][td.DEFAULT_ATTR_KEYS.MASK] - mask_combined = mask_old.__or__(mask_new) - self.graph.update_node_attrs( - attrs={ - td.DEFAULT_ATTR_KEYS.MASK: [mask_combined], - td.DEFAULT_ATTR_KEYS.BBOX: [mask_combined.bbox], - }, - node_ids=[value], - ) - - else: - if len(np.unique(pixels[0])) > 1: - raise ValueError( - f"pixels in Tracks.set_pixels has more than 1 timepoint " - f"for node {value}. This is not implemented, so if this is " - "necessary, Tracks.set_pixels should be updated" - ) - - time = int(np.unique(pixels[0])[0]) - pos = np.array([np.mean(pixels[dim + 1]) for dim in range(self.ndim - 1)]) - track_id = -1 # dummy, will be replaced in AddNodes._apply() - - node_dict = { - self.features.time_key: time, - self.features.position_key: pos, - self.features.tracklet_key: track_id, - "area": area_new, - td.DEFAULT_ATTR_KEYS.SOLUTION: 1, - td.DEFAULT_ATTR_KEYS.MASK: mask_new, - td.DEFAULT_ATTR_KEYS.BBOX: mask_new.bbox, - } - - self.graph.add_node(node_dict, index=value) - - # Invalidate cache for affected chunks - self._update_segmentation_cache(pixels) - def _update_segmentation_cache(self, pixels: tuple[np.ndarray, ...]) -> None: """Invalidate cached chunks that overlap with the given pixels. diff --git a/tests/data_model/test_solution_tracks.py b/tests/data_model/test_solution_tracks.py index b8e3f08c..6644a06c 100644 --- a/tests/data_model/test_solution_tracks.py +++ b/tests/data_model/test_solution_tracks.py @@ -1,5 +1,8 @@ +import numpy as np + from funtracks.actions import AddNode from funtracks.data_model import SolutionTracks, Tracks +from funtracks.user_actions import UserUpdateSegmentation from funtracks.utils.tracksdata_utils import create_empty_graphview_graph track_attrs = {"time_attr": "t", "tracklet_attr": "track_id"} @@ -64,6 +67,23 @@ def test_from_tracks_cls_recompute(graph_2d_with_segmentation): ) # still 1 +def test_update_segmentation(graph_2d_with_segmentation): + tracks = SolutionTracks( + graph_2d_with_segmentation, + ndim=3, + **track_attrs, + ) + pix = tracks.get_pixels(1) + assert isinstance(pix, tuple) + UserUpdateSegmentation( + tracks, + new_value=99, + updated_pixels=[(pix, 0)], + current_track_id=6, + ) + assert np.asarray(tracks.segmentation)[0, 50, 50] == 99 + + def test_next_track_id_empty(): graph = create_empty_graphview_graph( node_attributes=["pos", "track_id"], diff --git a/tests/data_model/test_tracks.py b/tests/data_model/test_tracks.py index 4ca71e71..45b68158 100644 --- a/tests/data_model/test_tracks.py +++ b/tests/data_model/test_tracks.py @@ -4,6 +4,7 @@ import tracksdata as td from funtracks.data_model import Tracks +from funtracks.user_actions import UserUpdateSegmentation from funtracks.utils.tracksdata_utils import ( create_empty_graphview_graph, ) @@ -87,19 +88,6 @@ def test_create_tracks(graph_3d_with_segmentation: td.graph.GraphView): assert tracks.get_position(1) == [55, 56, 57] -def test_pixels_and_seg_id(graph_3d_with_segmentation): - # create track with graph and seg - tracks = Tracks( - graph=graph_3d_with_segmentation, - **track_attrs, - ) - - # changing a segmentation id changes it in the mapping - pix = tracks.get_pixels(1) - new_seg_id = 10 - tracks.set_pixels(pix, new_seg_id) - - def test_nodes_edges(graph_2d_with_segmentation): tracks = Tracks(graph_2d_with_segmentation, ndim=3, **track_attrs) assert set(tracks.nodes()) == {1, 2, 3, 4, 5, 6} @@ -196,18 +184,6 @@ def test_set_positions_list(graph_2d_list): ) -def test_get_pixels_and_set_pixels(graph_2d_with_segmentation): - tracks = Tracks( - graph_2d_with_segmentation, - ndim=3, - **track_attrs, - ) - pix = tracks.get_pixels(1) - assert isinstance(pix, tuple) - tracks.set_pixels(pix, 99) - assert np.asarray(tracks.segmentation)[0, 50, 50] == 99 - - def test_get_pixels_none(graph_2d_with_track_id): tracks = Tracks(graph_2d_with_track_id, ndim=3, **track_attrs) assert tracks.get_pixels(1) is None @@ -216,8 +192,14 @@ def test_get_pixels_none(graph_2d_with_track_id): def test_set_pixels_no_segmentation(graph_2d_with_track_id): tracks = Tracks(graph_2d_with_track_id, ndim=3, **track_attrs) pix = [(np.array([0]), np.array([10]), np.array([20]))] + # set_pixels no longer exist, so we use UserUpdateSegmentation with pytest.raises(ValueError): - tracks.set_pixels(pix, 1) + UserUpdateSegmentation( + tracks, + new_value=1, + updated_pixels=[(pix, 1)], + current_track_id=1, + ) def test_compute_ndim_errors(): From d04e1229d27a413349800d9148d3abffa29d1b7d Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Fri, 30 Jan 2026 09:28:32 -0800 Subject: [PATCH 37/44] update tracksdata to latest versions --- pyproject.toml | 2 +- src/funtracks/annotators/_regionprops_annotator.py | 1 - tests/annotators/test_regionprops_annotator.py | 5 +---- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 46916e7e..de075cec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies =[ "pandas>=2.3.3", "zarr>=2.18,<4", "numcodecs>=0.13,<0.16", - "tracksdata[spatial]@git+https://github.com/JoOkuma/tracksdata@cab9e2734d30bec681522a0ab2420dc825080fed", + "tracksdata[spatial]@git+https://github.com/JoOkuma/tracksdata@9b09154c1257b6b526389f7de606e050567d9601", # This will soon be main, and I will then update the commit hash ] diff --git a/src/funtracks/annotators/_regionprops_annotator.py b/src/funtracks/annotators/_regionprops_annotator.py index c7fe66d3..0c103a1b 100644 --- a/src/funtracks/annotators/_regionprops_annotator.py +++ b/src/funtracks/annotators/_regionprops_annotator.py @@ -231,7 +231,6 @@ def update(self, action: BasicAction): ) for key in keys_to_compute: value = None - # TODO Teun: this somehow goes wrong when pos is an array self.tracks._set_node_attr(node, key, value) else: mask = self.tracks.graph[node]["mask"] diff --git a/tests/annotators/test_regionprops_annotator.py b/tests/annotators/test_regionprops_annotator.py index 74729498..3bcb53da 100644 --- a/tests/annotators/test_regionprops_annotator.py +++ b/tests/annotators/test_regionprops_annotator.py @@ -1,6 +1,5 @@ import numpy as np import pytest -from tracksdata.utils._dtypes import infer_default_value_from_dtype from funtracks.actions import UpdateNodeSeg, UpdateTrackID from funtracks.annotators import RegionpropsAnnotator @@ -85,9 +84,7 @@ def test_update_all(self, get_graph, ndim): # all regionprops features should be the defaults, because seg doesn't exist for key in rp_ann.features: actual = tracks.graph[node_id][key] - expected = infer_default_value_from_dtype( - tracks.graph._node_attr_schemas()[key].dtype - ) + 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) From 9552a657c92b5c0cd8d544f5a6da85ba78581c54 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Fri, 30 Jan 2026 13:15:34 -0800 Subject: [PATCH 38/44] UserActions use pixels, but all underlying actions use masks. Trying to remove pixels as much as possible from the code --- src/funtracks/actions/add_delete_node.py | 46 ++++++++----------- src/funtracks/actions/update_segmentation.py | 32 +++++++------ src/funtracks/data_model/tracks.py | 17 +++++++ src/funtracks/user_actions/user_add_node.py | 4 +- .../user_actions/user_delete_node.py | 11 ++++- .../user_actions/user_update_segmentation.py | 11 +++-- src/funtracks/utils/tracksdata_utils.py | 32 ++++++++----- tests/actions/test_add_delete_nodes.py | 25 +++++----- tests/actions/test_update_node_segs.py | 8 ++-- tests/annotators/test_edge_annotator.py | 17 ++++--- .../annotators/test_regionprops_annotator.py | 12 +++-- tests/annotators/test_track_annotator.py | 4 +- tests/utils/test_tracksdata_utils.py | 8 +++- 13 files changed, 138 insertions(+), 89 deletions(-) diff --git a/src/funtracks/actions/add_delete_node.py b/src/funtracks/actions/add_delete_node.py index 205550dd..3127a8f6 100644 --- a/src/funtracks/actions/add_delete_node.py +++ b/src/funtracks/actions/add_delete_node.py @@ -8,20 +8,16 @@ from typing import Any from funtracks.data_model.solution_tracks import SolutionTracks - from funtracks.data_model.tracks import Node, SegMask + from funtracks.data_model.tracks import Node import numpy as np import tracksdata as td from tracksdata.nodes._mask import Mask -from funtracks.utils.tracksdata_utils import ( - pixels_to_td_mask, -) - class AddNode(BasicAction): """Action for adding new nodes. If a segmentation should also be added, the - pixels for each node should be provided. The label to set the pixels will + mask for the node should be provided. The label to set the mask will be taken from the node id. The existing pixel values are assumed to be zero - you must explicitly update any other segmentations that were overwritten using an UpdateNodes action if you want to be able to undo the action. @@ -32,7 +28,7 @@ def __init__( tracks: SolutionTracks, node: Node, attributes: dict[str, Any], - pixels: SegMask | None = None, + mask: Mask | None = None, ): """Create an action to add a new node, with optional segmentation @@ -40,12 +36,12 @@ def __init__( tracks (Tracks): The Tracks to add the node to node (Node): A node id attributes (Attrs): Includes times, track_ids, and optionally positions - pixels (SegMask | None, optional): The segmentation associated with + mask (Mask | None, optional): The segmentation mask associated with the node. Defaults to None. Raises: ValueError: If time attribute is not in attributes. ValueError: If track_id is not in attributes. - ValueError: If pixels is None and position is not in attributes. + ValueError: If mask is None and position is not in attributes. """ super().__init__(tracks) self.tracks: SolutionTracks # Narrow type from base class @@ -63,7 +59,7 @@ def __init__( raise ValueError(f"Must provide a {track_id_key} attribute for node {node}") # Check for position - handle both single key and list of keys - if pixels is None: + if mask is None: if isinstance(pos_key, list): # Multi-axis position keys if not all(key in attributes for key in pos_key): @@ -76,7 +72,7 @@ def __init__( raise ValueError( f"Must provide position or segmentation for node {node}" ) - self.pixels = pixels + self.mask = mask self.attributes = attributes self._apply() @@ -85,16 +81,13 @@ def inverse(self) -> BasicAction: return DeleteNode(self.tracks, self.node) def _apply(self) -> None: - """Apply the action, and set segmentation if provided in self.pixels""" + """Apply the action, and set segmentation if provided in self.mask""" attrs = self.attributes if self.tracks.segmentation is not None: - if self.pixels is not None: - mask_obj, _ = pixels_to_td_mask( - self.pixels, self.tracks.ndim, self.tracks.scale - ) - attrs[td.DEFAULT_ATTR_KEYS.MASK] = mask_obj - attrs[td.DEFAULT_ATTR_KEYS.BBOX] = mask_obj.bbox + if self.mask is not None: + attrs[td.DEFAULT_ATTR_KEYS.MASK] = self.mask + attrs[td.DEFAULT_ATTR_KEYS.BBOX] = self.mask.bbox else: # TODO Teun: remove this defaulting behavior, see new tracksdata PR if len(self.tracks.segmentation.shape) == 3: @@ -109,7 +102,7 @@ def _apply(self) -> None: attrs[td.DEFAULT_ATTR_KEYS.BBOX] = [0, 0, 0, 1, 1, 1] else: raise ValueError( - "Must provide pixels or mask when adding node to tracks with seg" + "Must provide mask when adding node to tracks with seg" ) # Position is already set in attrs above @@ -131,16 +124,16 @@ def _apply(self) -> None: class DeleteNode(BasicAction): - """Action of deleting existing nodes + """Action of deleting existing node If the tracks contain a segmentation, this action also constructs a reversible - operation for setting involved pixels to zero + operation for setting involved masks to zero """ def __init__( self, tracks: SolutionTracks, node: Node, - pixels: SegMask | None = None, + mask: Mask | None = None, ): super().__init__(tracks) self.tracks: SolutionTracks # Narrow type from base class @@ -164,21 +157,22 @@ def __init__( [self.node], td.DEFAULT_ATTR_KEYS.SOLUTION )[0] - self.pixels = self.tracks.get_pixels(node) if pixels is None else pixels + mask = self.tracks.get_mask(node) if mask is None else mask + + self.mask = mask self._apply() def inverse(self) -> BasicAction: """Invert this action, and provide inverse segmentation operation if given""" - return AddNode(self.tracks, self.node, self.attributes, pixels=self.pixels) + return AddNode(self.tracks, self.node, self.attributes, mask=self.mask) def _apply(self) -> None: """ASSUMES THERE ARE NO INCIDENT EDGES - raises valueerror if an edge will be removed by this operation Steps: - - For each node - set pixels to 0 if self.pixels is provided - Remove nodes from graph + - Update annotators """ self.tracks.graph.remove_node(self.node) diff --git a/src/funtracks/actions/update_segmentation.py b/src/funtracks/actions/update_segmentation.py index 21b6cd00..e31315a9 100644 --- a/src/funtracks/actions/update_segmentation.py +++ b/src/funtracks/actions/update_segmentation.py @@ -3,40 +3,41 @@ from typing import TYPE_CHECKING import tracksdata as td +from tracksdata.nodes._mask import Mask -from funtracks.utils.tracksdata_utils import pixels_to_td_mask +from funtracks.utils.tracksdata_utils import td_mask_to_pixels from ._base import BasicAction if TYPE_CHECKING: from funtracks.data_model import Tracks - from funtracks.data_model.tracks import Node, SegMask + from funtracks.data_model.tracks import Node class UpdateNodeSeg(BasicAction): """Action for updating the segmentation associated with a node. - New nodes call AddNode with pixels instead of this action. + New nodes call AddNode with mask instead of this action. """ def __init__( self, tracks: Tracks, node: Node, - pixels: SegMask, + mask: Mask, added: bool = True, ): """ Args: - tracks (Tracks): The tracks to update the segmenatations for - node (Node): The node with updated segmenatation - pixels (SegMask): The pixels that were updated for the node - added (bool, optional): If the provided pixels were added (True) or deleted + tracks (Tracks): The tracks to update the segmentations for + node (Node): The node with updated segmentation + mask (Mask): The mask that was updated for the node + added (bool, optional): If the provided mask were added (True) or deleted (False) from this node. Defaults to True """ super().__init__(tracks) self.node = node - self.pixels = pixels + self.mask = mask self.added = added self._apply() @@ -45,7 +46,7 @@ def inverse(self) -> BasicAction: return UpdateNodeSeg( self.tracks, self.node, - pixels=self.pixels, + mask=self.mask, added=not self.added, ) @@ -53,12 +54,10 @@ def _apply(self) -> None: """Set new attributes""" value = self.node if self.added else 0 - mask_new, area_new = pixels_to_td_mask( - self.pixels, self.tracks.ndim, self.tracks.scale - ) + mask_new = self.mask if value == 0: - # val=0 means deleting the pixels from the mask + # val=0 means deleting (part of) the mask mask_old = self.tracks.graph[self.node][td.DEFAULT_ATTR_KEYS.MASK] mask_subtracted = mask_old.__isub__(mask_new) self.tracks.graph.update_node_attrs( @@ -82,6 +81,9 @@ def _apply(self) -> None: ) # Invalidate cache for affected chunks - self.tracks._update_segmentation_cache(self.pixels) + pixels = td_mask_to_pixels( + mask_new, time=self.tracks.get_time(self.node), ndim=self.tracks.ndim + ) + self.tracks._update_segmentation_cache(pixels) self.tracks.notify_annotators(self) diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index 88b4d0f7..c4199dad 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -14,6 +14,7 @@ import tracksdata as td from psygnal import Signal from tracksdata.array import GraphArrayView +from tracksdata.nodes._mask import Mask from funtracks.features import Feature, FeatureDict, Position, Time from funtracks.utils.tracksdata_utils import ( @@ -444,6 +445,22 @@ def get_time(self, node: Node) -> int: """ return int(self.get_times([node])[0]) + def get_mask(self, node: Node) -> Mask | None: + """Get the segmentation mask associated with a given node. + + Args: + node (Node): The node to get the mask for. + + Returns: + Mask | None: The segmentation mask for the node, or None if no + segmentation is available. + """ + if self.segmentation is None: + return None + + mask = self.graph[node][td.DEFAULT_ATTR_KEYS.MASK] + return mask + def get_pixels(self, node: Node) -> tuple[np.ndarray, ...] | None: """Get the pixels corresponding to each node in the nodes list. diff --git a/src/funtracks/user_actions/user_add_node.py b/src/funtracks/user_actions/user_add_node.py index 89bf68ad..44b714a3 100644 --- a/src/funtracks/user_actions/user_add_node.py +++ b/src/funtracks/user_actions/user_add_node.py @@ -6,6 +6,7 @@ import numpy as np from funtracks.exceptions import InvalidActionError +from funtracks.utils.tracksdata_utils import pixels_to_td_mask from ..actions._base import ActionGroup from ..actions.add_delete_edge import AddEdge, DeleteEdge @@ -126,7 +127,8 @@ def __init__( if pred is not None and succ is not None: self.actions.append(DeleteEdge(tracks, (pred, succ))) # add predecessor and successor edges - self.actions.append(AddNode(tracks, node, attributes, pixels)) + mask = pixels_to_td_mask(pixels, self.tracks.ndim) if pixels is not None else None + self.actions.append(AddNode(tracks, node, attributes, mask)) if pred is not None: self.actions.append(AddEdge(tracks, (pred, node))) if succ is not None: diff --git a/src/funtracks/user_actions/user_delete_node.py b/src/funtracks/user_actions/user_delete_node.py index c5d5cec7..9e6284da 100644 --- a/src/funtracks/user_actions/user_delete_node.py +++ b/src/funtracks/user_actions/user_delete_node.py @@ -4,6 +4,10 @@ import numpy as np +from funtracks.utils.tracksdata_utils import ( + pixels_to_td_mask, +) + from ..actions._base import ActionGroup from ..actions.add_delete_edge import AddEdge, DeleteEdge from ..actions.add_delete_node import DeleteNode @@ -45,4 +49,9 @@ def __init__( self.actions.append(AddEdge(tracks, (predecessor, successor))) # delete node - self.actions.append(DeleteNode(tracks, node, pixels=pixels)) + mask = ( + pixels_to_td_mask(pixels, ndim=self.tracks.ndim) + if pixels is not None + else None + ) + self.actions.append(DeleteNode(tracks, node, mask=mask)) diff --git a/src/funtracks/user_actions/user_update_segmentation.py b/src/funtracks/user_actions/user_update_segmentation.py index c17e5a8f..73769f1f 100644 --- a/src/funtracks/user_actions/user_update_segmentation.py +++ b/src/funtracks/user_actions/user_update_segmentation.py @@ -52,15 +52,15 @@ def __init__( continue time = pixels[0][0] # check if all pixels of old_value are removed - mask_pixels, _ = pixels_to_td_mask( - pixels, self.tracks.ndim, self.tracks.scale - ) + mask_pixels = pixels_to_td_mask(pixels, self.tracks.ndim) mask_old_value = self.tracks.graph[old_value]["mask"] # If pixels fully overlaps with old_value mask, delete node if mask_pixels.intersection(mask_old_value) == mask_old_value.mask.sum(): self.actions.append(UserDeleteNode(tracks, old_value, pixels=pixels)) else: - self.actions.append(UpdateNodeSeg(tracks, old_value, pixels, added=False)) + self.actions.append( + UpdateNodeSeg(tracks, old_value, mask_pixels, added=False) + ) if new_value != 0: all_pixels = tuple( np.concatenate([pixels[dim] for pixels, _ in updated_pixels]) @@ -71,8 +71,9 @@ def __init__( ) time = all_pixels[0][0] if self.tracks.graph.has_node(new_value): + mask_pixels = pixels_to_td_mask(all_pixels, self.tracks.ndim) self.actions.append( - UpdateNodeSeg(tracks, new_value, all_pixels, added=True) + UpdateNodeSeg(tracks, new_value, mask_pixels, added=True) ) else: time_key = tracks.features.time_key diff --git a/src/funtracks/utils/tracksdata_utils.py b/src/funtracks/utils/tracksdata_utils.py index ccdcb913..30fc3b50 100644 --- a/src/funtracks/utils/tracksdata_utils.py +++ b/src/funtracks/utils/tracksdata_utils.py @@ -302,7 +302,7 @@ def compute_node_attrs_from_pixels( # Convert pixels to masks first masks = [] for pix in pixels: - mask, _ = pixels_to_td_mask(pix, ndim, scale) + mask = pixels_to_td_mask(pix, ndim) masks.append(mask) # Reuse the from_masks function to compute attributes @@ -310,8 +310,11 @@ def compute_node_attrs_from_pixels( def pixels_to_td_mask( - pix: tuple[np.ndarray, ...], ndim: int, scale: list[float] | None -) -> tuple[Mask, float]: + pix: tuple[np.ndarray, ...], + ndim: int, + scale: list[float] | None = None, + include_area: bool = False, +) -> Mask | tuple[Mask, float]: """ Convert pixel coordinates to tracksdata mask format. @@ -319,12 +322,17 @@ def pixels_to_td_mask( pix: Pixel coordinates for 1 node! ndim: Number of dimensions (2D or 3D). scale: Scale factors for each dimension, used for area calculation + include_area: Whether to compute and return the area. Returns: - Tuple[td.Mask, np.ndarray]: A tuple containing the - tracksdata mask and the mask array. + Mask | tuple[Mask, float]: A tuple containing the + tracksdata mask and the area if include_area is True. + Otherwise, just the tracksdata mask. """ + if include_area and scale is None: + raise ValueError("Scale must be provided when include_area is True.") + spatial_dims = ndim - 1 # Handle both 2D and 3D # Calculate position and bounding box more efficiently @@ -345,13 +353,15 @@ def pixels_to_td_mask( local_coords = [pix[dim + 1] - bbox[dim] for dim in range(spatial_dims)] mask_array = np.zeros(mask_shape, dtype=bool) mask_array[tuple(local_coords)] = True - - area = np.sum(mask_array) - if scale is not None: - area *= np.prod(scale[1:]) - mask = Mask(mask_array, bbox=bbox) - return mask, area + + if include_area: + area = np.sum(mask_array) + if scale is not None: + area *= np.prod(scale[1:]) + return mask, area + else: + return mask def td_mask_to_pixels(mask: Mask, time: int, ndim: int) -> tuple[np.ndarray, ...]: diff --git a/tests/actions/test_add_delete_nodes.py b/tests/actions/test_add_delete_nodes.py index 8206250c..8fcb87b6 100644 --- a/tests/actions/test_add_delete_nodes.py +++ b/tests/actions/test_add_delete_nodes.py @@ -11,7 +11,7 @@ from funtracks.utils.tracksdata_utils import ( assert_node_attrs_equal_with_masks, create_empty_graphview_graph, - td_mask_to_pixels, + pixels_to_td_mask, ) from ..conftest import make_2d_disk_mask, make_3d_sphere_mask @@ -53,7 +53,11 @@ def test_add_delete_nodes(get_tracks, ndim, with_seg): actions = [] for node in nodes: - pixels = np.nonzero(reference_seg == node) if with_seg else None + if with_seg: + pixels = np.nonzero(reference_seg == node) + mask = pixels_to_td_mask(pixels, ndim=ndim) + else: + mask = None attrs = {} attrs[tracks.features.time_key] = reference_graph[node][tracks.features.time_key] @@ -72,7 +76,7 @@ def test_add_delete_nodes(get_tracks, ndim, with_seg): attrs["bbox"] = reference_graph[node]["bbox"] attrs["mask"] = reference_graph[node]["mask"] - actions.append(AddNode(tracks, node, attributes=attrs, pixels=pixels)) + actions.append(AddNode(tracks, node, attributes=attrs, mask=mask)) action = ActionGroup(tracks=tracks, actions=actions) assert set(tracks.graph.node_ids()) == set(reference_graph.node_ids()) @@ -187,22 +191,19 @@ def test_custom_attributes_preserved(get_tracks, ndim, with_seg): if with_seg: if ndim == 3: # Create 2D mask centered at (50, 50) with radius 5 - mask_obj = make_2d_disk_mask(center=(50, 50), radius=5) - pixels = td_mask_to_pixels(mask_obj, time=custom_attrs["t"], ndim=ndim) + mask = make_2d_disk_mask(center=(50, 50), radius=5) else: # Create proper 4D pixel coordinates (t, z, y, x) - mask_obj = make_3d_sphere_mask(center=(50, 50, 50), radius=5) - pixels = td_mask_to_pixels(mask_obj, time=custom_attrs["t"], ndim=ndim) - custom_attrs["mask"] = mask_obj - custom_attrs["bbox"] = mask_obj.bbox + mask = make_3d_sphere_mask(center=(50, 50, 50), radius=5) + custom_attrs["mask"] = mask + custom_attrs["bbox"] = mask.bbox custom_attrs.pop("pos") # pos will be computed from segmentation else: - pixels = None + mask = None # Add a node with custom attributes node_id = 100 - action = AddNode(tracks, node_id, custom_attrs.copy(), pixels=pixels) - + action = AddNode(tracks, node_id, custom_attrs.copy(), mask=mask) # Verify all attributes are present after adding assert tracks.graph.has_node(node_id) for key, value in custom_attrs.items(): diff --git a/tests/actions/test_update_node_segs.py b/tests/actions/test_update_node_segs.py index 1eede865..637b5565 100644 --- a/tests/actions/test_update_node_segs.py +++ b/tests/actions/test_update_node_segs.py @@ -3,9 +3,8 @@ from numpy.testing import assert_array_almost_equal from polars.testing import assert_series_equal -from funtracks.actions import ( - UpdateNodeSeg, -) +from funtracks.actions import UpdateNodeSeg +from funtracks.utils.tracksdata_utils import pixels_to_td_mask @pytest.mark.parametrize("ndim", [3, 4]) @@ -27,7 +26,8 @@ def test_update_node_segs(get_tracks, ndim): node = 1 pixels = np.nonzero(original_seg != new_seg) - action = UpdateNodeSeg(tracks, node, pixels=pixels, added=True) + mask = pixels_to_td_mask(pixels, ndim=ndim) + action = UpdateNodeSeg(tracks, node, mask=mask, added=True) assert set(tracks.graph.node_ids()) == set(reference_graph.node_ids()) assert tracks.graph[1]["area"] == original_area + 1 diff --git a/tests/annotators/test_edge_annotator.py b/tests/annotators/test_edge_annotator.py index ce7ede03..a567c43e 100644 --- a/tests/annotators/test_edge_annotator.py +++ b/tests/annotators/test_edge_annotator.py @@ -3,7 +3,10 @@ from funtracks.actions import UpdateNodeSeg, UpdateTrackID from funtracks.annotators import EdgeAnnotator from funtracks.data_model import SolutionTracks, Tracks -from funtracks.utils.tracksdata_utils import td_get_single_attr_from_edge +from funtracks.utils.tracksdata_utils import ( + pixels_to_td_mask, + td_get_single_attr_from_edge, +) track_attrs = {"time_attr": "t", "tracklet_attr": "track_id"} @@ -65,15 +68,16 @@ def test_update_all(self, get_graph, ndim) -> None: expected_iou = pytest.approx(0.0, abs=0.001) # Use UpdateNodeSeg action to modify segmentation and update edge - UpdateNodeSeg(tracks, node_id, pixels_to_remove, added=False) + mask_to_remove = pixels_to_td_mask(pixels_to_remove, ndim=ndim) + UpdateNodeSeg(tracks, node_id, mask_to_remove, added=False) assert tracks.get_edge_attr(edge_id, "iou", required=True) == expected_iou # segmentation is fully erased and you try to update node_id = 1 - pixels = tracks.get_pixels(node_id) - assert pixels is not None + 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, pixels, added=False) + UpdateNodeSeg(tracks, node_id, mask, added=False) assert td_get_single_attr_from_edge(tracks.graph, edge_id, "iou") == 0 @@ -110,7 +114,8 @@ def test_add_remove_feature(self, get_graph, ndim): # add it back in tracks.enable_features([to_remove_key]) # Use UpdateNodeSeg action to modify segmentation and update edge - UpdateNodeSeg(tracks, node_id, pixels_to_remove, added=False) + mask_to_remove = pixels_to_td_mask(pixels_to_remove, ndim=ndim) + UpdateNodeSeg(tracks, node_id, mask_to_remove, 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, required=True) == new_iou diff --git a/tests/annotators/test_regionprops_annotator.py b/tests/annotators/test_regionprops_annotator.py index 3bcb53da..adb04076 100644 --- a/tests/annotators/test_regionprops_annotator.py +++ b/tests/annotators/test_regionprops_annotator.py @@ -4,6 +4,7 @@ from funtracks.actions import UpdateNodeSeg, UpdateTrackID from funtracks.annotators import RegionpropsAnnotator from funtracks.data_model import SolutionTracks, Tracks +from funtracks.utils.tracksdata_utils import pixels_to_td_mask track_attrs = {"time_attr": "t", "tracklet_attr": "track_id"} @@ -65,22 +66,22 @@ def test_update_all(self, get_graph, ndim): orig_pixels = tracks.get_pixels(node_id) # remove all but one pixel pixels_to_remove = tuple(orig_pixels[d][1:] for d in range(len(orig_pixels))) + mask_to_remove = pixels_to_td_mask(pixels_to_remove, ndim=ndim) expected_area = 1 # Use UpdateNodeSeg action to modify segmentation and update features - UpdateNodeSeg(tracks, node_id, pixels_to_remove, added=False) + UpdateNodeSeg(tracks, node_id, mask_to_remove, 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 - pixels = tracks.get_pixels(node_id) + 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, pixels, added=False) - + 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[node_id][key] @@ -122,8 +123,9 @@ def test_add_remove_feature(self, get_graph, ndim): orig_pixels = tracks.get_pixels(node_id) assert orig_pixels is not None pixels_to_remove = tuple(orig_pixels[d][1:] for d in range(len(orig_pixels))) + mask_to_remove = pixels_to_td_mask(pixels_to_remove, ndim=ndim) # Use UpdateNodeSeg action to modify segmentation and update features - UpdateNodeSeg(tracks, node_id, pixels_to_remove, added=False) + UpdateNodeSeg(tracks, node_id, mask_to_remove, added=False) # the one we added back in is now present assert tracks.get_node_attr(node_id, to_remove_key) is not None diff --git a/tests/annotators/test_track_annotator.py b/tests/annotators/test_track_annotator.py index c3eaf6bb..439cfbaa 100644 --- a/tests/annotators/test_track_annotator.py +++ b/tests/annotators/test_track_annotator.py @@ -2,6 +2,7 @@ from funtracks.actions import UpdateNodeSeg from funtracks.annotators import TrackAnnotator +from funtracks.utils.tracksdata_utils import pixels_to_td_mask @pytest.mark.parametrize("ndim", [3, 4]) @@ -113,9 +114,10 @@ def test_ignores_irrelevant_actions(self, get_tracks, ndim, with_seg): orig_pixels = tracks.get_pixels(node_id) assert orig_pixels is not None pixels_to_remove = tuple(orig_pixels[d][1:] for d in range(len(orig_pixels))) + mask_to_remove = pixels_to_td_mask(pixels_to_remove, ndim=ndim) # Perform UpdateNodeSeg action - UpdateNodeSeg(tracks, node_id, pixels_to_remove, added=False) + UpdateNodeSeg(tracks, node_id, mask_to_remove, added=False) # Track ID should remain unchanged (no track update happened) assert tracks.get_track_id(node_id) == initial_track_id diff --git a/tests/utils/test_tracksdata_utils.py b/tests/utils/test_tracksdata_utils.py index 5d6d0a06..6f36ffd2 100644 --- a/tests/utils/test_tracksdata_utils.py +++ b/tests/utils/test_tracksdata_utils.py @@ -40,7 +40,9 @@ def test_mask_pixels_roundtrip(mask_func, ndim): 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=None) + 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), ( @@ -69,7 +71,9 @@ def test_mask_pixels_roundtrip_with_scale(ndim): 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) + 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) From 3d68dfe827b7bf09ab2b132d0e93589fbfc11dd7 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Fri, 30 Jan 2026 16:32:25 -0800 Subject: [PATCH 39/44] removed todo --- src/funtracks/data_model/tracks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index c4199dad..392d9c4b 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -496,6 +496,7 @@ def _update_segmentation_cache(self, pixels: tuple[np.ndarray, ...]) -> None: pixels: Tuple of arrays representing pixel coordinates (time, z, y, x) or (time, y, x), formatted like the output of np.nonzero. """ + # TODO Teun: ideally should work on the mask, not on pixels if self.segmentation is None: return From eb72ccbf25ce2d2574879fa9257b0123e733c27e Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Fri, 30 Jan 2026 17:28:11 -0800 Subject: [PATCH 40/44] remove default stuff from AddNode --- src/funtracks/actions/add_delete_node.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/funtracks/actions/add_delete_node.py b/src/funtracks/actions/add_delete_node.py index 3127a8f6..eab34a64 100644 --- a/src/funtracks/actions/add_delete_node.py +++ b/src/funtracks/actions/add_delete_node.py @@ -105,19 +105,7 @@ def _apply(self) -> None: "Must provide mask when adding node to tracks with seg" ) - # Position is already set in attrs above - # Add nodes to td graph - required_attrs = self.tracks.graph.node_attr_keys().copy() - if td.DEFAULT_ATTR_KEYS.NODE_ID in required_attrs: - required_attrs.remove(td.DEFAULT_ATTR_KEYS.NODE_ID) - if td.DEFAULT_ATTR_KEYS.SOLUTION not in attrs: - attrs[td.DEFAULT_ATTR_KEYS.SOLUTION] = 1 - for attr in required_attrs: - if attr not in attrs: - # TODO Teun: remove this logic when td has default values (PR) - attrs[attr] = self.tracks.features[attr]["default_value"] - - self.tracks.graph.add_node(attrs=attrs, index=self.node, validate_keys=True) + self.tracks.graph.add_node(attrs=attrs, index=self.node, validate_keys=False) # Always notify annotators - they will check their own preconditions self.tracks.notify_annotators(self) From 6d48a8c7db6fbd1a6ab82d51007f4d82425d4c31 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Fri, 30 Jan 2026 17:35:29 -0800 Subject: [PATCH 41/44] make update_segmentation_cache take a mask, not pixels --- src/funtracks/actions/update_segmentation.py | 8 +- src/funtracks/data_model/tracks.py | 80 +++++++++----------- 2 files changed, 38 insertions(+), 50 deletions(-) diff --git a/src/funtracks/actions/update_segmentation.py b/src/funtracks/actions/update_segmentation.py index e31315a9..2eef9ef0 100644 --- a/src/funtracks/actions/update_segmentation.py +++ b/src/funtracks/actions/update_segmentation.py @@ -5,8 +5,6 @@ import tracksdata as td from tracksdata.nodes._mask import Mask -from funtracks.utils.tracksdata_utils import td_mask_to_pixels - from ._base import BasicAction if TYPE_CHECKING: @@ -81,9 +79,7 @@ def _apply(self) -> None: ) # Invalidate cache for affected chunks - pixels = td_mask_to_pixels( - mask_new, time=self.tracks.get_time(self.node), ndim=self.tracks.ndim - ) - self.tracks._update_segmentation_cache(pixels) + time = self.tracks.get_time(self.node) + self.tracks._update_segmentation_cache(mask=mask_new, time=time) self.tracks.notify_annotators(self) diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index 392d9c4b..7260f328 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -489,59 +489,51 @@ def get_pixels(self, node: Node) -> tuple[np.ndarray, ...] | None: return (time_array, *global_coords) - def _update_segmentation_cache(self, pixels: tuple[np.ndarray, ...]) -> None: - """Invalidate cached chunks that overlap with the given pixels. + def _update_segmentation_cache(self, mask: td.Mask, time: int) -> None: + """Invalidate cached chunks that overlap with the given mask. Args: - pixels: Tuple of arrays representing pixel coordinates (time, z, y, x) - or (time, y, x), formatted like the output of np.nonzero. + mask: Mask object with .bbox attribute defining the affected region + time: Time point of the mask """ - # TODO Teun: ideally should work on the mask, not on pixels if self.segmentation is None: return cache = self.segmentation._cache - time_coords = pixels[0] - spatial_coords = pixels[1:] - - # For each unique time point - for time in np.unique(time_coords): - time = int(time) - - # Only invalidate if this time point is in the cache - if time not in cache._store: - continue - - # Get pixels at this time point and create bounding box slices - time_mask = time_coords == time - volume_slicing = tuple( - slice( - int(dim_coords[time_mask].min()), int(dim_coords[time_mask].max()) + 1 - ) - for dim_coords in spatial_coords - ) - # Use cache's method to get chunk bounds (same logic as cache.get()) - bounds = cache._chunk_bounds(volume_slicing) - chunk_ranges = [range(lo, hi + 1) for lo, hi in bounds] - - # Invalidate all affected chunks - cache_entry = cache._store[time] - for chunk_idx in itertools.product(*chunk_ranges): - if all( - 0 <= idx < grid_size - for idx, grid_size in zip(chunk_idx, cache.grid_shape, strict=True) - ): - cache_entry.ready[chunk_idx] = False - # Clear the buffer to ensure stale data isn't used - # when the chunk is recomputed - chunk_slc = tuple( - slice(ci * cs, min((ci + 1) * cs, fs)) - for ci, cs, fs in zip( - chunk_idx, cache.chunk_shape, cache.shape, strict=True - ) + # Only invalidate if this time point is in the cache + if time not in cache._store: + return + + # Convert bbox to slices directly + # bbox format: [z_min, y_min, x_min, z_max, y_max, x_max] (3D) + # or [y_min, x_min, y_max, x_max] (2D) + ndim = len(mask.bbox) // 2 + volume_slicing = tuple( + slice(mask.bbox[i], mask.bbox[i + ndim] + 1) for i in range(ndim) + ) + + # Use cache's method to get chunk bounds (same logic as cache.get()) + bounds = cache._chunk_bounds(volume_slicing) + chunk_ranges = [range(lo, hi + 1) for lo, hi in bounds] + + # Invalidate all affected chunks + cache_entry = cache._store[time] + for chunk_idx in itertools.product(*chunk_ranges): + if all( + 0 <= idx < grid_size + for idx, grid_size in zip(chunk_idx, cache.grid_shape, strict=True) + ): + cache_entry.ready[chunk_idx] = False + # Clear the buffer to ensure stale data isn't used + # when the chunk is recomputed + chunk_slc = tuple( + slice(ci * cs, min((ci + 1) * cs, fs)) + for ci, cs, fs in zip( + chunk_idx, cache.chunk_shape, cache.shape, strict=True ) - cache_entry.buffer[chunk_slc] = 0 + ) + cache_entry.buffer[chunk_slc] = 0 def _compute_ndim( self, From 3d7bca92d43797e246de86c8e1eb35b703d649c3 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Fri, 30 Jan 2026 17:52:31 -0800 Subject: [PATCH 42/44] remove unnecessary utils functions regarding node attributes from pixels/masks --- src/funtracks/utils/tracksdata_utils.py | 82 ------------------------- 1 file changed, 82 deletions(-) diff --git a/src/funtracks/utils/tracksdata_utils.py b/src/funtracks/utils/tracksdata_utils.py index 30fc3b50..b176ebd8 100644 --- a/src/funtracks/utils/tracksdata_utils.py +++ b/src/funtracks/utils/tracksdata_utils.py @@ -9,7 +9,6 @@ import scipy.ndimage as ndi import tracksdata as td from polars.testing import assert_frame_equal -from skimage import measure from tracksdata.nodes._mask import Mask @@ -228,87 +227,6 @@ def assert_node_attrs_equal_with_masks( assert np.array_equal(mask1.mask, mask2.mask) -def compute_node_attrs_from_masks( - masks: list[Mask], ndim: int, scale: list[float] | None -) -> dict[str, list[Any]]: - """ - Compute node attributes (area and pos) from a tracksdata Mask object. - - Parameters - ---------- - masks : list[Mask] - A list of tracksdata Mask objects containing the mask and bounding box. - ndim : int - Number of dimensions (2D or 3D). - scale : list[float] | None - Scale factors for each dimension. - - Returns - ------- - dict[str, Any] - A dictionary containing the computed node attributes ('area' and 'pos'). - """ - if not masks: - return {} - - area_list = [] - pos_list = [] - for mask in masks: - seg_crop = mask.mask - seg_bbox = mask.bbox - - pos_scale = scale[1:] if scale is not None else np.ones(ndim - 1) - area = np.sum(seg_crop) - if pos_scale is not None: - area *= np.prod(pos_scale) - area_list.append(float(area)) - - # Calculate position - use centroid if area > 0, otherwise use bbox center - if area > 0: - pos = measure.centroid(seg_crop, spacing=pos_scale) # type: ignore - pos += seg_bbox[: ndim - 1] * (pos_scale if pos_scale is not None else 1) - else: - # Use bbox center when area is 0 - pos = np.array( - [(seg_bbox[d] + seg_bbox[d + ndim - 1]) / 2 for d in range(ndim - 1)] - ) - pos_list.append(pos) - - return {"area": area_list, "pos": pos_list} - - -def compute_node_attrs_from_pixels( - pixels: list[tuple[np.ndarray, ...]] | None, ndim: int, scale: list[float] | None -) -> dict[str, list[Any]]: - """ - Compute node attributes (area and pos) from pixel coordinates. - Parameters - ---------- - pixels : list[tuple[np.ndarray, ...]] - List of pixel coordinates for each node. - ndim : int - Number of dimensions (2D or 3D). - scale : list[float] | None - Scale factors for each dimension. - - Returns - ------- - dict[str, list[Any]] - A dictionary containing the computed node attributes ('area' and 'pos'). - """ - if pixels is None: - return {} - - # Convert pixels to masks first - masks = [] - for pix in pixels: - mask = pixels_to_td_mask(pix, ndim) - masks.append(mask) - - # Reuse the from_masks function to compute attributes - return compute_node_attrs_from_masks(masks, ndim, scale) - - def pixels_to_td_mask( pix: tuple[np.ndarray, ...], ndim: int, From 0b2b8592928a11c4d1b4db281654bf4aa5ad2e25 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Sat, 31 Jan 2026 15:54:36 -0800 Subject: [PATCH 43/44] improve coverage of tracks.py --- tests/actions/test_update_node_segs.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/actions/test_update_node_segs.py b/tests/actions/test_update_node_segs.py index 637b5565..a7a8bf3f 100644 --- a/tests/actions/test_update_node_segs.py +++ b/tests/actions/test_update_node_segs.py @@ -13,6 +13,16 @@ def test_update_node_segs(get_tracks, ndim): 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) + + # Populate the cache by accessing segmentation at the node's time + # This ensures _update_segmentation_cache will test the cache invalidation logic + _ = np.asarray(tracks.segmentation[time]) + + # Verify cache is populated + assert time in tracks.segmentation._cache._store + original_seg = np.asarray(tracks.segmentation).copy() original_area = tracks.graph[1]["area"] original_pos = tracks.graph[1]["pos"] @@ -20,13 +30,13 @@ def test_update_node_segs(get_tracks, ndim): # Add a couple pixels to the first node new_seg = np.asarray(tracks.segmentation).copy() if ndim == 3: - new_seg[0][0][0] = 1 # 2D spatial + new_seg[time][0][0] = node # Use node time and node ID else: - new_seg[0][0][0][0] = 1 # 3D spatial - node = 1 + new_seg[time][0][0][0] = node # Use node time and node ID pixels = np.nonzero(original_seg != new_seg) mask = pixels_to_td_mask(pixels, ndim=ndim) + action = UpdateNodeSeg(tracks, node, mask=mask, added=True) assert set(tracks.graph.node_ids()) == set(reference_graph.node_ids()) @@ -34,6 +44,9 @@ def test_update_node_segs(get_tracks, ndim): assert not np.allclose(tracks.graph[1]["pos"], original_pos) assert_array_almost_equal(tracks.segmentation, new_seg) + # Re-populate cache for inverse action test + _ = np.asarray(tracks.segmentation[time]) + inverse = action.inverse() assert set(tracks.graph.node_ids()) == set(reference_graph.node_ids()) assert_series_equal( @@ -42,6 +55,9 @@ def test_update_node_segs(get_tracks, ndim): ) assert_array_almost_equal(tracks.segmentation, original_seg) + # Re-populate cache for second inverse test + _ = np.asarray(tracks.segmentation[time]) + inverse.inverse() assert set(tracks.graph.node_ids()) == set(reference_graph.node_ids()) From 2d32f3087db18c29b9588e623184a7e8f13144f6 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Mon, 2 Feb 2026 13:42:41 -0800 Subject: [PATCH 44/44] update Teun Todos --- src/funtracks/actions/add_delete_node.py | 1 + src/funtracks/utils/tracksdata_utils.py | 9 +-------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/funtracks/actions/add_delete_node.py b/src/funtracks/actions/add_delete_node.py index eab34a64..c47a3c42 100644 --- a/src/funtracks/actions/add_delete_node.py +++ b/src/funtracks/actions/add_delete_node.py @@ -90,6 +90,7 @@ def _apply(self) -> None: attrs[td.DEFAULT_ATTR_KEYS.BBOX] = self.mask.bbox else: # TODO Teun: remove this defaulting behavior, see new tracksdata PR + # update: default behaviour in td has a bug rn, will remove later if len(self.tracks.segmentation.shape) == 3: attrs[td.DEFAULT_ATTR_KEYS.MASK] = Mask( np.array([[False]]), bbox=[0, 0, 1, 1] diff --git a/src/funtracks/utils/tracksdata_utils.py b/src/funtracks/utils/tracksdata_utils.py index b176ebd8..62010903 100644 --- a/src/funtracks/utils/tracksdata_utils.py +++ b/src/funtracks/utils/tracksdata_utils.py @@ -417,14 +417,7 @@ def add_masks_and_bboxes_to_graph( def td_get_single_attr_from_edge(graph, edge: tuple[int, int], attrs: Sequence[str]): """Get a single attribute from a edge in a tracksdata graph.""" - # TODO Teun: later opdate to: - # edge_id = graph.edge_id(edge[0], edge[1]) - # item = graph.edge_attrs(attr_keys=attrs).filter(pl.col("edge_id") == edge_id) - # .select(attrs).item()once tracksdata supports default values. Right now, polars - # crashes when the edge attributes have different types. We either need a - # df = pl.DataFrame(data, strict=False).with_columns(... in line 171 in - # tracksdata/graph/rx, or tracksdata needs to support default values for missing - # attributes. The implementation below can be slow for large graphs. + # TODO Teun: later opdate to: graph.edges[edge_id][attr] (after td update) item = graph.filter(node_ids=[edge[0], edge[1]]).edge_attrs()[attrs].item() return item