Skip to content

Commit

Permalink
Add instance and track copy/pasting (#1206)
Browse files Browse the repository at this point in the history
* Add instance and track copy/pasting

* Remove print statements

* Make the linter happy

* Add test for copy/paste instance commands

* Add copy/paste tests for tracks

* Set `clipboard_track` to None if `selected_instance.track` is None

---------

Co-authored-by: Liezl Maree <[email protected]>
  • Loading branch information
talmo and roomrys authored Mar 3, 2023
1 parent 0bd559a commit 5b3b598
Show file tree
Hide file tree
Showing 3 changed files with 312 additions and 0 deletions.
28 changes: 28 additions & 0 deletions sleap/gui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)",
Expand Down
90 changes: 90 additions & 0 deletions sleap/gui/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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.
Expand Down
194 changes: 194 additions & 0 deletions tests/gui/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down

0 comments on commit 5b3b598

Please sign in to comment.