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.
- sam2/__init__.py +11 -0
- sam2/automatic_mask_generator.py +454 -0
- sam2/benchmark.py +92 -0
- sam2/build_sam.py +174 -0
- sam2/configs/sam2/sam2_hiera_b+.yaml +113 -0
- sam2/configs/sam2/sam2_hiera_l.yaml +117 -0
- sam2/configs/sam2/sam2_hiera_s.yaml +116 -0
- sam2/configs/sam2/sam2_hiera_t.yaml +118 -0
- sam2/configs/sam2.1/sam2.1_hiera_b+.yaml +116 -0
- sam2/configs/sam2.1/sam2.1_hiera_l.yaml +120 -0
- sam2/configs/sam2.1/sam2.1_hiera_s.yaml +119 -0
- sam2/configs/sam2.1/sam2.1_hiera_t.yaml +121 -0
- sam2/configs/sam2.1_training/sam2.1_hiera_b+_MOSE_finetune.yaml +339 -0
- sam2/modeling/__init__.py +5 -0
- sam2/modeling/backbones/__init__.py +5 -0
- sam2/modeling/backbones/hieradet.py +317 -0
- sam2/modeling/backbones/image_encoder.py +134 -0
- sam2/modeling/backbones/utils.py +93 -0
- sam2/modeling/memory_attention.py +169 -0
- sam2/modeling/memory_encoder.py +181 -0
- sam2/modeling/position_encoding.py +239 -0
- sam2/modeling/sam/__init__.py +5 -0
- sam2/modeling/sam/mask_decoder.py +295 -0
- sam2/modeling/sam/prompt_encoder.py +202 -0
- sam2/modeling/sam/transformer.py +311 -0
- sam2/modeling/sam2_base.py +913 -0
- sam2/modeling/sam2_utils.py +323 -0
- sam2/sam2_hiera_b+.yaml +113 -0
- sam2/sam2_hiera_l.yaml +117 -0
- sam2/sam2_hiera_s.yaml +116 -0
- sam2/sam2_hiera_t.yaml +118 -0
- sam2/sam2_image_predictor.py +466 -0
- sam2/sam2_video_predictor.py +1388 -0
- sam2/sam2_video_predictor_legacy.py +1172 -0
- sam2/utils/__init__.py +5 -0
- sam2/utils/amg.py +348 -0
- sam2/utils/misc.py +349 -0
- sam2/utils/transforms.py +118 -0
- singlebehaviorlab/__init__.py +4 -0
- singlebehaviorlab/__main__.py +130 -0
- singlebehaviorlab/_paths.py +100 -0
- singlebehaviorlab/backend/__init__.py +2 -0
- singlebehaviorlab/backend/augmentations.py +320 -0
- singlebehaviorlab/backend/data_store.py +420 -0
- singlebehaviorlab/backend/model.py +1290 -0
- singlebehaviorlab/backend/train.py +4667 -0
- singlebehaviorlab/backend/uncertainty.py +578 -0
- singlebehaviorlab/backend/video_processor.py +688 -0
- singlebehaviorlab/backend/video_utils.py +139 -0
- singlebehaviorlab/data/config/config.yaml +85 -0
- singlebehaviorlab/data/training_profiles.json +334 -0
- singlebehaviorlab/gui/__init__.py +4 -0
- singlebehaviorlab/gui/analysis_widget.py +2291 -0
- singlebehaviorlab/gui/attention_export.py +311 -0
- singlebehaviorlab/gui/clip_extraction_widget.py +481 -0
- singlebehaviorlab/gui/clustering_widget.py +3187 -0
- singlebehaviorlab/gui/inference_popups.py +1138 -0
- singlebehaviorlab/gui/inference_widget.py +4550 -0
- singlebehaviorlab/gui/inference_worker.py +651 -0
- singlebehaviorlab/gui/labeling_widget.py +2324 -0
- singlebehaviorlab/gui/main_window.py +754 -0
- singlebehaviorlab/gui/metadata_management_widget.py +1119 -0
- singlebehaviorlab/gui/motion_tracking.py +764 -0
- singlebehaviorlab/gui/overlay_export.py +1234 -0
- singlebehaviorlab/gui/plot_integration.py +729 -0
- singlebehaviorlab/gui/qt_helpers.py +29 -0
- singlebehaviorlab/gui/registration_widget.py +1485 -0
- singlebehaviorlab/gui/review_widget.py +1330 -0
- singlebehaviorlab/gui/segmentation_tracking_widget.py +2752 -0
- singlebehaviorlab/gui/tab_tutorial_dialog.py +312 -0
- singlebehaviorlab/gui/timeline_themes.py +131 -0
- singlebehaviorlab/gui/training_profiles.py +418 -0
- singlebehaviorlab/gui/training_widget.py +3719 -0
- singlebehaviorlab/gui/video_utils.py +233 -0
- singlebehaviorlab/licenses/SAM2-LICENSE +201 -0
- singlebehaviorlab/licenses/VideoPrism-LICENSE +202 -0
- singlebehaviorlab-2.0.0.dist-info/METADATA +447 -0
- singlebehaviorlab-2.0.0.dist-info/RECORD +88 -0
- singlebehaviorlab-2.0.0.dist-info/WHEEL +5 -0
- singlebehaviorlab-2.0.0.dist-info/entry_points.txt +2 -0
- singlebehaviorlab-2.0.0.dist-info/licenses/LICENSE +21 -0
- singlebehaviorlab-2.0.0.dist-info/top_level.txt +3 -0
- videoprism/__init__.py +0 -0
- videoprism/encoders.py +910 -0
- videoprism/layers.py +1136 -0
- videoprism/models.py +407 -0
- videoprism/tokenizers.py +167 -0
- 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
|
+
|