Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract clip as video, slp and pkg.slp #2059

Open
wants to merge 25 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c3a8e5b
add extrack clip and labels menu item
ericleonardis Dec 17, 2024
dcfa897
add export video clip and labels
ericleonardis Dec 17, 2024
39c17b5
test exportvideoclip for creating video, slp, and pkg
ericleonardis Dec 17, 2024
945d737
simplify dialogue menu by making new custom dialogue
ericleonardis Dec 17, 2024
1907daf
new custom export clip and labels dialogue
ericleonardis Dec 17, 2024
c31caa8
test length of lists in labels object
ericleonardis Dec 17, 2024
9f71476
change comments
ericleonardis Dec 17, 2024
117d5a3
reformatted linting with black
ericleonardis Dec 17, 2024
351919e
clean up double
ericleonardis Dec 17, 2024
fd1fc67
split clip pkg and clip pkg
ericleonardis Dec 17, 2024
08b40cf
add two menu items for clip video and clip pkg
ericleonardis Dec 17, 2024
ab58597
add error for no clip selected; remove imports
ericleonardis Dec 17, 2024
5f6fb07
add test for clip pkg
ericleonardis Dec 17, 2024
4c4ea5d
added edge case testing
ericleonardis Dec 17, 2024
c964f2a
throw error for single frame, needs selected range
ericleonardis Dec 17, 2024
ace2c85
add error hanlding for slp and package clip writing
ericleonardis Dec 17, 2024
ddcb213
skip progress bar in tests
ericleonardis Dec 17, 2024
2dccc28
remove unused import
ericleonardis Dec 17, 2024
4bc5422
fix type in ffmpeg error message
ericleonardis Dec 18, 2024
2d00bb8
node
ericleonardis Dec 18, 2024
f5c617f
expanded tests for gui dialogues
ericleonardis Dec 18, 2024
e972d3f
test for multiple videos
ericleonardis Dec 18, 2024
39cc496
map frame_to_indedx and find valid_frame_indices for labels
ericleonardis Dec 18, 2024
b529dd8
enable/disable based on whether clip is selected
ericleonardis Dec 18, 2024
cd866f3
make framerange == (0, video.frames) a valid choice
ericleonardis Dec 20, 2024
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
16 changes: 16 additions & 0 deletions sleap/gui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,22 @@ def new_instance_menu_action():

labelMenu.addSeparator()

add_menu_item(
labelMenu,
"Extract clip and labels",
"Extract Clip and Labels...",
self.commands.exportClipVideo,
)

add_menu_item(
labelMenu,
"extract clip labels package",
"Extract Clip Labels Package...",
self.commands.exportClipPkg,
)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets enable/disable these based on whether a clip is selected in MainWindow._update_gui_state (which is called periodically while the GUI is open). For example:

self._menu_actions["delete clip predictions"].setEnabled(has_frame_range)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion! I have now changed these menu items to be enabled/disabled based on whether a clip is selected.


labelMenu.addSeparator()

