diff --git a/sleap/gui/app.py b/sleap/gui/app.py index e4c959826..a14fc007a 100644 --- a/sleap/gui/app.py +++ b/sleap/gui/app.py @@ -169,6 +169,8 @@ def __init__( self.state["skeleton_description"] = "No skeleton loaded yet" if no_usage_data: self.state["share usage data"] = False + self.state["clipboard_track"] = None + self.state["clipboard_instance"] = None self.state.connect("marker size", self.plotFrame) self.state.connect("node label size", self.plotFrame) self.state.connect("show non-visible nodes", self.plotFrame) @@ -722,6 +724,19 @@ def new_instance_menu_action(): labelMenu.addSeparator() + labelMenu.addAction( + "Copy Instance", + self.commands.copyInstance, + Qt.CTRL + Qt.Key_C, + ) + labelMenu.addAction( + "Paste Instance", + self.commands.pasteInstance, + Qt.CTRL + Qt.Key_V, + ) + + labelMenu.addSeparator() + add_menu_item( labelMenu, "delete frame predictions", @@ -808,6 +823,19 @@ def new_instance_menu_action(): tracksMenu.addSeparator() + tracksMenu.addAction( + "Copy Instance Track", + self.commands.copyInstanceTrack, + Qt.CTRL + Qt.SHIFT + Qt.Key_C, + ) + tracksMenu.addAction( + "Paste Instance Track", + self.commands.pasteInstanceTrack, + Qt.CTRL + Qt.SHIFT + Qt.Key_V, + ) + + tracksMenu.addSeparator() + seekbar_header_options = ( "None", "Point Displacement (sum)", diff --git a/sleap/gui/commands.py b/sleap/gui/commands.py index fc4413439..930db684a 100644 --- a/sleap/gui/commands.py +++ b/sleap/gui/commands.py @@ -537,8 +537,17 @@ def setInstancePointVisibility(self, instance: Instance, node: Node, visible: bo ) def addUserInstancesFromPredictions(self): + """Create user instance from a predicted instance.""" self.execute(AddUserInstancesFromPredictions) + def copyInstance(self): + """Copy the selected instance to the instance clipboard.""" + self.execute(CopyInstance) + + def pasteInstance(self): + """Paste the instance from the clipboard as a new copy.""" + self.execute(PasteInstance) + def deleteSelectedInstance(self): """Deletes currently selected instance.""" self.execute(DeleteSelectedInstance) @@ -567,6 +576,14 @@ def deleteMultipleTracks(self, delete_all: bool = False): """Delete all tracks.""" self.execute(DeleteMultipleTracks, delete_all=delete_all) + def copyInstanceTrack(self): + """Copies the selected instance's track to the track clipboard.""" + self.execute(CopyInstanceTrack) + + def pasteInstanceTrack(self): + """Pastes the track in the clipboard to the selected instance.""" + self.execute(PasteInstanceTrack) + def setTrackName(self, track: "Track", name: str): """Sets name for track.""" self.execute(SetTrackName, track=track, name=name) @@ -2581,6 +2598,37 @@ def do_action(context: CommandContext, params: dict): context.labels.remove_unused_tracks() +class CopyInstanceTrack(EditCommand): + @staticmethod + def do_action(context: CommandContext, params: dict): + selected_instance: Instance = context.state["instance"] + if selected_instance is None: + return + context.state["clipboard_track"] = selected_instance.track + + +class PasteInstanceTrack(EditCommand): + topics = [UpdateTopic.tracks] + + @staticmethod + def do_action(context: CommandContext, params: dict): + selected_instance: Instance = context.state["instance"] + track_to_paste = context.state["clipboard_track"] + if selected_instance is None or track_to_paste is None: + return + + # Ensure mutual exclusivity of tracks within a frame. + for inst in selected_instance.frame.instances_to_show: + if inst == selected_instance: + continue + if inst.track is not None and inst.track == track_to_paste: + # Unset track for other instances that have the same track. + inst.track = None + + # Set the track on the selected instance. + selected_instance.track = context.state["clipboard_track"] + + class SetTrackName(EditCommand): topics = [UpdateTopic.tracks, UpdateTopic.frame] @@ -3066,6 +3114,48 @@ def do_action(cls, context: CommandContext, params: dict): context.labels.add_instance(context.state["labeled_frame"], new_instance) +class CopyInstance(EditCommand): + @classmethod + def do_action(cls, context: CommandContext, params: dict): + current_instance: Instance = context.state["instance"] + if current_instance is None: + return + context.state["clipboard_instance"] = current_instance + + +class PasteInstance(EditCommand): + topics = [UpdateTopic.frame, UpdateTopic.project_instances] + + @classmethod + def do_action(cls, context: CommandContext, params: dict): + base_instance: Instance = context.state["clipboard_instance"] + current_frame: LabeledFrame = context.state["labeled_frame"] + if base_instance is None or current_frame is None: + return + + # Create a new instance copy. + new_instance = Instance.from_numpy( + base_instance.numpy(), skeleton=base_instance.skeleton + ) + + if base_instance.frame != current_frame: + # Only copy the track if we're not on the same frame and the track doesn't + # exist on the current frame. + current_frame_tracks = [ + inst.track for inst in current_frame if inst.track is not None + ] + if base_instance.track not in current_frame_tracks: + new_instance.track = base_instance.track + + # Add to the current frame. + context.labels.add_instance(current_frame, new_instance) + + if current_frame not in context.labels.labels: + # Add current frame to labels if it wasn't already there. This happens when + # adding an instance to an empty labeled frame that isn't in the labels. + context.labels.append(current_frame) + + def open_website(url: str): """Open website in default browser. diff --git a/tests/gui/test_commands.py b/tests/gui/test_commands.py index cef1cca74..bfa92ea1a 100644 --- a/tests/gui/test_commands.py +++ b/tests/gui/test_commands.py @@ -548,6 +548,200 @@ def test_DeleteMultipleTracks(min_tracks_2node_labels: Labels): assert len(labels.tracks) == 0 +def test_CopyInstance(min_tracks_2node_labels: Labels): + """Test that copying an instance works as expected.""" + labels = min_tracks_2node_labels + instance = labels[0].instances[0] + + # Set-up command context + context: CommandContext = CommandContext.from_labels(labels) + + # Copy instance + assert context.state["instance"] is None + context.copyInstance() + assert context.state["clipboard_instance"] is None + + # Copy instance + context.state["instance"] = instance + context.copyInstance() + assert context.state["clipboard_instance"] == instance + + +def test_PasteInstance(min_tracks_2node_labels: Labels): + """Test that pasting an instance works as expected.""" + labels = min_tracks_2node_labels + lf_to_copy: LabeledFrame = labels.labeled_frames[0] + instance: Instance = lf_to_copy.instances[0] + + # Set-up command context + context: CommandContext = CommandContext.from_labels(labels) + + def paste_instance( + lf_to_paste: LabeledFrame, assertions_pre_paste, assertions_post_paste + ): + """Helper function to test pasting an instance.""" + instances_checkpoint = list(lf_to_paste.instances) + assertions_pre_paste(instance, lf_to_copy) + + context.pasteInstance() + assertions_post_paste(instances_checkpoint, lf_to_copy, lf_to_paste) + + # Case 1: No instance copied, but frame selected + + def assertions_prior(*args): + assert context.state["clipboard_instance"] is None + + def assertions_post(instances_checkpoint, lf_to_copy, *args): + assert instances_checkpoint == lf_to_copy.instances + + context.state["labeled_frame"] = lf_to_copy + paste_instance(lf_to_copy, assertions_prior, assertions_post) + + # Case 2: No frame selected, but instance copied + + def assertions_prior(*args): + assert context.state["labeled_frame"] is None + + context.state["labeled_frame"] = None + context.state["clipboard_instance"] = instance + paste_instance(lf_to_copy, assertions_prior, assertions_post) + + # Case 3: Instance copied and current frame selected + + def assertions_prior(instance, lf_to_copy, *args): + assert context.state["clipboard_instance"] == instance + assert context.state["labeled_frame"] == lf_to_copy + + def assertions_post(instances_checkpoint, lf_to_copy, lf_to_paste, *args): + lf_checkpoint_tracks = [ + inst.track for inst in instances_checkpoint if inst.track is not None + ] + lf_to_copy_tracks = [ + inst.track for inst in lf_to_copy.instances if inst.track is not None + ] + assert len(lf_checkpoint_tracks) == len(lf_to_copy_tracks) + assert len(lf_to_paste.instances) == len(instances_checkpoint) + 1 + assert lf_to_paste.instances[-1].points == instance.points + + context.state["labeled_frame"] = lf_to_copy + context.state["clipboard_instance"] = instance + paste_instance(lf_to_copy, assertions_prior, assertions_post) + + # Case 4: Instance copied and different frame selected, but new frame has same track + + def assertions_prior(instance, lf_to_copy, *args): + assert context.state["clipboard_instance"] == instance + assert context.state["labeled_frame"] != lf_to_copy + lf_to_paste = context.state["labeled_frame"] + tracks_in_lf_to_paste = [ + inst.track for inst in lf_to_paste.instances if inst.track is not None + ] + assert instance.track in tracks_in_lf_to_paste + + lf_to_paste = labels.labeled_frames[1] + context.state["labeled_frame"] = lf_to_paste + paste_instance(lf_to_paste, assertions_prior, assertions_post) + + # Case 5: Instance copied and different frame selected, and track not in new frame + + def assertions_prior(instance, lf_to_copy, *args): + assert context.state["clipboard_instance"] == instance + assert context.state["labeled_frame"] != lf_to_copy + lf_to_paste = context.state["labeled_frame"] + tracks_in_lf_to_paste = [ + inst.track for inst in lf_to_paste.instances if inst.track is not None + ] + assert instance.track not in tracks_in_lf_to_paste + + def assertions_post(instances_checkpoint, lf_to_copy, lf_to_paste, *args): + assert len(lf_to_paste.instances) == len(instances_checkpoint) + 1 + assert lf_to_paste.instances[-1].points == instance.points + assert lf_to_paste.instances[-1].track == instance.track + + lf_to_paste = labels.labeled_frames[2] + context.state["labeled_frame"] = lf_to_paste + for inst in lf_to_paste.instances: + inst.track = None + paste_instance(lf_to_paste, assertions_prior, assertions_post) + + # Case 6: Instance copied, different frame selected, and frame not in Labels + + def assertions_prior(instance, lf_to_copy, *args): + assert context.state["clipboard_instance"] == instance + assert context.state["labeled_frame"] != lf_to_copy + assert context.state["labeled_frame"] not in labels.labeled_frames + + def assertions_post(instances_checkpoint, lf_to_copy, lf_to_paste, *args): + assert len(lf_to_paste.instances) == len(instances_checkpoint) + 1 + assert lf_to_paste.instances[-1].points == instance.points + assert lf_to_paste.instances[-1].track == instance.track + assert lf_to_paste in labels.labeled_frames + + lf_to_paste = labels.get((labels.video, 3)) + labels.labeled_frames.remove(lf_to_paste) + lf_to_paste.instances = [] + context.state["labeled_frame"] = lf_to_paste + paste_instance(lf_to_paste, assertions_prior, assertions_post) + + +def test_CopyInstanceTrack(min_tracks_2node_labels: Labels): + """Test that copying a track from one instance to another works.""" + labels = min_tracks_2node_labels + instance = labels.labeled_frames[0].instances[0] + + # Set-up CommandContext + context: CommandContext = CommandContext.from_labels(labels) + + # Case 1: No instance selected + context.copyInstanceTrack() + assert context.state["clipboard_track"] is None + + # Case 2: Instance selected and track + context.state["instance"] = instance + context.copyInstanceTrack() + assert context.state["clipboard_track"] == instance.track + + # Case 3: Instance selected and no track + instance.track = None + context.copyInstanceTrack() + assert context.state["clipboard_track"] is None + + +def test_PasteInstanceTrack(min_tracks_2node_labels: Labels): + """Test that pasting a track from one instance to another works.""" + labels = min_tracks_2node_labels + instance = labels.labeled_frames[0].instances[0] + + # Set-up CommandContext + context: CommandContext = CommandContext.from_labels(labels) + + # Case 1: No instance selected + context.state["clipboard_track"] = instance.track + + context.pasteInstanceTrack() + assert context.state["instance"] is None + + # Case 2: Instance selected and track + lf_to_paste = labels.labeled_frames[1] + instance_with_same_track = lf_to_paste.instances[0] + instance_to_paste = lf_to_paste.instances[1] + context.state["instance"] = instance_to_paste + assert instance_to_paste.track != instance.track + assert instance_with_same_track.track == instance.track + + context.pasteInstanceTrack() + assert instance_to_paste.track == instance.track + assert instance_with_same_track.track != instance.track + + # Case 3: Instance selected and no track + lf_to_paste = labels.labeled_frames[2] + instance_to_paste = lf_to_paste.instances[0] + instance.track = None + + context.pasteInstanceTrack() + assert isinstance(instance_to_paste.track, Track) + + @pytest.mark.skipif( sys.platform.startswith("win"), reason="Files being using in parallel by linux CI tests via Github Actions "