Skip to content

Commit 88d777c

Browse files
gitttt-1234claude
andauthored
Fix skeleton node removal not updating instance point data (#2621)
When deleting nodes from a skeleton via the GUI, the DeleteNode command was calling skeleton.remove_node() directly, which only updates the skeleton but does NOT update instance point arrays. This caused file corruption where saved .slp files couldn't be reopened due to a mismatch between skeleton node count and instance point count. The fix uses Labels.remove_nodes() which properly calls Instance.update_skeleton() on all instances, ensuring point arrays are updated to match the modified skeleton. Fixes GitHub Discussion #2500. Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 03ac1fa commit 88d777c

File tree

2 files changed

+107
-1
lines changed

2 files changed

+107
-1
lines changed

sleap/gui/commands.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3158,7 +3158,14 @@ class DeleteNode(EditCommand):
31583158
@staticmethod
31593159
def do_action(context: CommandContext, params: dict):
31603160
node = context.state["selected_node"]
3161-
context.state["skeleton"].remove_node(node)
3161+
skeleton = context.state["skeleton"]
3162+
3163+
if context.labels is not None:
3164+
# Use Labels.remove_nodes() to properly update all instances
3165+
context.labels.remove_nodes([node], skeleton=skeleton)
3166+
else:
3167+
# Fallback when no labels (e.g., skeleton-only editing)
3168+
skeleton.remove_node(node)
31623169

31633170

31643171
class SetNodeName(EditCommand):

tests/gui/test_commands.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from sleap.gui.commands import (
2121
AddInstance,
2222
CommandContext,
23+
DeleteNode,
2324
ExportAnalysisFile,
2425
ExportDatasetWithImages,
2526
ExportFullPackage,
@@ -611,6 +612,104 @@ def OpenSkeleton_ask(context: CommandContext, params: dict) -> bool:
611612
assert_skeletons_match(labels.skeleton, fly32_skeleton)
612613

613614

615+
def test_DeleteNode_updates_instances(centered_pair_predictions: Labels, tmpdir):
616+
"""Test that DeleteNode properly updates instance point data.
617+
618+
This is a regression test for GitHub Discussion #2500 where removing nodes from
619+
a skeleton did not update instance point data, causing file corruption that
620+
prevented the file from being loaded.
621+
622+
The bug occurred because DeleteNode called skeleton.remove_node() directly,
623+
which only updates the skeleton but NOT the instance point arrays. The fix
624+
is to use Labels.remove_nodes() which properly calls Instance.update_skeleton()
625+
to update all instances.
626+
"""
627+
from sleap.sleap_io_adaptors.lf_labels_utils import labels_copy, labels_load_file
628+
629+
# Create a copy to avoid mutating the fixture
630+
labels = labels_copy(centered_pair_predictions)
631+
632+
# Get original state
633+
original_skeleton = labels.skeleton
634+
original_num_nodes = len(original_skeleton.nodes)
635+
636+
# Pick a node to delete (use a middle node to test index handling)
637+
node_to_delete = original_skeleton.nodes[1] # Second node
638+
node_name_to_delete = node_to_delete.name
639+
640+
# Set up command context
641+
context = CommandContext.from_labels(labels)
642+
context.state["skeleton"] = original_skeleton
643+
context.state["selected_node"] = node_to_delete
644+
645+
# Execute the DeleteNode command
646+
DeleteNode.do_action(context, params={})
647+
648+
# Verify skeleton was updated
649+
assert len(labels.skeleton.nodes) == original_num_nodes - 1
650+
assert node_name_to_delete not in labels.skeleton.node_names
651+
652+
# Verify ALL instances have updated point arrays that match the new skeleton
653+
for lf in labels.labeled_frames:
654+
for inst in lf.instances:
655+
# Instance points should have the same number of points as skeleton nodes
656+
assert len(inst.points) == len(labels.skeleton.nodes), (
657+
f"Instance has {len(inst.points)} points but skeleton has "
658+
f"{len(labels.skeleton.nodes)} nodes. "
659+
"Instance point data was not updated when node was deleted!"
660+
)
661+
# Point names should match skeleton node names
662+
assert list(inst.points["name"]) == labels.skeleton.node_names, (
663+
"Instance point names do not match skeleton node names!"
664+
)
665+
666+
# Save and reload to verify file is not corrupted
667+
save_path = Path(tmpdir) / "test_delete_node.slp"
668+
labels.save(str(save_path))
669+
670+
# This should NOT raise an error - the bug caused a ValueError here
671+
reloaded_labels = labels_load_file(str(save_path))
672+
673+
# Verify reloaded data is correct
674+
assert len(reloaded_labels.skeleton.nodes) == original_num_nodes - 1
675+
assert node_name_to_delete not in reloaded_labels.skeleton.node_names
676+
assert len(reloaded_labels.labeled_frames) == len(labels.labeled_frames)
677+
678+
# Verify all reloaded instances have correct point counts
679+
for lf in reloaded_labels.labeled_frames:
680+
for inst in lf.instances:
681+
assert len(inst.points) == len(reloaded_labels.skeleton.nodes)
682+
683+
684+
def test_DeleteNode_without_labels():
685+
"""Test that DeleteNode still works when no Labels object is available.
686+
687+
When editing a skeleton without a Labels context (e.g., in a skeleton-only
688+
editor), the command should fall back to calling skeleton.remove_node()
689+
directly.
690+
"""
691+
# Create a standalone skeleton (not associated with Labels)
692+
skeleton = Skeleton(
693+
nodes=["head", "neck", "tail"],
694+
edges=[("head", "neck"), ("neck", "tail")],
695+
)
696+
697+
# Set up command context without labels
698+
context = CommandContext.from_labels(None)
699+
context.state["skeleton"] = skeleton
700+
context.state["selected_node"] = skeleton.nodes[1] # "neck"
701+
702+
# Execute the DeleteNode command
703+
DeleteNode.do_action(context, params={})
704+
705+
# Verify skeleton was updated
706+
assert len(skeleton.nodes) == 2
707+
assert "neck" not in skeleton.node_names
708+
assert skeleton.node_names == ["head", "tail"]
709+
# Edge from head to neck should be removed
710+
assert len(skeleton.edges) == 0
711+
712+
614713
def test_SaveProjectAs(centered_pair_predictions: Labels, tmpdir):
615714
"""Test that project can be saved as default slp extension"""
616715

0 commit comments

Comments
 (0)