add_menu_item(
labelMenu,
"add instances from all frame predictions",
Expand Down
200 changes: 191 additions & 9 deletions sleap/gui/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class which inherits from `AppCommand` (or a more specialized class such as
from sleap.gui.dialogs.merge import MergeDialog, ReplaceSkeletonTableDialog
from sleap.gui.dialogs.message import MessageDialog
from sleap.gui.dialogs.missingfiles import MissingFilesDialog
from sleap.gui.dialogs.export_clip import ExportClipAndLabelsDialog
ericleonardis marked this conversation as resolved.
Show resolved Hide resolved
from sleap.gui.state import GuiState
from sleap.gui.suggestions import VideoFrameSuggestions
from sleap.instance import Instance, LabeledFrame, Point, PredictedInstance, Track
Expand All @@ -57,10 +58,12 @@ class which inherits from `AppCommand` (or a more specialized class such as
from sleap.io.format.adaptor import Adaptor
from sleap.io.format.csv import CSVAdaptor
from sleap.io.format.ndx_pose import NDXPoseAdaptor
from sleap.io.video import Video
from sleap.io.video import Video, MediaVideo
from sleap.io.videowriter import VideoWriter
from sleap.skeleton import Node, Skeleton
from sleap.util import get_package_file


# Indicates whether we support multiple project windows (i.e., "open" opens new window)
OPEN_IN_NEW = True

Expand Down Expand Up @@ -627,6 +630,14 @@ def openPrereleaseVersion(self):
"""Open the current prerelease version."""
self.execute(OpenPrereleaseVersion)

def exportClipVideo(self):
"""Exports a selected range of video frames and their corresponding labels."""
self.execute(ExportClipVideo)

def exportClipPkg(self):
"""Exports a selected range of video frames and their corresponding labels."""
self.execute(ExportClipPkg)


# File Commands

Expand Down Expand Up @@ -3470,13 +3481,184 @@ def do_action(context: CommandContext, params: dict):
if rls is not None:
context.openWebsite(rls.url)

class ExportClipVideo(AppCommand):
@staticmethod
Comment on lines +3484 to +3485
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider creating a base class for clip export commands.

There's significant code duplication between ExportClipVideo and ExportClipPkg, particularly in frame range validation and labels extraction logic. Consider creating a base class to share common functionality.

class BaseClipExportCommand(AppCommand):
    @staticmethod
    def _validate_frame_range(frame_range: tuple, video) -> None:
        """Validates the frame range for clip export."""
        if frame_range[0] < 0 or frame_range[1] > video.frames:
            raise ValueError(f"Frame range {frame_range} is outside video bounds [0, {video.frames}]")
        if frame_range == (0, 1) or frame_range[0] == frame_range[1]:
            raise ValueError("No valid clip frame range selected! Please select a valid frame range using shift + click in the GUI.")

    @staticmethod
    def _extract_labeled_frames(video, labels, frame_range: tuple) -> Labels:
        """Extracts and remaps labeled frames for the given range."""
        frame_to_index = {lf.frame_idx: idx for idx, lf in enumerate(labels.labeled_frames) if lf.video == video}
        valid_frame_indices = [frame for frame in range(*frame_range) if frame in frame_to_index]
        
        if not valid_frame_indices:
            raise ValueError("No valid labeled frames found in the selected frame range.")
            
        pruned_labels = labels.extract(
            inds=[frame_to_index[frame] for frame in valid_frame_indices],
            copy=True
        )
        
        # Remap frame indices
        frame_offset = frame_range[0]
        for labeled_frame in pruned_labels.labeled_frames:
            labeled_frame.frame_idx -= frame_offset
            
        return pruned_labels

Also applies to: 3645-3646

def do_action(context: CommandContext, params: dict):
"""
Exports a pruned video clip and labels to a specified file based on selected frame range.

def copy_to_clipboard(text: str):
"""Copy a string to the system clipboard.
Args:
context (CommandContext): Contains state information like video and labels.
params (dict): Parameters including filename, fps, and open_when_done.
"""
# Extract video and labels from context
video = context.state["video"]
labels = context.state["labels"]

# Ensure frame range is set; default to all frames if None
frame_range = context.state.get("frame_range", (0, video.frames))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we say

Suggested change
frame_range = context.state.get("frame_range", (0, video.frames))
frame_range = context.state.get("frame_range", None)

and raise the no valid clip range error below if frame_range is None?


# Check if clip is selected, raise error if no clip selected
if frame_range == (0, video.frames) or frame_range == (0, 1):
raise ValueError("No valid clip frame range selected! Please select a valid frame range using shift + click in the GUI.")

# Extract only the selected frames into a new Labels object
pruned_labels = labels.extract(
inds=range(*frame_range),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When inds is either a list or range, then we just call Labels.get for each item in the list or range (int in our case).

This means that we try to grab the item from the Labels.labels: List[LabeledFrame] at the index we provided - which is not the same as grabbing the item at the frame index.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sleap/sleap/io/dataset.py

Lines 779 to 798 in 1eff33d

def extract(self, inds, copy: bool = False) -> "Labels":
"""Extract labeled frames from indices and return a new `Labels` object.
Args:
inds: Any valid indexing keys, e.g., a range, slice, list of label indices,
numpy array, `Video`, etc. See `__getitem__` for full list.
copy: If `True`, create a new copy of all of the extracted labeled frames
and associated labels. If `False` (the default), a shallow copy with
references to the original labeled frames and other objects will be
returned.
Returns:
A new `Labels` object with the specified labeled frames.
This will preserve the other data structures even if they are not found in
the extracted labels, including:
- `Labels.videos`
- `Labels.skeletons`
- `Labels.tracks`
- `Labels.suggestions`
- `Labels.provenance`
"""
lfs = self.__getitem__(inds)

sleap/sleap/io/dataset.py

Lines 636 to 674 in 1eff33d

def __getitem__(
self,
key: Union[
int,
slice,
np.integer,
np.ndarray,
list,
range,
Video,
Tuple[Video, Union[np.integer, np.ndarray, int, list, range]],
],
*secondary_key: Union[
int,
slice,
np.integer,
np.ndarray,
list,
range,
],
) -> Union[LabeledFrame, List[LabeledFrame]]:
"""Return labeled frames matching key or return `None` if not found.
This makes `labels[...]` safe and will not raise an exception if the
item is not found.
Do not call __getitem__ directly, use get instead (get allows kwargs for logic).
If you happen to call __getitem__ directly, get will be called but without any
keyword arguments.
Args:
key: Indexing argument to match against. If `key` is a `Video` or tuple of
`(Video, frame_index)`, frames that match the criteria will be searched
for. If a scalar, list, range or array of integers are provided, the
labels with those linear indices will be returned.
secondary_key: Numerical indexing argument(s) which supplement `key`. Only
used when `key` is a `Video`.
"""
return self.get(key, *secondary_key)

sleap/sleap/io/dataset.py

Lines 768 to 769 in 1eff33d

elif isinstance(key, (list, range)):
return [self.__getitem__(i) for i in key]

sleap/sleap/io/dataset.py

Lines 738 to 739 in 1eff33d

if isinstance(key, int):
return self.labels.__getitem__(key)

sleap/sleap/io/dataset.py

Lines 552 to 555 in 1eff33d

@property
def labels(self):
"""Alias for labeled_frames."""
return self.labeled_frames

labeled_frames: List[LabeledFrame] = attr.ib(default=attr.Factory(list))

Copy link
Contributor Author

@ericleonardis ericleonardis Dec 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I think I have addressed this suggestion by creating a frame_to_index mapping and then feeding in valid frame indices on line 3666-3677 in command.py.

`

Map frame indices to the actual labeled frame objects

    frame_to_index = {lf.frame_idx: idx for idx, lf in enumerate(labels.labeled_frames) if lf.video == video}
    valid_frame_indices = [frame for frame in range(*frame_range) if frame in frame_to_index]

    # Extract only the selected frames into a new Labels object
    pruned_labels = labels.extract(
        inds=[frame_to_index[frame] for frame in valid_frame_indices],
        copy=True  # Ensures a deep copy of the extracted labels
    )

`

This should now be robust to multiple videos and sparsely labelled videos.

copy=True, # Ensures a deep copy of the extracted labels
)

Args:
text: String to copy to clipboard.
"""
clipboard = QtWidgets.QApplication.clipboard()
clipboard.clear(mode=clipboard.Clipboard)
clipboard.setText(text, mode=clipboard.Clipboard)
# Remap frame indices in pruned_labels to start from 0
for labeled_frame in pruned_labels.labeled_frames:
labeled_frame.frame_idx -= frame_range[0]

# Initialize VideoWriter
height, width = video.height, video.width
fps = params["fps"]
writer = VideoWriter.safe_builder(params["filename"], height, width, fps)

# Write frames to the video
for frame_idx in range(*frame_range):
try:
frame = video.get_frame(frame_idx)

# Convert grayscale to BGR if necessary
if frame.ndim == 2: # Grayscale frames
frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)

writer.add_frame(frame, bgr=True)
except KeyError:
raise KeyError(f"Failed to load frame {frame_idx} from video.")
ericleonardis marked this conversation as resolved.
Show resolved Hide resolved

writer.close()
ericleonardis marked this conversation as resolved.
Show resolved Hide resolved

# Create a new Video object for the output video
new_media_video = MediaVideo(
filename=params["filename"], grayscale=video.channels == 1, bgr=True
)
Comment on lines +3577 to +3581
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Create a new Video object for the output video
new_media_video = MediaVideo(
filename=params["filename"], grayscale=video.channels == 1, bgr=True
)
# Create a new Video object for the output video
new_media_video = MediaVideo(
filename=params["filename"], grayscale=video.grayscale, bgr=video.bgr
)

new_video = Video(backend=new_media_video)

# Update pruned labels to point to the new video
for labeled_frame in pruned_labels.labeled_frames:
labeled_frame.video = new_video

pruned_labels.videos = [new_video]

# Save the pruned labels
labels_filename = params["filename"].replace(".mp4", ".slp")
Comment on lines +3590 to +3591
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use pathlib for path operations

Suggested change
# Save the pruned labels
labels_filename = params["filename"].replace(".mp4", ".slp")
# Save the pruned labels
labels_filename = str(Path(params["filename"]).with_suffix(".slp"))

pruned_labels.save(labels_filename)

# Open the video file when done, if specified
if params.get("open_when_done", False):
open_file(params["filename"])

@staticmethod
def ask(context: CommandContext, params: dict) -> bool:
"""
Asks the user for export parameters via a custom dialog.

Args:
context: Command context, providing state and application access.
params: A dictionary to populate with user-defined options.

Returns:
bool: True if the user confirmed the action, False if canceled.
"""

# Extract FPS from video metadata (fallback to 30 if unavailable)
video_fps = getattr(context.state["video"], "fps", 30)

# Initialize and show the export dialog
dialog = ExportClipAndLabelsDialog(video_fps=video_fps)
if dialog.exec_() != QtWidgets.QDialog.Accepted:
return False # User canceled

# Get user input from dialog
export_options = dialog.get_results()

# Prompt user to select output file
filename, _ = QtWidgets.QFileDialog.getSaveFileName(
None, "Save Clip As...", "", "Video (*.mp4 *.avi)"
)
if not filename:
return False # User canceled file selection

# Populate export parameters
params["filename"] = filename
params["fps"] = export_options["fps"]
params["open_when_done"] = export_options["open_when_done"]

# Access frame range
if context.state.get("has_frame_range"):
params["frames"] = range(*context.state["frame_range"])
else:
params["frames"] = range(context.state["video"].frames)

return True

def copy_to_clipboard(text: str):
"""Copy a string to the system clipboard.

Args:
text: String to copy to clipboard.
"""
clipboard = QtWidgets.QApplication.clipboard()
clipboard.clear(mode=clipboard.Clipboard)
clipboard.setText(text, mode=clipboard.Clipboard)

class ExportClipPkg(AppCommand):
@staticmethod
def do_action(context: CommandContext, params: dict):
"""
Exports a pruned labels package based on selected frame range.

Args:
context (CommandContext): Contains state information like video and labels.
params (dict): Parameters including filename, fps, and open_when_done.
"""
# Extract video and labels from context
video = context.state["video"]
labels = context.state["labels"]

# Ensure frame range is set; default to all frames if None
frame_range = context.state.get("frame_range", (0, video.frames))

# Check if clip is selected, raise error if no clip selected
if frame_range == (0, video.frames) or frame_range == (0, 1):
raise ValueError("No valid clip frame range selected! Please select a valid frame range using shift + click in the GUI.")

ericleonardis marked this conversation as resolved.
Show resolved Hide resolved
# Extract only the selected frames into a new Labels object
pruned_labels = labels.extract(
inds=range(*frame_range),
copy=True, # Ensures a deep copy of the extracted labels
)

# Remap frame indices in pruned_labels to start from 0
for labeled_frame in pruned_labels.labeled_frames:
labeled_frame.frame_idx -= frame_range[0]

pkg_filename = params["filename"]
pruned_labels.save(pkg_filename, with_images=True)

@staticmethod
def ask(context: CommandContext, params: dict) -> bool:
"""
Asks the user for export parameters via a custom dialog.

Args:
context: Command context, providing state and application access.
params: A dictionary to populate with user-defined options.

Returns:
bool: True if the user confirmed the action, False if canceled.
"""

# Prompt user to select output file
filename, _ = QtWidgets.QFileDialog.getSaveFileName(
None, "Save Clip Package As...", "", "Label Package (*.pkg.slp)"
)
if not filename:
return False # User canceled file selection
# Update the params dictionary with the selected filename
params["filename"] = filename
return True
52 changes: 52 additions & 0 deletions sleap/gui/dialogs/export_clip.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

from sleap.gui.dialogs.formbuilder import FormBuilderModalDialog
from qtpy import QtWidgets


class ExportClipDialog(FormBuilderModalDialog):
Expand All @@ -27,3 +28,54 @@ def __init__(self):
self.add_message(message)

self.setWindowTitle("Export Clip Options")


class ExportClipAndLabelsDialog(FormBuilderModalDialog):
def __init__(self, video_fps=30):
from sleap.io.videowriter import VideoWriter

# Initialize with a blank widget (no YAML needed)
super().__init__(form_widget=QtWidgets.QWidget())

self.setWindowTitle("Export Clip Options")

# FPS Field
self.fps_input = QtWidgets.QSpinBox()
self.fps_input.setRange(1, 240)
self.fps_input.setValue(video_fps) # Set default FPS from video
self.add_message("Frames per second:")
self.layout().insertWidget(2, self.fps_input)

# Open when done Checkbox
self.open_when_done = QtWidgets.QCheckBox("Open file after saving")
self.layout().insertWidget(3, self.open_when_done)

# Video format message
can_use_ffmpeg = VideoWriter.can_use_ffmpeg()
if can_use_ffmpeg:
message = (
"<i><b>MP4</b> file will be encoded using "
"system ffmpeg via imageio (preferred option).</i>"
)
else:
message = (
"<i>Unable to use ffmpeg via imageio. "
"<b>AVI</b> file will be encoded using OpenCV.</i>"
)
self.add_message(message)

def on_accept(self):
"""Retrieve the form results and accept the dialog."""
self._results = {
"fps": self.fps_input.value(),
"open_when_done": self.open_when_done.isChecked(),
}
self.accept()

def get_results(self):
"""Get results as a dictionary."""
self._results = {
"fps": self.fps_input.value(),
"open_when_done": self.open_when_done.isChecked(),
}
return self._results
Loading