Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ dependencies =[
"dask>=2025.5.0",
"pandas>=2.3.3",
"zarr>=2.18,<4",
"tracksdata[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
Expand Down
10 changes: 8 additions & 2 deletions src/funtracks/actions/update_segmentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
27 changes: 27 additions & 0 deletions tests/actions/test_update_node_segs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion tests/data_model/test_solution_tracks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading