diff --git a/pyproject.toml b/pyproject.toml index bc7e72c3..92f12d26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dependencies =[ "dask>=2025.5.0", "pandas>=2.3.3", "zarr>=2.18,<4", - "tracksdata[spatial]>=0.1.0rc6", + "tracksdata[spatial]>=0.1.0rc7", "tqdm>=4.66.1", # zarr 2.x's util.py imports cbuffer_sizes/cbuffer_metainfo from # numcodecs.blosc, which numcodecs >= 0.16 removed. Pin numcodecs per diff --git a/src/funtracks/actions/update_segmentation.py b/src/funtracks/actions/update_segmentation.py index 4d83a158..56ccb152 100644 --- a/src/funtracks/actions/update_segmentation.py +++ b/src/funtracks/actions/update_segmentation.py @@ -60,9 +60,15 @@ def _apply(self) -> None: mask_new = self.mask if value == 0: - # val=0 means deleting (part of) the mask + # val=0 means deleting (part of) the mask. + # Mask.__isub__ subtracts *in place* and returns the same object, which + # would mutate the mask still stored in the graph. Downstream consumers + # (e.g. GraphArrayView's cache invalidation) diff the graph's previous + # mask against the new one, so the previous mask must stay intact. + # Subtract on a fresh copy so the graph receives a distinct new object. mask_old = self.tracks.graph.nodes[self.node][self.mask_key] - mask_subtracted = mask_old.__isub__(mask_new) + mask_subtracted = Mask(mask_old.mask.copy(), bbox=mask_old.bbox.copy()) + mask_subtracted -= mask_new self.tracks.update_mask(self.node, mask_subtracted, mask_key=self.mask_key) elif self.tracks.graph.has_node(value): diff --git a/tests/actions/test_update_node_segs.py b/tests/actions/test_update_node_segs.py index 73a98af4..d3801e65 100644 --- a/tests/actions/test_update_node_segs.py +++ b/tests/actions/test_update_node_segs.py @@ -50,3 +50,30 @@ def test_update_node_segs(get_tracks, ndim): assert tracks.graph.nodes[1]["area"] == original_area + 1 assert not np.allclose(tracks.graph.nodes[1]["pos"], original_pos) assert_array_almost_equal(tracks.segmentation, new_seg) + + +def test_update_node_segs_erase_interior_pixel(get_tracks): + """Erasing a pixel *interior* to a node must clear it in the segmentation view. + + Node 1 is a disk centred at (50, 50); erasing its centre pixel does not shrink + the bbox, so it exercises exactly the bbox-unchanged path that regressed. + """ + tracks = get_tracks(ndim=3, with_seg=True, is_solution=True) + node = 1 + time = tracks.get_time(node) + + original_bbox = np.asarray(tracks.graph.nodes[node]["bbox"]).copy() + original_area = tracks.graph.nodes[node]["area"] + # Precondition: the interior pixel currently belongs to the node. + assert int(np.asarray(tracks.segmentation[time, 50, 50])) == node + + # Erase the single interior pixel (50, 50). + erase_mask = Mask(np.ones((1, 1), dtype=bool), np.array([50, 50, 51, 51])) + UpdateNodeSeg(tracks, node, mask=erase_mask, added=False) + + # The bbox must be unchanged (the erased pixel was interior), which is the + # condition under which the stale-readback bug manifested. + assert np.array_equal(np.asarray(tracks.graph.nodes[node]["bbox"]), original_bbox) + assert tracks.graph.nodes[node]["area"] == original_area - 1 + # The segmentation view must reflect the erase. + assert int(np.asarray(tracks.segmentation[time, 50, 50])) == 0 diff --git a/tests/data_model/test_solution_tracks.py b/tests/data_model/test_solution_tracks.py index 063fba80..42d51e64 100644 --- a/tests/data_model/test_solution_tracks.py +++ b/tests/data_model/test_solution_tracks.py @@ -44,7 +44,10 @@ def test_from_tracks_cls(graph_2d_with_segmentation): ) solution_tracks = SolutionTracks.from_tracks(tracks) assert solution_tracks.graph == tracks.graph - assert solution_tracks.segmentation == tracks.segmentation + # from_tracks reuses the same segmentation instance. Assert identity rather + # than `==`: on newer tracksdata GraphArrayView.__eq__ is element-wise and + # returns an array, which makes a truthiness assert ambiguous. + assert solution_tracks.segmentation is tracks.segmentation assert solution_tracks.features.time_key == tracks.features.time_key assert solution_tracks.features.position_key == tracks.features.position_key assert solution_tracks.scale == tracks.scale