singlebehaviorlab 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. sam2/__init__.py +11 -0
  2. sam2/automatic_mask_generator.py +454 -0
  3. sam2/benchmark.py +92 -0
  4. sam2/build_sam.py +174 -0
  5. sam2/configs/sam2/sam2_hiera_b+.yaml +113 -0
  6. sam2/configs/sam2/sam2_hiera_l.yaml +117 -0
  7. sam2/configs/sam2/sam2_hiera_s.yaml +116 -0
  8. sam2/configs/sam2/sam2_hiera_t.yaml +118 -0
  9. sam2/configs/sam2.1/sam2.1_hiera_b+.yaml +116 -0
  10. sam2/configs/sam2.1/sam2.1_hiera_l.yaml +120 -0
  11. sam2/configs/sam2.1/sam2.1_hiera_s.yaml +119 -0
  12. sam2/configs/sam2.1/sam2.1_hiera_t.yaml +121 -0
  13. sam2/configs/sam2.1_training/sam2.1_hiera_b+_MOSE_finetune.yaml +339 -0
  14. sam2/modeling/__init__.py +5 -0
  15. sam2/modeling/backbones/__init__.py +5 -0
  16. sam2/modeling/backbones/hieradet.py +317 -0
  17. sam2/modeling/backbones/image_encoder.py +134 -0
  18. sam2/modeling/backbones/utils.py +93 -0
  19. sam2/modeling/memory_attention.py +169 -0
  20. sam2/modeling/memory_encoder.py +181 -0
  21. sam2/modeling/position_encoding.py +239 -0
  22. sam2/modeling/sam/__init__.py +5 -0
  23. sam2/modeling/sam/mask_decoder.py +295 -0
  24. sam2/modeling/sam/prompt_encoder.py +202 -0
  25. sam2/modeling/sam/transformer.py +311 -0
  26. sam2/modeling/sam2_base.py +913 -0
  27. sam2/modeling/sam2_utils.py +323 -0
  28. sam2/sam2_hiera_b+.yaml +113 -0
  29. sam2/sam2_hiera_l.yaml +117 -0
  30. sam2/sam2_hiera_s.yaml +116 -0
  31. sam2/sam2_hiera_t.yaml +118 -0
  32. sam2/sam2_image_predictor.py +466 -0
  33. sam2/sam2_video_predictor.py +1388 -0
  34. sam2/sam2_video_predictor_legacy.py +1172 -0
  35. sam2/utils/__init__.py +5 -0
  36. sam2/utils/amg.py +348 -0
  37. sam2/utils/misc.py +349 -0
  38. sam2/utils/transforms.py +118 -0
  39. singlebehaviorlab/__init__.py +4 -0
  40. singlebehaviorlab/__main__.py +130 -0
  41. singlebehaviorlab/_paths.py +100 -0
  42. singlebehaviorlab/backend/__init__.py +2 -0
  43. singlebehaviorlab/backend/augmentations.py +320 -0
  44. singlebehaviorlab/backend/data_store.py +420 -0
  45. singlebehaviorlab/backend/model.py +1290 -0
  46. singlebehaviorlab/backend/train.py +4667 -0
  47. singlebehaviorlab/backend/uncertainty.py +578 -0
  48. singlebehaviorlab/backend/video_processor.py +688 -0
  49. singlebehaviorlab/backend/video_utils.py +139 -0
  50. singlebehaviorlab/data/config/config.yaml +85 -0
  51. singlebehaviorlab/data/training_profiles.json +334 -0
  52. singlebehaviorlab/gui/__init__.py +4 -0
  53. singlebehaviorlab/gui/analysis_widget.py +2291 -0
  54. singlebehaviorlab/gui/attention_export.py +311 -0
  55. singlebehaviorlab/gui/clip_extraction_widget.py +481 -0
  56. singlebehaviorlab/gui/clustering_widget.py +3187 -0
  57. singlebehaviorlab/gui/inference_popups.py +1138 -0
  58. singlebehaviorlab/gui/inference_widget.py +4550 -0
  59. singlebehaviorlab/gui/inference_worker.py +651 -0
  60. singlebehaviorlab/gui/labeling_widget.py +2324 -0
  61. singlebehaviorlab/gui/main_window.py +754 -0
  62. singlebehaviorlab/gui/metadata_management_widget.py +1119 -0
  63. singlebehaviorlab/gui/motion_tracking.py +764 -0
  64. singlebehaviorlab/gui/overlay_export.py +1234 -0
  65. singlebehaviorlab/gui/plot_integration.py +729 -0
  66. singlebehaviorlab/gui/qt_helpers.py +29 -0
  67. singlebehaviorlab/gui/registration_widget.py +1485 -0
  68. singlebehaviorlab/gui/review_widget.py +1330 -0
  69. singlebehaviorlab/gui/segmentation_tracking_widget.py +2752 -0
  70. singlebehaviorlab/gui/tab_tutorial_dialog.py +312 -0
  71. singlebehaviorlab/gui/timeline_themes.py +131 -0
  72. singlebehaviorlab/gui/training_profiles.py +418 -0
  73. singlebehaviorlab/gui/training_widget.py +3719 -0
  74. singlebehaviorlab/gui/video_utils.py +233 -0
  75. singlebehaviorlab/licenses/SAM2-LICENSE +201 -0
  76. singlebehaviorlab/licenses/VideoPrism-LICENSE +202 -0
  77. singlebehaviorlab-2.0.0.dist-info/METADATA +447 -0
  78. singlebehaviorlab-2.0.0.dist-info/RECORD +88 -0
  79. singlebehaviorlab-2.0.0.dist-info/WHEEL +5 -0
  80. singlebehaviorlab-2.0.0.dist-info/entry_points.txt +2 -0
  81. singlebehaviorlab-2.0.0.dist-info/licenses/LICENSE +21 -0
  82. singlebehaviorlab-2.0.0.dist-info/top_level.txt +3 -0
  83. videoprism/__init__.py +0 -0
  84. videoprism/encoders.py +910 -0
  85. videoprism/layers.py +1136 -0
  86. videoprism/models.py +407 -0
  87. videoprism/tokenizers.py +167 -0
  88. videoprism/utils.py +168 -0
