Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
7 changes: 4 additions & 3 deletions src/jabs/ui/central_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,19 +304,20 @@ def load_video(self, path: Path) -> None:
# open poses and any labels that might exist for this video
self._pose_est = self._project.load_pose_est(path)
self._labels = self._project.video_manager.load_video_labels(path, self._pose_est)
self._stacked_timeline.pose = self._pose_est

# if no saved labels exist, initialize a new VideoLabels object
if self._labels is None:
self._labels = VideoLabels(path.name, self._pose_est.num_frames)

self._player_widget.load_video(path, self._pose_est, self._labels)

# load saved predictions for this video
self._predictions, self._probabilities, self._frame_indexes = (
self._project.prediction_manager.load_predictions(path.name, self.behavior)
)

# load video into player
self._player_widget.load_video(path, self._pose_est, self._labels)
# Set timeline pose after
self._stacked_timeline.pose = self._pose_est

# update ui components with properties of new video
display_identities = [
Expand Down
4 changes: 4 additions & 0 deletions src/jabs/ui/player_widget/player_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,10 @@ def load_video(self, path: Path, pose_est: PoseEstimation, video_labels: VideoLa
# cleanup the old player thread if it exists
self._cleanup_player_thread()

# close the old video stream if it exists
if self._video_stream is not None:
self._video_stream.close()

# reset the button state to not playing
self.stop()
self.reset()
Expand Down
40 changes: 32 additions & 8 deletions src/jabs/ui/video_list_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,11 @@ def selectionCommand(self, index, event=None):


class VideoListDockWidget(QtWidgets.QDockWidget):
"""dock for listing video files associated with the project."""
"""dock for listing video files associated with the project.

Uses a debounce timer to delay video loading when rapidly switching videos
with , and . keys, preventing race conditions from out-of-order signal processing.
"""

selectionChanged = QtCore.Signal(str)

Expand All @@ -80,6 +84,13 @@ def __init__(self, *args, **kwargs):
self.setWindowTitle("Project Videos")
self._project = None
self._suppress_selection_event = False
self._pending_selection = None # Track the pending video selection

# Debounce timer to delay video loading when rapidly switching videos
self._debounce_timer = QtCore.QTimer(self)
self._debounce_timer.setSingleShot(True)
self._debounce_timer.setInterval(150) # 150ms delay
self._debounce_timer.timeout.connect(self._emit_pending_selection)

self._video_filter_box = QtWidgets.QLineEdit(self)
self._video_filter_box.setFocusPolicy(QtCore.Qt.FocusPolicy.ClickFocus)
Expand All @@ -98,14 +109,27 @@ def __init__(self, *args, **kwargs):
self._video_filter_box.textChanged.connect(self._filter_list)

def _selection_changed(self, current, _):
"""Emit signal when the selected video changes."""
if self._suppress_selection_event:
"""Handle video selection change with debouncing.

When rapidly switching videos, this cancels pending video loads and
delays the selectionChanged signal emission to prevent race conditions.
"""
if self._suppress_selection_event or not current:
return
if current:
video = current.data(QtCore.Qt.ItemDataRole.UserRole)
self.selectionChanged.emit(video)
else:
self.selectionChanged.emit("")

# Store the pending selection (current should always have a value due to SingleSelection mode)
self._pending_selection = current.data(QtCore.Qt.ItemDataRole.UserRole)

# Cancel any pending timer and start a new one
# This ensures only the final video in a rapid sequence gets loaded
self._debounce_timer.stop()
self._debounce_timer.start()

def _emit_pending_selection(self):
"""Emit the pending selection signal after the debounce timer expires."""
if self._pending_selection is not None:
self.selectionChanged.emit(self._pending_selection)
self._pending_selection = None

def _filter_list(self, text):
"""Filter the video list based on the text entered in the filter box."""
Expand Down
14 changes: 13 additions & 1 deletion src/jabs/video_reader/video_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ def filename(self):
"""return the name of the video file"""
return self._filename

def close(self):
"""Release the video capture resources."""
if self.stream is not None:
self.stream.release()
self.stream = None

def __del__(self):
"""Ensure video capture is released when object is garbage collected."""
self.close()

def get_frame_time(self, frame_number):
"""return a formatted string of the time of a given frame"""
return time.strftime("%H:%M:%S", time.gmtime(frame_number * self._duration))
Expand Down Expand Up @@ -147,4 +157,6 @@ def get_nframes_from_file(cls, path: Path):
if not stream.isOpened():
raise OSError(f"unable to open {path}")

return int(stream.get(cv2.CAP_PROP_FRAME_COUNT))
num_frames = int(stream.get(cv2.CAP_PROP_FRAME_COUNT))
stream.release() # Always release the stream
return num_frames