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 7 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
9 changes: 9 additions & 0 deletions sleap/gui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,15 @@ def new_instance_menu_action():

labelMenu.addSeparator()

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

labelMenu.addSeparator()

add_menu_item(
labelMenu,
"add instances from all frame predictions",
Expand Down
299 changes: 291 additions & 8 deletions sleap/gui/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,9 @@ def openPrereleaseVersion(self):
"""Open the current prerelease version."""
self.execute(OpenPrereleaseVersion)

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

# File Commands

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

class ExportVideoClip(AppCommand):
@staticmethod
def do_action(context: CommandContext, params: dict):
"""
Extracts a video clip and saves it with pose annotations.

def copy_to_clipboard(text: str):
"""Copy a string to the system clipboard.
Args:
context: Command context, providing state and application access.
params: Parameters for the action, including file paths and export options.
"""
from sleap.io.visuals import save_labeled_video
from sleap import Labels

Args:
text: String to copy to clipboard.
"""
clipboard = QtWidgets.QApplication.clipboard()
clipboard.clear(mode=clipboard.Clipboard)
clipboard.setText(text, mode=clipboard.Clipboard)
# 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")
if frame_range is None:
frame_range = (0, video.frames)

# 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
)

# 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]

# Ensure the pruned labels include the original video reference
if video not in pruned_labels.videos:
pruned_labels.add_video(video)

# Conditionally render labels
render_labels = params["render_labels"]

# Save the video clip with annotations
# Save the video clip with pose annotations
save_labeled_video(
filename=params["filename"],
labels=context.state["labels"] if render_labels else None,
video=context.state["video"],
frames=list(params["frames"]),
fps=params["fps"],
color_manager=params["color_manager"],
background=params["background"],
show_edges=params["show_edges"] if render_labels else False,
edge_is_wedge=params["edge_is_wedge"] if render_labels else False,
marker_size=params["marker_size"] if render_labels else 0,
scale=params["scale"],
crop_size_xy=params["crop"],
gui_progress=True,
)

# 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["open_when_done"]:
open_file(params["filename"])

@staticmethod
def ask(context: CommandContext, params: dict) -> bool:
"""
Asks the user for export parameters via a 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.
"""
from sleap.gui.dialogs.export_clip import ExportClipDialog
from sleap.io.videowriter import VideoWriter
from qtpy import QtWidgets

# Show the export dialog to the user
dialog = ExportClipDialog()

# Set default FPS from the current video
dialog.form_widget.set_form_data(
dict(fps=getattr(context.state["video"], "fps", 30))
)

export_options = dialog.get_results()

if export_options is None: # User canceled the dialog
return False

# **Add "Render Labels" Option Manually**
# Since the dialog does not have an "Render Labels" checkbox, add it here.
# Prompt the user with a simple Yes/No dialog for "Render Labels"
render_labels_dialog = QtWidgets.QMessageBox()
render_labels_dialog.setWindowTitle("Render Labels")
render_labels_dialog.setText("Do you want to include pose annotations in the video?")
render_labels_dialog.setStandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No)
render_labels_dialog.setDefaultButton(QtWidgets.QMessageBox.Yes)
render_labels = render_labels_dialog.exec_() == QtWidgets.QMessageBox.Yes

# Determine default output filename
default_out_filename = context.state["filename"] + ".avi"
if VideoWriter.can_use_ffmpeg():
default_out_filename = context.state["filename"] + ".mp4"

# Prompt the user to select an output file
filename, _ = QtWidgets.QFileDialog.getSaveFileName(
None,
"Save Clip As...",
default_out_filename,
"Video (*.avi *.mp4)",
)

if not filename: # User canceled file selection
return False

# Populate parameters with user-selected options
params["filename"] = filename
params["fps"] = export_options["fps"]
params["scale"] = export_options["scale"]
params["open_when_done"] = export_options["open_when_done"]
params["background"] = export_options["background"]
params["render_labels"] = render_labels # Add render_labels option

# Handle crop size
params["crop"] = None
w = int(context.state["video"].width * params["scale"])
h = int(context.state["video"].height * params["scale"])
if export_options["crop"] == "Half":
params["crop"] = (w // 2, h // 2)
elif export_options["crop"] == "Quarter":
params["crop"] = (w // 4, h // 4)

# Use GUI visuals if selected
if export_options["use_gui_visuals"]:
params["color_manager"] = context.app.color_manager
else:
params["color_manager"] = None

# 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)

params["show_edges"] = context.state.get("show edges", default=True)
params["edge_is_wedge"] = (
context.state.get("edge style", default="").lower() == "wedge"
)
params["marker_size"] = context.state.get("marker size", default=4)

return True

ericleonardis marked this conversation as resolved.
Show resolved Hide resolved
class ExportVideoClip(AppCommand):
@staticmethod
def do_action(context: CommandContext, params: dict):
from sleap.io.visuals import save_labeled_video
from sleap.io.video import MediaVideo, Video
import cv2
import numpy as np

# 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")
if frame_range is None:
frame_range = (0, video.frames)

# 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]

# Save the video
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
height, width = video.height, video.width
is_color = video.channels == 3
writer = cv2.VideoWriter(params["filename"], fourcc, params["fps"], (width, height), is_color)

for frame_idx in params["frames"]:
frame = video.get_frame(frame_idx)

# Ensure frame format is valid for OpenCV
if frame.ndim == 2: # Grayscale frames
frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)

if frame.shape[:2] != (height, width):
raise ValueError(f"Frame size {frame.shape[:2]} does not match expected {height, width}")

writer.write(frame)

writer.release()

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

# Step 1: Update all labeled frames to point to the new video
for labeled_frame in pruned_labels.labeled_frames:
if labeled_frame.video.filename == video.filename:
labeled_frame.video = new_video

# Step 2: Safely replace the old video with the new video
pruned_labels.videos = [
v for v in pruned_labels.videos if v.filename != video.filename
]
pruned_labels.add_video(new_video)

# Step 3: Rebuild the cache to ensure consistency
pruned_labels.update_cache()

# Save the pruned labels as .slp
labels_filename = params["filename"].replace(".mp4", ".slp")
pruned_labels.save(labels_filename)

# Save the pruned labels with embedded images as .pkg.slp
pkg_filename = params["filename"].replace(".mp4", ".pkg.slp")
pruned_labels.save(pkg_filename, with_images=True)
ericleonardis marked this conversation as resolved.
Show resolved Hide resolved

# Open the video file when done, if specified
if params["open_when_done"]:
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.
"""
from sleap.gui.dialogs.export_clip import ExportClipAndLabelsDialog
from qtpy import QtWidgets

# 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 (*.avi *.mp4)"
)
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)
ericleonardis marked this conversation as resolved.
Show resolved Hide resolved
ericleonardis marked this conversation as resolved.
Show resolved Hide resolved
52 changes: 51 additions & 1 deletion sleap/gui/dialogs/export_clip.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"""

from sleap.gui.dialogs.formbuilder import FormBuilderModalDialog

from qtpy import QtWidgets

class ExportClipDialog(FormBuilderModalDialog):
def __init__(self):
Expand All @@ -27,3 +27,53 @@ 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
ericleonardis marked this conversation as resolved.
Show resolved Hide resolved
Loading
Loading