@@ -0,0 +1,481 @@
1
+ import logging
2
+ from PyQt6.QtWidgets import (
3
+ QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QLineEdit,
4
+ QSpinBox, QProgressBar, QFileDialog, QGroupBox, QFormLayout, QMessageBox, QComboBox
5
+ )
6
+ from PyQt6.QtCore import QThread, pyqtSignal, Qt
7
+ import os
8
+ import json
9
+
10
+ logger = logging.getLogger(__name__)
11
+ from singlebehaviorlab.backend.video_utils import extract_clips, get_video_info
12
+ from singlebehaviorlab.backend.model import VideoPrismBackbone
13
+
14
+ BACKBONE_MODELS = [
15
+ "videoprism_public_v1_base",
16
+ "videoprism_public_v1_large",
17
+ "videoprism_public_v1_small",
18
+ "videoprism_public_v1_huge",
19
+ "videoprism_mouse_v1_base",
20
+ "videoprism_mouse_v1_large",
21
+ ]
22
+
23
+ class ModelLoadWorker(QThread):
24
+ """Worker thread for loading/downloading VideoPrism model."""
25
+ finished = pyqtSignal(str)
26
+ error = pyqtSignal(str)
27
+
28
+ def __init__(self, model_name):
29
+ super().__init__()
30
+ self.model_name = model_name
31
+
32
+ def run(self):
33
+ try:
34
+ _ = VideoPrismBackbone(model_name=self.model_name)
35
+ self.finished.emit(self.model_name)
36
+ except Exception as e:
37
+ self.error.emit(str(e))
38
+
39
+
40
+
41
+ class ClipExtractionWorker(QThread):
42
+ """Worker thread for clip extraction."""
43
+ progress = pyqtSignal(int, int)
44
+ finished = pyqtSignal(int, str, bool)
45
+ error = pyqtSignal(str)
46
+
47
+ def __init__(self, video_path, output_dir, target_fps, clip_length, step_frames):
48
+ super().__init__()
49
+ self.video_path = video_path
50
+ self.output_dir = output_dir
51
+ self.target_fps = target_fps
52
+ self.clip_length = clip_length
53
+ self.step_frames = step_frames
54
+ self.should_stop = False
55
+
56
+ def stop(self):
57
+ """Request extraction stop."""
58
+ self.should_stop = True
59
+
60
+ def run(self):
61
+ try:
62
+ def progress_cb(current, total):
63
+ self.progress.emit(current, total)
64
+
65
+ num_clips, output_dir = extract_clips(
66
+ self.video_path,
67
+ self.output_dir,
68
+ self.target_fps,
69
+ self.clip_length,
70
+ self.step_frames,
71
+ progress_cb,
72
+ stop_callback=lambda: self.should_stop,
73
+ )
74
+
75
+ import json
76
+ meta_path = os.path.join(output_dir, "clips_metadata.json")
77
+ try:
78
+ with open(meta_path, 'w') as f:
79
+ json.dump({
80
+ "target_fps": self.target_fps,
81
+ "clip_length": self.clip_length,
82
+ "step_frames": self.step_frames
83
+ }, f, indent=2)
84
+ except Exception as e:
85
+ logger.warning("Failed to save clips metadata: %s", e)
86
+
87
+ self.finished.emit(num_clips, output_dir, self.should_stop)
88
+ except Exception as e:
89
+ self.error.emit(str(e))
90
+
91
+
92
+ class ClipExtractionWidget(QWidget):
93
+ """Widget for extracting 16-frame clips from videos."""
94
+
95
+ def __init__(self, config: dict):
96
+ super().__init__()
97
+ self.config = config
98
+ self.video_path = ""
99
+ self.video_paths = []
100
+ self.worker = None
101
+ self._setup_ui()
102
+
103
+ def update_config(self, config: dict):
104
+ """Update configuration (called when experiments change)."""
105
+ self.config = config
106
+
107
+ def _setup_ui(self):
108
+ """Setup UI components."""
109
+ layout = QVBoxLayout()
110
+
111
+ video_group = QGroupBox("Video selection")
112
+ video_layout = QVBoxLayout()
113
+
114
+ single_video_layout = QHBoxLayout()
115
+ self.video_path_edit = QLineEdit()
116
+ self.video_path_edit.setReadOnly(True)
117
+ self.browse_btn = QPushButton("Select single video...")
118
+ self.browse_btn.clicked.connect(self._browse_video)
119
+ single_video_layout.addWidget(QLabel("Single video:"))
120
+ single_video_layout.addWidget(self.video_path_edit)
121
+ single_video_layout.addWidget(self.browse_btn)
122
+ video_layout.addLayout(single_video_layout)
123
+
124
+ multiple_video_layout = QHBoxLayout()
125
+ self.video_list_label = QLabel("No videos selected")
126
+ self.browse_multiple_btn = QPushButton("Select multiple videos...")
127
+ self.browse_multiple_btn.clicked.connect(self._browse_multiple_videos)
128
+ self.clear_videos_btn = QPushButton("Clear")
129
+ self.clear_videos_btn.clicked.connect(self._clear_videos)
130
+ self.clear_videos_btn.setEnabled(False)
131
+ multiple_video_layout.addWidget(QLabel("Multiple videos:"))
132
+ multiple_video_layout.addWidget(self.video_list_label)
133
+ multiple_video_layout.addWidget(self.browse_multiple_btn)
134
+ multiple_video_layout.addWidget(self.clear_videos_btn)
135
+ video_layout.addLayout(multiple_video_layout)
136
+
137
+ video_group.setLayout(video_layout)
138
+ layout.addWidget(video_group)
139
+
140
+ info_group = QGroupBox("Video info")
141
+ info_layout = QFormLayout()
142
+ self.info_label = QLabel("No video selected")
143
+ info_layout.addRow("Info:", self.info_label)
144
+ info_group.setLayout(info_layout)
145
+ layout.addWidget(info_group)
146
+
147
+ model_group = QGroupBox("Backbone model")
148
+ model_layout = QHBoxLayout()
149
+
150
+ model_layout.addWidget(QLabel("Select model:"))
151
+ self.model_combo = QComboBox()
152
+ self.model_combo.addItems(BACKBONE_MODELS)
153
+ current_model = self.config.get("backbone_model", "videoprism_public_v1_base")
154
+ index = self.model_combo.findText(current_model)
155
+ if index >= 0:
156
+ self.model_combo.setCurrentIndex(index)
157
+ else:
158
+ self.model_combo.addItem(current_model)
159
+ self.model_combo.setCurrentText(current_model)
160
+
161
+ model_layout.addWidget(self.model_combo)
162
+
163
+ self.load_model_btn = QPushButton("Load/download model")
164
+ self.load_model_btn.clicked.connect(self._load_backbone)
165
+ model_layout.addWidget(self.load_model_btn)
166
+
167
+ model_group.setLayout(model_layout)
168
+ layout.addWidget(model_group)
169
+
170
+ params_group = QGroupBox("Extraction parameters")
171
+ params_layout = QFormLayout()
172
+
173
+ self.target_fps_spin = QSpinBox()
174
+ self.target_fps_spin.setRange(1, 99999)
175
+ self.target_fps_spin.setValue(16)
176
+ params_layout.addRow("Target FPS:", self.target_fps_spin)
177
+
178
+ self.clip_length_spin = QSpinBox()
179
+ self.clip_length_spin.setRange(1, 64)
180
+ self.clip_length_spin.setValue(16)
181
+ params_layout.addRow("Frames per clip:", self.clip_length_spin)
182
+
183
+ self.step_frames_spin = QSpinBox()
184
+ self.step_frames_spin.setRange(1, 64)
185
+ self.step_frames_spin.setValue(16)
186
+ params_layout.addRow("Step frames:", self.step_frames_spin)
187
+
188
+ params_group.setLayout(params_layout)
189
+ layout.addWidget(params_group)
190
+
191
+ output_group = QGroupBox("Output")
192
+ output_layout = QHBoxLayout()
193
+ self.output_path_edit = QLineEdit()
194
+ self.output_path_edit.setPlaceholderText("Auto-generated from video name")
195
+ self.output_browse_btn = QPushButton("Browse...")
196
+ self.output_browse_btn.clicked.connect(self._browse_output)
197
+ output_layout.addWidget(QLabel("Output directory:"))
198
+ output_layout.addWidget(self.output_path_edit)
199
+ output_layout.addWidget(self.output_browse_btn)
200
+ output_group.setLayout(output_layout)
201
+ layout.addWidget(output_group)
202
+
203
+ button_layout = QHBoxLayout()
204
+ self.extract_btn = QPushButton("Extract clips")
205
+ self.extract_btn.clicked.connect(self._extract_clips)
206
+ self.extract_btn.setEnabled(False)
207
+ button_layout.addWidget(self.extract_btn)
208
+
209
+ self.stop_extract_btn = QPushButton("Stop extraction")
210
+ self.stop_extract_btn.clicked.connect(self._stop_extraction)
211
+ self.stop_extract_btn.setEnabled(False)
212
+ button_layout.addWidget(self.stop_extract_btn)
213
+
214
+ self.extract_multiple_btn = QPushButton("Extract from all selected videos")
215
+ self.extract_multiple_btn.clicked.connect(self._extract_multiple_videos)
216
+ self.extract_multiple_btn.setEnabled(False)
217
+ button_layout.addWidget(self.extract_multiple_btn)
218
+
219
+ layout.addLayout(button_layout)
220
+
221
+ self.progress_bar = QProgressBar()
222
+ self.progress_bar.setVisible(False)
223
+ layout.addWidget(self.progress_bar)
224
+
225
+ self.status_label = QLabel("")
226
+ layout.addWidget(self.status_label)
227
+
228
+ layout.addStretch()
229
+ self.setLayout(layout)
230
+
231
+ def _load_backbone(self):
232
+ """Load/Download selected backbone model."""
233
+ model_name = self.model_combo.currentText()
234
+
235
+ reply = QMessageBox.question(
236
+ self,
237
+ "Load Model",
238
+ f"Load backbone model '{model_name}'?\n\n"
239
+ "If this model is not cached, it will be downloaded (this may take a while and requires internet).",
240
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
241
+ )
242
+ if reply == QMessageBox.StandardButton.No:
243
+ return
244
+
245
+ self.load_model_btn.setEnabled(False)
246
+ self.status_label.setText(f"Loading/Downloading {model_name}...")
247
+ self.progress_bar.setVisible(True)
248
+ self.progress_bar.setRange(0, 0) # Indeterminate
249
+
250
+ self.model_worker = ModelLoadWorker(model_name)
251
+ self.model_worker.finished.connect(self._on_model_loaded)
252
+ self.model_worker.error.connect(self._on_model_error)
253
+ self.model_worker.start()
254
+
255
+ def _on_model_loaded(self, model_name: str):
256
+ """Handle successful model load."""
257
+ self.load_model_btn.setEnabled(True)
258
+ self.progress_bar.setVisible(False)
259
+ self.progress_bar.setRange(0, 100)
260
+ self.status_label.setText(f"Model '{model_name}' loaded successfully")
261
+
262
+ self.config['backbone_model'] = model_name
263
+
264
+ QMessageBox.information(self, "Success", f"VideoPrism backbone '{model_name}' is ready to use.")
265
+
266
+ def _on_model_error(self, error_msg: str):
267
+ """Handle model load error."""
268
+ self.load_model_btn.setEnabled(True)
269
+ self.progress_bar.setVisible(False)
270
+ self.progress_bar.setRange(0, 100)
271
+ self.status_label.setText(f"Error loading model: {error_msg}")
272
+ QMessageBox.critical(self, "Error", f"Failed to load model:\n{error_msg}")
273
+
274
+ def _browse_video(self):
275
+ """Browse for video file."""
276
+ video_path, _ = QFileDialog.getOpenFileName(
277
+ self,
278
+ "Select Video File",
279
+ self.config.get("raw_videos_dir", self.config.get("data_dir", "data/raw_videos")),
280
+ "Video Files (*.mp4 *.avi *.mov *.mkv);;All Files (*)"
281
+ )
282
+ if video_path:
283
+ from .video_utils import ensure_video_in_experiment
284
+ video_path = ensure_video_in_experiment(video_path, self.config, self)
285
+ self.set_video_path(video_path)
286
+
287
+ def set_video_path(self, video_path: str):
288
+ """Set video path and update UI."""
289
+ self.video_path = video_path
290
+ self.video_path_edit.setText(video_path)
291
+
292
+ info = get_video_info(video_path)
293
+ if info:
294
+ info_text = f"FPS: {info['fps']:.2f}, Frames: {info['frame_count']}, Size: {info['width']}x{info['height']}"
295
+ self.info_label.setText(info_text)
296
+ # Default target FPS to native video FPS
297
+ native_fps = int(round(info['fps']))
298
+ self.target_fps_spin.setValue(max(1, min(native_fps, self.target_fps_spin.maximum())))
299
+
300
+ video_name = os.path.splitext(os.path.basename(video_path))[0]
301
+ clips_dir = os.path.join(self.config.get("clips_dir", "data/clips"), video_name)
302
+ self.output_path_edit.setText(clips_dir)
303
+
304
+ self.extract_btn.setEnabled(True)
305
+
306
+ def _browse_multiple_videos(self):
307
+ """Browse for multiple video files."""
308
+ video_paths, _ = QFileDialog.getOpenFileNames(
309
+ self,
310
+ "Select Video Files",
311
+ self.config.get("raw_videos_dir", self.config.get("data_dir", "data/raw_videos")),
312
+ "Video Files (*.mp4 *.avi *.mov *.mkv);;All Files (*)"
313
+ )
314
+ if video_paths:
315
+ # Ensure videos are in experiment folder (batch operation)
316
+ from .video_utils import ensure_videos_in_experiment
317
+ self.video_paths = ensure_videos_in_experiment(video_paths, self.config, self)
318
+ if len(video_paths) == 1:
319
+ self.video_list_label.setText(f"1 video selected: {os.path.basename(video_paths[0])}")
320
+ else:
321
+ self.video_list_label.setText(f"{len(video_paths)} videos selected")
322
+ self.clear_videos_btn.setEnabled(True)
323
+ self.extract_multiple_btn.setEnabled(True)
324
+ # Default target FPS to first video's native FPS
325
+ first_info = get_video_info(self.video_paths[0])
326
+ if first_info:
327
+ native_fps = int(round(first_info['fps']))
328
+ self.target_fps_spin.setValue(max(1, min(native_fps, self.target_fps_spin.maximum())))
329
+
330
+ def _clear_videos(self):
331
+ """Clear selected videos."""
332
+ self.video_paths = []
333
+ self.video_list_label.setText("No videos selected")
334
+ self.clear_videos_btn.setEnabled(False)
335
+ self.extract_multiple_btn.setEnabled(False)
336
+
337
+ def _extract_multiple_videos(self):
338
+ """Extract clips from multiple videos."""
339
+ if not self.video_paths:
340
+ QMessageBox.warning(self, "Error", "Please select at least one video file.")
341
+ return
342
+
343
+ output_base_dir = self.output_path_edit.text().strip()
344
+ if not output_base_dir:
345
+ output_base_dir = self.config.get("clips_dir", "data/clips")
346
+
347
+ target_fps = self.target_fps_spin.value()
348
+ clip_length = self.clip_length_spin.value()
349
+ step_frames = self.step_frames_spin.value()
350
+
351
+ if step_frames > clip_length:
352
+ QMessageBox.warning(self, "Error", "Step frames cannot be greater than clip length.")
353
+ return
354
+
355
+ self.extract_multiple_btn.setEnabled(False)
356
+ self.extract_btn.setEnabled(False)
357
+ self.progress_bar.setVisible(True)
358
+ self.progress_bar.setValue(0)
359
+ self.status_label.setText(f"Processing {len(self.video_paths)} videos...")
360
+
361
+ total_clips = 0
362
+ for i, video_path in enumerate(self.video_paths):
363
+ if not os.path.exists(video_path):
364
+ self.status_label.setText(f"Skipping invalid video: {video_path}")
365
+ continue
366
+
367
+ video_name = os.path.splitext(os.path.basename(video_path))[0]
368
+ output_dir = os.path.join(output_base_dir, video_name)
369
+
370
+ self.status_label.setText(f"Processing video {i+1}/{len(self.video_paths)}: {video_name}")
371
+
372
+ try:
373
+ def progress_cb(current, total):
374
+ overall_progress = int(100 * (i + current / max(total, 1)) / len(self.video_paths))
375
+ self.progress_bar.setValue(overall_progress)
376
+
377
+ num_clips, _ = extract_clips(
378
+ video_path,
379
+ output_dir,
380
+ target_fps,
381
+ clip_length,
382
+ step_frames,
383
+ progress_cb
384
+ )
385
+ total_clips += num_clips
386
+ except Exception as e:
387
+ self.status_label.setText(f"Error processing {video_name}: {str(e)}")
388
+ continue
389
+
390
+ self.extract_multiple_btn.setEnabled(True)
391
+ self.extract_btn.setEnabled(True)
392
+ self.progress_bar.setVisible(False)
393
+ self.progress_bar.setValue(100)
394
+ self.status_label.setText(f"Extracted {total_clips} clips from {len(self.video_paths)} videos")
395
+ QMessageBox.information(
396
+ self,
397
+ "Success",
398
+ f"Extracted {total_clips} clips from {len(self.video_paths)} videos successfully!"
399
+ )
400
+
401
+ def _browse_output(self):
402
+ """Browse for output directory."""
403
+ output_dir = QFileDialog.getExistingDirectory(
404
+ self,
405
+ "Select Output Directory",
406
+ self.config.get("clips_dir", "data/clips")
407
+ )
408
+ if output_dir:
409
+ self.output_path_edit.setText(output_dir)
410
+
411
+ def _extract_clips(self):
412
+ """Start clip extraction."""
413
+ if not self.video_path or not os.path.exists(self.video_path):
414
+ QMessageBox.warning(self, "Error", "Please select a valid video file.")
415
+ return
416
+
417
+ output_dir = self.output_path_edit.text().strip()
418
+ if not output_dir:
419
+ QMessageBox.warning(self, "Error", "Please specify output directory.")
420
+ return
421
+
422
+ target_fps = self.target_fps_spin.value()
423
+ clip_length = self.clip_length_spin.value()
424
+ step_frames = self.step_frames_spin.value()
425
+
426
+ if step_frames > clip_length:
427
+ QMessageBox.warning(self, "Error", "Step frames cannot be greater than clip length.")
428
+ return
429
+
430
+ self.extract_btn.setEnabled(False)
431
+ self.progress_bar.setVisible(True)
432
+ self.progress_bar.setValue(0)
433
+ self.status_label.setText("Extracting clips...")
434
+
435
+ self.worker = ClipExtractionWorker(
436
+ self.video_path,
437
+ output_dir,
438
+ target_fps,
439
+ clip_length,
440
+ step_frames
441
+ )
442
+ self.worker.progress.connect(self._on_progress)
443
+ self.worker.finished.connect(self._on_finished)
444
+ self.worker.error.connect(self._on_error)
445
+ self.worker.start()
446
+ self.stop_extract_btn.setEnabled(True)
447
+
448
+ def _stop_extraction(self):
449
+ """Stop currently running single-video extraction."""
450
+ if self.worker and self.worker.isRunning():
451
+ self.worker.stop()
452
+ self.stop_extract_btn.setEnabled(False)
453
+ self.status_label.setText("Stopping extraction...")
454
+
455
+ def _on_progress(self, current: int, total: int):
456
+ """Update progress bar."""
457
+ if total > 0:
458
+ progress = int(100 * current / total)
459
+ self.progress_bar.setValue(progress)
460
+ self.status_label.setText(f"Extracted {current} clips...")
461
+
462
+ def _on_finished(self, num_clips: int, output_dir: str, cancelled: bool):
463
+ """Handle extraction completion."""
464
+ self.extract_btn.setEnabled(True)
465
+ self.stop_extract_btn.setEnabled(False)
466
+ self.progress_bar.setVisible(False)
467
+ if cancelled:
468
+ self.status_label.setText(f"Stopped. Extracted {num_clips} clips to {output_dir}")
469
+ QMessageBox.information(self, "Stopped", f"Extraction stopped.\n\nSaved {num_clips} clips.")
470
+ else:
471
+ self.status_label.setText(f"Extracted {num_clips} clips to {output_dir}")
472
+ QMessageBox.information(self, "Success", f"Extracted {num_clips} clips successfully!")
473
+
474
+ def _on_error(self, error_msg: str):
475
+ """Handle extraction error."""
476
+ self.extract_btn.setEnabled(True)
477
+ self.stop_extract_btn.setEnabled(False)
478
+ self.progress_bar.setVisible(False)
479
+ self.status_label.setText(f"Error: {error_msg}")
480
+ QMessageBox.critical(self, "Error", f"Extraction failed:\n{error_msg}")
481
+