Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
10 changes: 4 additions & 6 deletions src/jabs/ui/central_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,8 +272,7 @@ def set_project(self, project: Project) -> None:

# This will get set when the first video in the project is loaded, but
# we need to set it to None so that we don't try to cache the current
# labels when we do so (the current labels belong to the previous
# project)
# labels when we do so (the current labels belong to the previous project)
self._labels = None
self._loaded_video = None

Expand Down Expand Up @@ -304,27 +303,26 @@ 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)

# update ui components with properties of new video
display_identities = [
self._pose_est.identity_index_to_display(i) for i in self._pose_est.identities
]
self._set_identities(display_identities)
self._player_widget.set_active_identity(self._controls.current_identity_index)

self._stacked_timeline.pose = self._pose_est
self._stacked_timeline.framerate = self._player_widget.stream_fps
self._suppress_label_track_update = False
self._set_label_track()
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.
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