Skip to content

Commit 0a40b0f

Browse files
authored
Merge pull request #261 from KumarLabJax/add-debounce-to-video-list
fix race condition when rapidly switching videos
2 parents 03e68fb + 5d51e7c commit 0a40b0f

File tree

4 files changed

+53
-15
lines changed

4 files changed

+53
-15
lines changed

src/jabs/ui/central_widget.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -272,8 +272,7 @@ def set_project(self, project: Project) -> None:
272272

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

@@ -304,27 +303,26 @@ def load_video(self, path: Path) -> None:
304303
# open poses and any labels that might exist for this video
305304
self._pose_est = self._project.load_pose_est(path)
306305
self._labels = self._project.video_manager.load_video_labels(path, self._pose_est)
307-
self._stacked_timeline.pose = self._pose_est
308306

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

311+
self._player_widget.load_video(path, self._pose_est, self._labels)
312+
313313
# load saved predictions for this video
314314
self._predictions, self._probabilities, self._frame_indexes = (
315315
self._project.prediction_manager.load_predictions(path.name, self.behavior)
316316
)
317317

318-
# load video into player
319-
self._player_widget.load_video(path, self._pose_est, self._labels)
320-
321318
# update ui components with properties of new video
322319
display_identities = [
323320
self._pose_est.identity_index_to_display(i) for i in self._pose_est.identities
324321
]
325322
self._set_identities(display_identities)
326323
self._player_widget.set_active_identity(self._controls.current_identity_index)
327324

325+
self._stacked_timeline.pose = self._pose_est
328326
self._stacked_timeline.framerate = self._player_widget.stream_fps
329327
self._suppress_label_track_update = False
330328
self._set_label_track()

src/jabs/ui/player_widget/player_widget.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,10 @@ def load_video(self, path: Path, pose_est: PoseEstimation, video_labels: VideoLa
256256
# cleanup the old player thread if it exists
257257
self._cleanup_player_thread()
258258

259+
# close the old video stream if it exists
260+
if self._video_stream is not None:
261+
self._video_stream.close()
262+
259263
# reset the button state to not playing
260264
self.stop()
261265
self.reset()

src/jabs/ui/video_list_widget.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,11 @@ def selectionCommand(self, index, event=None):
7171

7272

7373
class VideoListDockWidget(QtWidgets.QDockWidget):
74-
"""dock for listing video files associated with the project."""
74+
"""dock for listing video files associated with the project.
75+
76+
Uses a debounce timer to delay video loading when rapidly switching videos
77+
with , and . keys, preventing race conditions from out-of-order signal processing.
78+
"""
7579

7680
selectionChanged = QtCore.Signal(str)
7781

@@ -80,6 +84,13 @@ def __init__(self, *args, **kwargs):
8084
self.setWindowTitle("Project Videos")
8185
self._project = None
8286
self._suppress_selection_event = False
87+
self._pending_selection = None # Track the pending video selection
88+
89+
# Debounce timer to delay video loading when rapidly switching videos
90+
self._debounce_timer = QtCore.QTimer(self)
91+
self._debounce_timer.setSingleShot(True)
92+
self._debounce_timer.setInterval(150) # 150ms delay
93+
self._debounce_timer.timeout.connect(self._emit_pending_selection)
8394

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

100111
def _selection_changed(self, current, _):
101-
"""Emit signal when the selected video changes."""
102-
if self._suppress_selection_event:
112+
"""Handle video selection change with debouncing.
113+
114+
When rapidly switching videos, this cancels pending video loads and
115+
delays the selectionChanged signal emission to prevent race conditions.
116+
"""
117+
if self._suppress_selection_event or not current:
103118
return
104-
if current:
105-
video = current.data(QtCore.Qt.ItemDataRole.UserRole)
106-
self.selectionChanged.emit(video)
107-
else:
108-
self.selectionChanged.emit("")
119+
120+
# Store the pending selection.
121+
self._pending_selection = current.data(QtCore.Qt.ItemDataRole.UserRole)
122+
123+
# Cancel any pending timer and start a new one
124+
# This ensures only the final video in a rapid sequence gets loaded
125+
self._debounce_timer.stop()
126+
self._debounce_timer.start()
127+
128+
def _emit_pending_selection(self):
129+
"""Emit the pending selection signal after the debounce timer expires."""
130+
if self._pending_selection is not None:
131+
self.selectionChanged.emit(self._pending_selection)
132+
self._pending_selection = None
109133

110134
def _filter_list(self, text):
111135
"""Filter the video list based on the text entered in the filter box."""

src/jabs/video_reader/video_reader.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ def filename(self):
5959
"""return the name of the video file"""
6060
return self._filename
6161

62+
def close(self):
63+
"""Release the video capture resources."""
64+
if self.stream is not None:
65+
self.stream.release()
66+
self.stream = None
67+
68+
def __del__(self):
69+
"""Ensure video capture is released when object is garbage collected."""
70+
self.close()
71+
6272
def get_frame_time(self, frame_number):
6373
"""return a formatted string of the time of a given frame"""
6474
return time.strftime("%H:%M:%S", time.gmtime(frame_number * self._duration))
@@ -147,4 +157,6 @@ def get_nframes_from_file(cls, path: Path):
147157
if not stream.isOpened():
148158
raise OSError(f"unable to open {path}")
149159

150-
return int(stream.get(cv2.CAP_PROP_FRAME_COUNT))
160+
num_frames = int(stream.get(cv2.CAP_PROP_FRAME_COUNT))
161+
stream.release() # Always release the stream
162+
return num_frames

0 commit comments

Comments
 (0)