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,2324 @@
1
+ from PyQt6.QtWidgets import (
2
+ QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QComboBox,
3
+ QListWidget, QListWidgetItem, QGroupBox, QMessageBox, QFileDialog, QDialog,
4
+ QDialogButtonBox, QCheckBox, QScrollArea, QSlider, QAbstractItemView,
5
+ QFormLayout, QLineEdit, QSizePolicy, QSpinBox, QProgressBar,
6
+ QInputDialog, QApplication,
7
+ )
8
+ from PyQt6.QtCore import QTimer, Qt, pyqtSignal, QRect, QRectF
9
+ from PyQt6.QtGui import QPixmap, QImage, QPainter, QColor, QPen
10
+ import cv2
11
+ import os
12
+ import random
13
+ import json
14
+ import shutil
15
+ from singlebehaviorlab.backend.data_store import AnnotationManager
16
+
17
+
18
+ class GridOverlayLabel(QLabel):
19
+ """QLabel for video frames with bbox drawing support."""
20
+ bbox_moved = pyqtSignal() # emitted when user finishes moving an existing bbox
21
+
22
+ def __init__(self, parent=None):
23
+ super().__init__(parent)
24
+ self.bbox_enabled = False
25
+ self.bbox_norm = None # (x1, y1, x2, y2) normalized within frame rect
26
+ self._bbox_drag_start = None
27
+ self._bbox_drag_current = None
28
+ self._bbox_moving = False
29
+ self._bbox_move_start = None
30
+ self._frame_rect = QRect() # Region where the scaled frame is drawn
31
+
32
+ def set_bbox_enabled(self, enabled: bool):
33
+ self.bbox_enabled = enabled
34
+ if not enabled:
35
+ self._bbox_drag_start = None
36
+ self._bbox_drag_current = None
37
+ if self.bbox_enabled:
38
+ self.setCursor(Qt.CursorShape.CrossCursor)
39
+ else:
40
+ self.setCursor(Qt.CursorShape.ArrowCursor)
41
+ self.update()
42
+
43
+ def set_bbox_norm(self, bbox_norm):
44
+ """Set normalized bbox as (x1, y1, x2, y2) in [0,1] within frame rect."""
45
+ if not bbox_norm or len(bbox_norm) != 4:
46
+ self.bbox_norm = None
47
+ self.update()
48
+ return
49
+ x1, y1, x2, y2 = [float(v) for v in bbox_norm]
50
+ x1 = max(0.0, min(1.0, x1))
51
+ y1 = max(0.0, min(1.0, y1))
52
+ x2 = max(0.0, min(1.0, x2))
53
+ y2 = max(0.0, min(1.0, y2))
54
+ if x2 <= x1 or y2 <= y1:
55
+ self.bbox_norm = None
56
+ else:
57
+ self.bbox_norm = (x1, y1, x2, y2)
58
+ self.update()
59
+
60
+ def get_bbox_norm(self):
61
+ return list(self.bbox_norm) if self.bbox_norm is not None else None
62
+
63
+ def clear_bbox(self):
64
+ self.bbox_norm = None
65
+ self._bbox_drag_start = None
66
+ self._bbox_drag_current = None
67
+ self.update()
68
+
69
+ def setPixmap(self, pixmap):
70
+ super().setPixmap(pixmap)
71
+ # Compute where the scaled pixmap is drawn (centered in the label)
72
+ if pixmap and not pixmap.isNull():
73
+ label_w, label_h = self.width(), self.height()
74
+ pm_w, pm_h = pixmap.width(), pixmap.height()
75
+ x = (label_w - pm_w) // 2
76
+ y = (label_h - pm_h) // 2
77
+ self._frame_rect = QRect(x, y, pm_w, pm_h)
78
+
79
+ def paintEvent(self, event):
80
+ super().paintEvent(event)
81
+ if self._frame_rect.isEmpty():
82
+ return
83
+
84
+ painter = QPainter(self)
85
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
86
+
87
+ r = self._frame_rect
88
+
89
+ # Draw saved bbox if present
90
+ if self.bbox_enabled and self.bbox_norm is not None:
91
+ bx1, by1, bx2, by2 = self.bbox_norm
92
+ x = r.x() + bx1 * r.width()
93
+ y = r.y() + by1 * r.height()
94
+ w = (bx2 - bx1) * r.width()
95
+ h = (by2 - by1) * r.height()
96
+ bbox_fill = QColor(255, 140, 0, 55)
97
+ bbox_pen = QPen(QColor(255, 140, 0, 220))
98
+ bbox_pen.setWidth(2)
99
+ painter.fillRect(QRectF(x, y, w, h), bbox_fill)
100
+ painter.setPen(bbox_pen)
101
+ painter.drawRect(QRectF(x, y, w, h))
102
+ # Red center dot
103
+ cx = x + w / 2.0
104
+ cy = y + h / 2.0
105
+ painter.setBrush(QColor(255, 0, 0, 220))
106
+ painter.setPen(QPen(QColor(180, 0, 0, 255), 1))
107
+ painter.drawEllipse(QRectF(cx - 4, cy - 4, 8, 8))
108
+
109
+ # Draw live bbox while dragging
110
+ if self.bbox_enabled and self._bbox_drag_start is not None and self._bbox_drag_current is not None:
111
+ drag_rect = QRect(self._bbox_drag_start, self._bbox_drag_current).normalized().intersected(r)
112
+ if drag_rect.width() > 1 and drag_rect.height() > 1:
113
+ live_pen = QPen(QColor(255, 90, 0, 230))
114
+ live_pen.setWidth(2)
115
+ painter.setPen(live_pen)
116
+ painter.fillRect(drag_rect, QColor(255, 90, 0, 45))
117
+ painter.drawRect(drag_rect)
118
+
119
+ painter.end()
120
+
121
+ def _click_inside_bbox(self, pos) -> bool:
122
+ """Check if a click position is inside the current saved bbox."""
123
+ if self.bbox_norm is None or self._frame_rect.isEmpty():
124
+ return False
125
+ r = self._frame_rect
126
+ bx1, by1, bx2, by2 = self.bbox_norm
127
+ px = r.x() + bx1 * r.width()
128
+ py = r.y() + by1 * r.height()
129
+ pw = (bx2 - bx1) * r.width()
130
+ ph = (by2 - by1) * r.height()
131
+ return (px <= pos.x() <= px + pw) and (py <= pos.y() <= py + ph)
132
+
133
+ def mousePressEvent(self, event):
134
+ if self.bbox_enabled and event.button() == Qt.MouseButton.LeftButton:
135
+ if self._frame_rect.contains(event.pos()):
136
+ if self._click_inside_bbox(event.pos()):
137
+ self._bbox_moving = True
138
+ self._bbox_move_start = event.pos()
139
+ else:
140
+ self._bbox_moving = False
141
+ self._bbox_drag_start = event.pos()
142
+ self._bbox_drag_current = event.pos()
143
+ self.update()
144
+ return
145
+ return super().mousePressEvent(event)
146
+
147
+ def mouseMoveEvent(self, event):
148
+ if self.bbox_enabled and (event.buttons() & Qt.MouseButton.LeftButton):
149
+ if getattr(self, '_bbox_moving', False) and self.bbox_norm is not None:
150
+ # Move mode: translate bbox by mouse delta
151
+ r = self._frame_rect
152
+ if r.width() > 0 and r.height() > 0:
153
+ dx_norm = (event.pos().x() - self._bbox_move_start.x()) / r.width()
154
+ dy_norm = (event.pos().y() - self._bbox_move_start.y()) / r.height()
155
+ bx1, by1, bx2, by2 = self.bbox_norm
156
+ w, h = bx2 - bx1, by2 - by1
157
+ new_x1 = bx1 + dx_norm
158
+ new_y1 = by1 + dy_norm
159
+ # Clamp to frame bounds
160
+ new_x1 = max(0.0, min(1.0 - w, new_x1))
161
+ new_y1 = max(0.0, min(1.0 - h, new_y1))
162
+ self.bbox_norm = (new_x1, new_y1, new_x1 + w, new_y1 + h)
163
+ self._bbox_move_start = event.pos()
164
+ self.update()
165
+ return
166
+ if self._bbox_drag_start is not None:
167
+ self._bbox_drag_current = event.pos()
168
+ self.update()
169
+ return
170
+ return super().mouseMoveEvent(event)
171
+
172
+ def mouseReleaseEvent(self, event):
173
+ if self.bbox_enabled and event.button() == Qt.MouseButton.LeftButton:
174
+ if getattr(self, '_bbox_moving', False):
175
+ # End move mode — bbox_norm already updated in mouseMoveEvent
176
+ self._bbox_moving = False
177
+ self._bbox_move_start = None
178
+ self.update()
179
+ self.bbox_moved.emit()
180
+ return
181
+ if self._bbox_drag_start is not None and self._bbox_drag_current is not None:
182
+ drag_rect = QRect(self._bbox_drag_start, self._bbox_drag_current).normalized().intersected(self._frame_rect)
183
+ if drag_rect.width() >= 4 and drag_rect.height() >= 4 and self._frame_rect.width() > 0 and self._frame_rect.height() > 0:
184
+ x1 = (drag_rect.x() - self._frame_rect.x()) / self._frame_rect.width()
185
+ y1 = (drag_rect.y() - self._frame_rect.y()) / self._frame_rect.height()
186
+ x2 = (drag_rect.right() - self._frame_rect.x()) / self._frame_rect.width()
187
+ y2 = (drag_rect.bottom() - self._frame_rect.y()) / self._frame_rect.height()
188
+ x1 = max(0.0, min(1.0, x1))
189
+ y1 = max(0.0, min(1.0, y1))
190
+ x2 = max(0.0, min(1.0, x2))
191
+ y2 = max(0.0, min(1.0, y2))
192
+ if x2 > x1 and y2 > y1:
193
+ self.bbox_norm = (x1, y1, x2, y2)
194
+ self._bbox_drag_start = None
195
+ self._bbox_drag_current = None
196
+ self.update()
197
+ return
198
+ return super().mouseReleaseEvent(event)
199
+
200
+
201
+ class FullScreenLabelingDialog(QDialog):
202
+ """Full screen dialog for video labeling."""
203
+
204
+ def __init__(self, parent=None):
205
+ super().__init__(parent)
206
+ self.parent_widget = parent
207
+ self.setWindowTitle("Full Screen Labeling")
208
+ self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.FramelessWindowHint)
209
+
210
+ self._setup_ui()
211
+
212
+ def _setup_ui(self):
213
+ layout = QVBoxLayout()
214
+ layout.setContentsMargins(0, 0, 0, 0)
215
+ layout.setSpacing(0)
216
+
217
+ # Video Area (Main content)
218
+ self.video_label = QLabel()
219
+ self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
220
+ self.video_label.setStyleSheet("background-color: black;")
221
+ self.video_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
222
+ layout.addWidget(self.video_label)
223
+
224
+ # Controls Overlay (Bottom bar)
225
+ controls_container = QWidget()
226
+ controls_container.setStyleSheet("""
227
+ QWidget { background-color: rgba(40, 40, 40, 200); }
228
+ QPushButton { color: white; border: 1px solid #666; padding: 5px 15px; border-radius: 4px; background-color: #333; font-weight: bold; }
229
+ QPushButton:hover { background-color: #555; }
230
+ QPushButton:pressed { background-color: #222; }
231
+ QLabel { color: white; font-weight: bold; }
232
+ QComboBox { background-color: #333; color: white; border: 1px solid #666; padding: 5px; }
233
+ QComboBox::drop-down { border: none; }
234
+ """)
235
+ controls_root = QVBoxLayout(controls_container)
236
+ controls_root.setContentsMargins(10, 8, 10, 8)
237
+ controls_root.setSpacing(8)
238
+
239
+ # Row 1: Playback + class
240
+ row1 = QHBoxLayout()
241
+ self.info_label = QLabel()
242
+ row1.addWidget(self.info_label)
243
+ row1.addStretch()
244
+ self.prev_btn = QPushButton("◀ Prev (A)")
245
+ self.prev_btn.clicked.connect(self.parent_widget._prev_clip)
246
+ row1.addWidget(self.prev_btn)
247
+ self.play_btn = QPushButton("Play/Pause (Space)")
248
+ self.play_btn.clicked.connect(self.parent_widget._toggle_play)
249
+ row1.addWidget(self.play_btn)
250
+ self.next_btn = QPushButton("Next ▶ (D)")
251
+ self.next_btn.clicked.connect(self.parent_widget._next_clip)
252
+ row1.addWidget(self.next_btn)
253
+ self.random_btn = QPushButton("Random (R)")
254
+ self.random_btn.clicked.connect(self.parent_widget._random_clip)
255
+ row1.addWidget(self.random_btn)
256
+
257
+ class_col = QVBoxLayout()
258
+ class_col.setSpacing(4)
259
+ fs_frame_nav_row = QHBoxLayout()
260
+ fs_frame_nav_row.setSpacing(4)
261
+ self.fs_prev_frame_btn = QPushButton("Prev frame (Q)")
262
+ self.fs_prev_frame_btn.clicked.connect(self.parent_widget._prev_frame)
263
+ fs_frame_nav_row.addWidget(self.fs_prev_frame_btn)
264
+ self.fs_next_frame_btn = QPushButton("Next frame (E)")
265
+ self.fs_next_frame_btn.clicked.connect(self.parent_widget._next_frame)
266
+ fs_frame_nav_row.addWidget(self.fs_next_frame_btn)
267
+ class_col.addLayout(fs_frame_nav_row)
268
+
269
+ fs_class_row = QHBoxLayout()
270
+ fs_class_row.addWidget(QLabel("Class:"))
271
+ self.class_combo = QComboBox()
272
+ self.class_combo.setMinimumWidth(180)
273
+ self.update_classes()
274
+ self.class_combo.activated.connect(self._on_class_selected)
275
+ fs_class_row.addWidget(self.class_combo)
276
+ class_col.addLayout(fs_class_row)
277
+ row1.addSpacing(20)
278
+ row1.addLayout(class_col)
279
+ controls_root.addLayout(row1)
280
+
281
+ # Row 2: Per-frame labeling for clip mode
282
+ row_pf = QHBoxLayout()
283
+ self.fs_frame_info_label = QLabel("Frame: - / -")
284
+ self.fs_frame_info_label.setMinimumWidth(180)
285
+ row_pf.addWidget(self.fs_frame_info_label)
286
+ self.fs_frame_label_bar = QLabel()
287
+ self.fs_frame_label_bar.setFixedHeight(14)
288
+ self.fs_frame_label_bar.setMinimumWidth(180)
289
+ self.fs_frame_label_bar.setStyleSheet("background: #4b5563; border: 1px solid #6b7280; border-radius: 3px;")
290
+ row_pf.addWidget(self.fs_frame_label_bar, 1)
291
+ self.fs_mark_in_btn = QPushButton("In")
292
+ self.fs_mark_in_btn.setFixedWidth(34)
293
+ self.fs_mark_in_btn.clicked.connect(self.parent_widget._fl_mark_in)
294
+ row_pf.addWidget(self.fs_mark_in_btn)
295
+ self.fs_range_label = QLabel("—")
296
+ self.fs_range_label.setFixedWidth(60)
297
+ self.fs_range_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
298
+ row_pf.addWidget(self.fs_range_label)
299
+ self.fs_mark_out_btn = QPushButton("Out")
300
+ self.fs_mark_out_btn.setFixedWidth(40)
301
+ self.fs_mark_out_btn.clicked.connect(self.parent_widget._fl_mark_out)
302
+ row_pf.addWidget(self.fs_mark_out_btn)
303
+ self.fs_fl_class_combo = QComboBox()
304
+ self.fs_fl_class_combo.setMinimumWidth(140)
305
+ self.fs_fl_class_combo.currentTextChanged.connect(self._on_fs_fl_class_changed)
306
+ row_pf.addWidget(self.fs_fl_class_combo)
307
+ self.fs_apply_btn = QPushButton("Apply")
308
+ self.fs_apply_btn.clicked.connect(self._apply_fs_frame_label)
309
+ row_pf.addWidget(self.fs_apply_btn)
310
+ self.fs_clear_btn = QPushButton("Clr")
311
+ self.fs_clear_btn.clicked.connect(self.parent_widget._fl_clear_labels)
312
+ row_pf.addWidget(self.fs_clear_btn)
313
+ controls_root.addLayout(row_pf)
314
+
315
+ # Row 3: Position scroll bar (drag to move through video / clip)
316
+ row3 = QHBoxLayout()
317
+ self.fs_position_label = QLabel("Position:")
318
+ row3.addWidget(self.fs_position_label)
319
+ self.fs_position_slider = QSlider(Qt.Orientation.Horizontal)
320
+ self.fs_position_slider.setMinimum(0)
321
+ self.fs_position_slider.setMaximum(0)
322
+ self.fs_position_slider.setMinimumHeight(22)
323
+ self.fs_position_slider.valueChanged.connect(self._on_fs_position_slider_changed)
324
+ row3.addWidget(self.fs_position_slider, 1)
325
+ controls_root.addLayout(row3)
326
+
327
+ # Add container to layout
328
+ layout.addWidget(controls_container)
329
+ self.setLayout(layout)
330
+
331
+ # Floating Close Button (Top-Right)
332
+ self.close_btn = QPushButton("✕", self)
333
+ self.close_btn.setFixedSize(50, 50)
334
+ self.close_btn.setStyleSheet("""
335
+ QPushButton {
336
+ background-color: rgba(0, 0, 0, 100);
337
+ color: white;
338
+ font-weight: bold;
339
+ font-size: 24px;
340
+ border: none;
341
+ border-radius: 25px;
342
+ }
343
+ QPushButton:hover {
344
+ background-color: rgba(200, 0, 0, 150);
345
+ }
346
+ """)
347
+ self.close_btn.clicked.connect(self.close)
348
+ self.close_btn.setCursor(Qt.CursorShape.PointingHandCursor)
349
+ self.close_btn.raise_()
350
+
351
+ def resizeEvent(self, event):
352
+ super().resizeEvent(event)
353
+ # Position top-right with margin
354
+ self.close_btn.move(self.width() - 60, 10)
355
+
356
+ def showEvent(self, event):
357
+ super().showEvent(event)
358
+ # Deferred refresh so layout/geometry is ready (fixes freeze on reopen).
359
+ QTimer.singleShot(0, self.parent_widget._refresh_fullscreen_from_current_state)
360
+ QTimer.singleShot(80, self.parent_widget._refresh_fullscreen_from_current_state)
361
+
362
+ def update_classes(self):
363
+ """Sync class list with parent."""
364
+ self.class_combo.blockSignals(True)
365
+ self.class_combo.clear()
366
+ # Copy items from parent's combo
367
+ parent_combo = self.parent_widget.class_combo
368
+ for i in range(parent_combo.count()):
369
+ self.class_combo.addItem(parent_combo.itemText(i))
370
+ self.class_combo.setCurrentIndex(parent_combo.currentIndex())
371
+ self.class_combo.blockSignals(False)
372
+ if hasattr(self.parent_widget, "fl_class_combo") and hasattr(self, "fs_fl_class_combo"):
373
+ current = self.parent_widget.fl_class_combo.currentText()
374
+ self.fs_fl_class_combo.blockSignals(True)
375
+ self.fs_fl_class_combo.clear()
376
+ for i in range(self.parent_widget.fl_class_combo.count()):
377
+ self.fs_fl_class_combo.addItem(self.parent_widget.fl_class_combo.itemText(i))
378
+ idx = self.fs_fl_class_combo.findText(current)
379
+ if idx >= 0:
380
+ self.fs_fl_class_combo.setCurrentIndex(idx)
381
+ self.fs_fl_class_combo.blockSignals(False)
382
+
383
+ def update_info(self, text):
384
+ self.info_label.setText(text)
385
+
386
+ def _on_class_selected(self):
387
+ # Sync selection back to parent and save
388
+ idx = self.class_combo.currentIndex()
389
+ self.parent_widget.class_combo.setCurrentIndex(idx)
390
+ self.parent_widget._save_label()
391
+
392
+ def _on_fs_fl_class_changed(self, text: str):
393
+ if hasattr(self.parent_widget, "fl_class_combo"):
394
+ idx = self.parent_widget.fl_class_combo.findText(text)
395
+ if idx >= 0:
396
+ self.parent_widget.fl_class_combo.setCurrentIndex(idx)
397
+
398
+ def _apply_fs_frame_label(self):
399
+ self._on_fs_fl_class_changed(self.fs_fl_class_combo.currentText())
400
+ self.parent_widget._fl_apply_label()
401
+
402
+ def sync_per_frame_controls(self):
403
+ clip_mode = bool(self.parent_widget.current_frames) and not self.parent_widget._is_source_video_mode()
404
+ self.fs_frame_info_label.setVisible(clip_mode)
405
+ self.fs_frame_label_bar.setVisible(clip_mode)
406
+ self.fs_mark_in_btn.setVisible(clip_mode)
407
+ self.fs_range_label.setVisible(clip_mode)
408
+ self.fs_mark_out_btn.setVisible(clip_mode)
409
+ self.fs_fl_class_combo.setVisible(clip_mode)
410
+ self.fs_apply_btn.setVisible(clip_mode)
411
+ self.fs_clear_btn.setVisible(clip_mode)
412
+ self.fs_prev_frame_btn.setVisible(clip_mode)
413
+ self.fs_next_frame_btn.setVisible(clip_mode)
414
+ if not clip_mode:
415
+ return
416
+ self.fs_frame_info_label.setText(self.parent_widget.frame_label.text())
417
+ self.fs_range_label.setText(self.parent_widget.fl_range_label.text())
418
+ pixmap = self.parent_widget.frame_label_bar.pixmap()
419
+ self.fs_frame_label_bar.setPixmap(pixmap if pixmap is not None else QPixmap())
420
+ current = self.parent_widget.fl_class_combo.currentText()
421
+ idx = self.fs_fl_class_combo.findText(current)
422
+ if idx >= 0:
423
+ self.fs_fl_class_combo.blockSignals(True)
424
+ self.fs_fl_class_combo.setCurrentIndex(idx)
425
+ self.fs_fl_class_combo.blockSignals(False)
426
+
427
+ def update_scrubber(self):
428
+ """Sync position slider with parent's current mode."""
429
+ source_mode = self.parent_widget._is_source_video_mode()
430
+ clip_mode = bool(self.parent_widget.current_frames) and not source_mode
431
+ self.fs_position_slider.setEnabled(source_mode or clip_mode)
432
+ self.fs_position_label.setText("Position:" if source_mode else "Frame:")
433
+ self.sync_per_frame_controls()
434
+ if clip_mode:
435
+ self.fs_position_slider.blockSignals(True)
436
+ self.fs_position_slider.setMinimum(0)
437
+ self.fs_position_slider.setMaximum(max(0, len(self.parent_widget.current_frames) - 1))
438
+ self.fs_position_slider.setValue(int(self.parent_widget.current_frame_idx))
439
+ self.fs_position_slider.blockSignals(False)
440
+ return
441
+ if source_mode:
442
+ max_frame = max(0, int(self.parent_widget.current_source_frame_count) - 1)
443
+ curr = int(self.parent_widget.current_source_frame)
444
+ self.fs_position_slider.blockSignals(True)
445
+ self.fs_position_slider.setMinimum(0)
446
+ self.fs_position_slider.setMaximum(max_frame)
447
+ self.fs_position_slider.setValue(max(0, min(max_frame, curr)))
448
+ self.fs_position_slider.blockSignals(False)
449
+ return
450
+ self.fs_position_slider.blockSignals(True)
451
+ self.fs_position_slider.setMaximum(0)
452
+ self.fs_position_slider.setValue(0)
453
+ self.fs_position_slider.blockSignals(False)
454
+
455
+ def _on_fs_position_slider_changed(self, value: int):
456
+ if self.parent_widget._is_source_video_mode():
457
+ self.parent_widget._display_source_frame(value)
458
+ if hasattr(self.parent_widget, "source_scrub_slider"):
459
+ self.parent_widget.source_scrub_slider.blockSignals(True)
460
+ self.parent_widget.source_scrub_slider.setValue(int(value))
461
+ self.parent_widget.source_scrub_slider.blockSignals(False)
462
+ return
463
+ if not self.parent_widget.current_frames:
464
+ return
465
+ self.parent_widget._capture_current_frame_bbox()
466
+ self.parent_widget.current_frame_idx = int(value)
467
+ self.parent_widget.frame_slider.blockSignals(True)
468
+ self.parent_widget.frame_slider.setValue(int(value))
469
+ self.parent_widget.frame_slider.blockSignals(False)
470
+ self.parent_widget._sync_bbox_to_frame()
471
+ self.parent_widget._display_frame()
472
+
473
+ def keyPressEvent(self, event):
474
+ if event.key() == Qt.Key.Key_Escape:
475
+ self.close()
476
+ elif event.key() == Qt.Key.Key_Space:
477
+ self.parent_widget._toggle_play()
478
+ elif event.key() == Qt.Key.Key_A:
479
+ self.parent_widget._prev_clip()
480
+ elif event.key() == Qt.Key.Key_D:
481
+ self.parent_widget._next_clip()
482
+ elif event.key() == Qt.Key.Key_R:
483
+ self.parent_widget._random_clip()
484
+ elif event.key() == Qt.Key.Key_Q:
485
+ self.parent_widget._prev_frame()
486
+ elif event.key() == Qt.Key.Key_E:
487
+ self.parent_widget._next_frame()
488
+ elif event.key() >= Qt.Key.Key_1 and event.key() <= Qt.Key.Key_9:
489
+ idx = event.key() - Qt.Key.Key_1
490
+ if idx < self.class_combo.count():
491
+ self.class_combo.setCurrentIndex(idx)
492
+ self._on_class_selected()
493
+ else:
494
+ super().keyPressEvent(event)
495
+
496
+
497
+ class LabelingWidget(QWidget):
498
+ """Widget for labeling video clips."""
499
+
500
+ def __init__(self, config: dict):
501
+ super().__init__()
502
+ self.config = config
503
+ self.annotation_manager = AnnotationManager(
504
+ self.config.get("annotation_file", "data/annotations/annotations.json")
505
+ )
506
+ self.current_clip_path = None
507
+ self.current_frames = []
508
+ self.current_frame_idx = 0
509
+ self.is_playing = False
510
+ self.frame_bboxes = {} # {frame_idx: [x1, y1, x2, y2]} per-frame bboxes
511
+ self.clip_base_dir = self.config.get("clips_dir", "data/clips")
512
+ self.fullscreen_dialog = None
513
+ self.zoom_factor = 1.0
514
+ self.zoom_min = 0.5
515
+ self.zoom_max = 4.0
516
+ self.zoom_step = 0.2
517
+ self._base_display_pixmap = None
518
+ self.source_video_paths = []
519
+ self.current_source_video_path = None
520
+ self.current_source_cap = None
521
+ self.current_source_frame = 0
522
+ self.current_source_frame_count = 0
523
+ self._setup_ui()
524
+ self.refresh_clip_list()
525
+
526
+ def _setup_ui(self):
527
+ """Setup UI components."""
528
+ main_layout = QHBoxLayout()
529
+ left_column = QWidget()
530
+ left_column.setMaximumWidth(420)
531
+ left_column_layout = QVBoxLayout(left_column)
532
+ left_column_layout.setContentsMargins(0, 0, 0, 0)
533
+
534
+ def _wrap_scroll(widget: QWidget, max_height: int, min_height: int = 0) -> QScrollArea:
535
+ scroll = QScrollArea()
536
+ scroll.setWidgetResizable(True)
537
+ scroll.setFrameShape(QScrollArea.Shape.NoFrame)
538
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
539
+ scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
540
+ if min_height > 0:
541
+ scroll.setMinimumHeight(min_height)
542
+ scroll.setMaximumHeight(max_height)
543
+ scroll.setWidget(widget)
544
+ return scroll
545
+
546
+ source_group = QGroupBox("Source Videos")
547
+ source_layout = QVBoxLayout()
548
+ source_video_row = QHBoxLayout()
549
+ self.source_video_list = QListWidget()
550
+ self.source_video_list.setMaximumHeight(90)
551
+ self.source_video_list.itemSelectionChanged.connect(self._on_source_video_selected)
552
+ source_video_row.addWidget(self.source_video_list, 1)
553
+ source_video_btn_col = QVBoxLayout()
554
+ self.add_source_video_btn = QPushButton("Add videos...")
555
+ self.add_source_video_btn.clicked.connect(self.open_timeline_import_dialog)
556
+ source_video_btn_col.addWidget(self.add_source_video_btn)
557
+ self.remove_source_video_btn = QPushButton("Remove selected")
558
+ self.remove_source_video_btn.clicked.connect(self._remove_selected_source_video)
559
+ source_video_btn_col.addWidget(self.remove_source_video_btn)
560
+ self.clear_source_video_btn = QPushButton("Clear all")
561
+ self.clear_source_video_btn.clicked.connect(self._clear_source_videos)
562
+ source_video_btn_col.addWidget(self.clear_source_video_btn)
563
+ source_video_btn_col.addStretch()
564
+ source_video_row.addLayout(source_video_btn_col)
565
+ source_layout.addLayout(source_video_row)
566
+ source_group.setLayout(source_layout)
567
+ left_column_layout.addWidget(source_group)
568
+
569
+ extract_group = QGroupBox("Clip Extraction")
570
+ extract_layout = QFormLayout()
571
+ self.ea_target_fps_spin = QSpinBox()
572
+ self.ea_target_fps_spin.setRange(1, 240)
573
+ self.ea_target_fps_spin.setValue(int(self.config.get("default_target_fps", 12)))
574
+ extract_layout.addRow("Target FPS:", self.ea_target_fps_spin)
575
+ self.ea_clip_length_spin = QSpinBox()
576
+ self.ea_clip_length_spin.setRange(1, 128)
577
+ self.ea_clip_length_spin.setValue(int(self.config.get("default_clip_length", 8)))
578
+ extract_layout.addRow("Frames/clip:", self.ea_clip_length_spin)
579
+ self.ea_step_spin = QSpinBox()
580
+ self.ea_step_spin.setRange(1, 1000)
581
+ self.ea_step_spin.setValue(int(self.config.get("default_step_frames", 8)))
582
+ extract_layout.addRow("Step (subsampled frames):", self.ea_step_spin)
583
+ self.ea_max_clips_spin = QSpinBox()
584
+ self.ea_max_clips_spin.setRange(0, 100000)
585
+ self.ea_max_clips_spin.setValue(0)
586
+ self.ea_max_clips_spin.setToolTip("Max clips per video (0 = unlimited)")
587
+ extract_layout.addRow("Max clips/video:", self.ea_max_clips_spin)
588
+ self.ea_output_dir_edit = QLineEdit(self.clip_base_dir)
589
+ ea_output_browse_btn = QPushButton("...")
590
+ ea_output_browse_btn.clicked.connect(self._browse_output_dir)
591
+ output_row = QHBoxLayout()
592
+ output_row.addWidget(self.ea_output_dir_edit, 1)
593
+ output_row.addWidget(ea_output_browse_btn)
594
+ output_widget = QWidget()
595
+ output_widget.setLayout(output_row)
596
+ extract_layout.addRow("Output:", output_widget)
597
+ ea_btn_row = QHBoxLayout()
598
+ self.extract_all_btn = QPushButton("Extract all clips")
599
+ self.extract_all_btn.setToolTip(
600
+ "Slide a window over the entire video and extract every clip as unlabeled.\n"
601
+ "Use 'Show unlabeled only' and 'Next unlabeled' to browse and label them."
602
+ )
603
+ self.extract_all_btn.clicked.connect(self._extract_all_clips_from_videos)
604
+ ea_btn_row.addWidget(self.extract_all_btn)
605
+ self.ea_progress = QProgressBar()
606
+ self.ea_progress.setVisible(False)
607
+ ea_btn_row.addWidget(self.ea_progress, 1)
608
+ extract_layout.addRow(ea_btn_row)
609
+ self.ea_status_label = QLabel("")
610
+ self.ea_status_label.setWordWrap(True)
611
+ extract_layout.addRow(self.ea_status_label)
612
+ extract_group.setLayout(extract_layout)
613
+ left_column_layout.addWidget(extract_group)
614
+
615
+ left_group = QGroupBox("Clips")
616
+ left_group_layout = QVBoxLayout()
617
+ video_filter_layout = QHBoxLayout()
618
+ video_filter_layout.addWidget(QLabel("Filter by video:"))
619
+ self.video_filter_combo = QComboBox()
620
+ self.video_filter_combo.addItem("All Videos")
621
+ self.video_filter_combo.currentTextChanged.connect(self._on_video_filter_changed)
622
+ video_filter_layout.addWidget(self.video_filter_combo)
623
+ left_group_layout.addLayout(video_filter_layout)
624
+ class_filter_layout = QHBoxLayout()
625
+ class_filter_layout.addWidget(QLabel("Filter by class:"))
626
+ self.class_filter_combo = QComboBox()
627
+ self.class_filter_combo.addItem("All Classes")
628
+ self.class_filter_combo.currentTextChanged.connect(self._on_class_filter_changed)
629
+ class_filter_layout.addWidget(self.class_filter_combo)
630
+ left_group_layout.addLayout(class_filter_layout)
631
+ self.clip_list = QListWidget()
632
+ self.clip_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
633
+ self.clip_list.itemClicked.connect(self._on_clip_selected)
634
+ left_group_layout.addWidget(self.clip_list)
635
+ filter_layout = QHBoxLayout()
636
+ self.show_unlabeled_btn = QPushButton("Show unlabeled only")
637
+ self.show_unlabeled_btn.clicked.connect(self._filter_unlabeled)
638
+ self.show_all_btn = QPushButton("Show All")
639
+ self.show_all_btn.clicked.connect(self._show_all_clips)
640
+ filter_layout.addWidget(self.show_unlabeled_btn)
641
+ filter_layout.addWidget(self.show_all_btn)
642
+ left_group_layout.addLayout(filter_layout)
643
+ self.next_unlabeled_btn = QPushButton("Next unlabeled")
644
+ self.next_unlabeled_btn.clicked.connect(self._next_unlabeled)
645
+ left_group_layout.addWidget(self.next_unlabeled_btn)
646
+ left_group.setLayout(left_group_layout)
647
+ left_column_layout.addWidget(left_group, 1)
648
+
649
+ main_layout.addWidget(left_column, 0)
650
+ right_panel = QVBoxLayout()
651
+ right_panel.setAlignment(Qt.AlignmentFlag.AlignTop)
652
+
653
+ video_group = QGroupBox("Video player")
654
+ video_layout = QVBoxLayout()
655
+
656
+ self.video_scroll = QScrollArea()
657
+ self.video_scroll.setWidgetResizable(False)
658
+ self.video_scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
659
+ self.video_scroll.setMinimumSize(480, 320)
660
+ self.video_scroll.setStyleSheet("background-color: black; border: none;")
661
+ self.video_scroll.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
662
+
663
+ self.video_label = GridOverlayLabel("No clip selected")
664
+ self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
665
+ self.video_label.setStyleSheet("background-color: black; color: white;")
666
+ self.video_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
667
+ self.video_label.setMouseTracking(True)
668
+ self.video_label.bbox_moved.connect(self._on_bbox_moved)
669
+ self.video_scroll.setWidget(self.video_label)
670
+ video_layout.addWidget(self.video_scroll)
671
+
672
+ self.btn_zoom_in = QPushButton("+", self.video_scroll.viewport())
673
+ self.btn_zoom_out = QPushButton("-", self.video_scroll.viewport())
674
+ self._style_zoom_button(self.btn_zoom_in)
675
+ self._style_zoom_button(self.btn_zoom_out)
676
+ self.btn_zoom_in.clicked.connect(self._zoom_in)
677
+ self.btn_zoom_out.clicked.connect(self._zoom_out)
678
+ self._position_zoom_buttons()
679
+
680
+ controls_layout = QHBoxLayout()
681
+
682
+ self.prev_clip_btn = QPushButton("< Previous")
683
+ self.prev_clip_btn.setToolTip("Go to previous clip")
684
+ self.prev_clip_btn.clicked.connect(self._prev_clip)
685
+ controls_layout.addWidget(self.prev_clip_btn)
686
+
687
+ self.play_pause_btn = QPushButton("Play")
688
+ self.play_pause_btn.clicked.connect(self._toggle_play)
689
+ self.play_pause_btn.setEnabled(False)
690
+ controls_layout.addWidget(self.play_pause_btn)
691
+
692
+ self.next_clip_btn = QPushButton("Next >")
693
+ self.next_clip_btn.setToolTip("Go to next clip")
694
+ self.next_clip_btn.clicked.connect(self._next_clip)
695
+ controls_layout.addWidget(self.next_clip_btn)
696
+
697
+ self.random_clip_btn = QPushButton("Random")
698
+ self.random_clip_btn.setToolTip("Select a random clip")
699
+ self.random_clip_btn.clicked.connect(self._random_clip)
700
+ controls_layout.addWidget(self.random_clip_btn)
701
+
702
+ controls_layout.addStretch()
703
+
704
+ self.fullscreen_btn = QPushButton("⛶ Full Screen")
705
+ self.fullscreen_btn.setToolTip("Open clip in full screen mode for easier labeling")
706
+ self.fullscreen_btn.clicked.connect(self._open_fullscreen)
707
+ controls_layout.addWidget(self.fullscreen_btn)
708
+
709
+ video_layout.addLayout(controls_layout)
710
+ video_layout.addSpacing(6)
711
+
712
+ # Frame scrubber for per-frame bbox annotation
713
+ frame_slider_layout = QHBoxLayout()
714
+ self.frame_label = QLabel("Frame: 0/0")
715
+ self.frame_label.setFixedWidth(90)
716
+ frame_slider_layout.addWidget(self.frame_label)
717
+ self.frame_slider = QSlider(Qt.Orientation.Horizontal)
718
+ self.frame_slider.setMinimum(0)
719
+ self.frame_slider.setMaximum(0)
720
+ self.frame_slider.setEnabled(False)
721
+ self.frame_slider.valueChanged.connect(self._on_frame_slider_changed)
722
+ frame_slider_layout.addWidget(self.frame_slider)
723
+ self.frame_prev_btn = QPushButton("◀")
724
+ self.frame_prev_btn.setFixedWidth(32)
725
+ self.frame_prev_btn.setToolTip("Previous frame (Q)")
726
+ self.frame_prev_btn.clicked.connect(self._prev_frame)
727
+ frame_slider_layout.addWidget(self.frame_prev_btn)
728
+ self.frame_next_btn = QPushButton("▶")
729
+ self.frame_next_btn.setFixedWidth(32)
730
+ self.frame_next_btn.setToolTip("Next frame (E)")
731
+ self.frame_next_btn.clicked.connect(self._next_frame)
732
+ frame_slider_layout.addWidget(self.frame_next_btn)
733
+ video_layout.addLayout(frame_slider_layout)
734
+
735
+ video_group.setLayout(video_layout)
736
+ right_panel.addWidget(video_group, 3)
737
+
738
+ # Source video scrubber (visible only when a source video is loaded)
739
+ self.source_scrub_widget = QWidget()
740
+ scrub_layout = QHBoxLayout(self.source_scrub_widget)
741
+ scrub_layout.setContentsMargins(4, 2, 4, 2)
742
+ self.source_frame_label = QLabel("Frame: — / —")
743
+ self.source_frame_label.setFixedWidth(130)
744
+ self.source_frame_label.setStyleSheet("font-weight: 600;")
745
+ scrub_layout.addWidget(self.source_frame_label)
746
+ self.source_scrub_slider = QSlider(Qt.Orientation.Horizontal)
747
+ self.source_scrub_slider.setMinimum(0)
748
+ self.source_scrub_slider.setMaximum(0)
749
+ self.source_scrub_slider.setMinimumHeight(24)
750
+ self.source_scrub_slider.valueChanged.connect(self._on_source_scrub_changed)
751
+ scrub_layout.addWidget(self.source_scrub_slider, 1)
752
+ self.source_scrub_widget.setVisible(False)
753
+ right_panel.addWidget(self.source_scrub_widget)
754
+
755
+ label_group = QGroupBox("Labeling")
756
+ label_layout = QVBoxLayout()
757
+
758
+ label_controls = QHBoxLayout()
759
+ label_controls.addWidget(QLabel("Behavior class:"))
760
+ self.class_combo = QComboBox()
761
+ self.class_combo.activated.connect(self._save_label)
762
+ label_controls.addWidget(self.class_combo)
763
+ self.multi_label_btn = QPushButton("Multi")
764
+ self.multi_label_btn.setToolTip("Assign multiple labels to this clip (for OvR multi-label training)")
765
+ self.multi_label_btn.setFixedWidth(50)
766
+ self.multi_label_btn.clicked.connect(self._open_multi_label_dialog)
767
+ label_controls.addWidget(self.multi_label_btn)
768
+ label_layout.addLayout(label_controls)
769
+
770
+ class_buttons_layout = QHBoxLayout()
771
+ self.add_class_btn = QPushButton("Add...")
772
+ self.add_class_btn.setToolTip("Add new behavior class")
773
+ self.add_class_btn.clicked.connect(self._add_class)
774
+ self.rename_class_btn = QPushButton("Rename...")
775
+ self.rename_class_btn.setToolTip("Rename selected behavior class")
776
+ self.rename_class_btn.clicked.connect(self._rename_class)
777
+ self.remove_class_btn = QPushButton("Remove...")
778
+ self.remove_class_btn.setToolTip("Remove selected behavior class")
779
+ self.remove_class_btn.clicked.connect(self._remove_class)
780
+ class_buttons_layout.addWidget(self.add_class_btn)
781
+ class_buttons_layout.addWidget(self.rename_class_btn)
782
+ class_buttons_layout.addWidget(self.remove_class_btn)
783
+ label_layout.addLayout(class_buttons_layout)
784
+
785
+ self._update_class_combo()
786
+
787
+ label_layout.addWidget(QLabel("Keyboard shortcuts: 1-9 for classes, Space to play/pause"))
788
+
789
+ # Per-frame labeling controls (integrated into label group)
790
+ fl_separator = QLabel("Per-frame labeling:")
791
+ fl_separator.setStyleSheet("font-weight: 600; font-size: 11px; margin-top: 4px;")
792
+ fl_separator.setToolTip(
793
+ "Label individual frame ranges within a clip.\n"
794
+ "Navigate to a frame, click 'Mark In', navigate to end, click 'Mark Out',\n"
795
+ "then click 'Apply' to assign the selected class to that range."
796
+ )
797
+ label_layout.addWidget(fl_separator)
798
+
799
+ self.frame_label_bar = QLabel()
800
+ self.frame_label_bar.setFixedHeight(14)
801
+ self.frame_label_bar.setStyleSheet("background: #e5e7eb; border: 1px solid #c0c8d2; border-radius: 3px;")
802
+ label_layout.addWidget(self.frame_label_bar)
803
+
804
+ fl_controls = QHBoxLayout()
805
+ fl_controls.setSpacing(3)
806
+ self.fl_mark_in_btn = QPushButton("In")
807
+ self.fl_mark_in_btn.setToolTip("Set start of frame range (current frame)")
808
+ self.fl_mark_in_btn.setFixedWidth(30)
809
+ self.fl_mark_in_btn.clicked.connect(self._fl_mark_in)
810
+ fl_controls.addWidget(self.fl_mark_in_btn)
811
+
812
+ self.fl_range_label = QLabel("—")
813
+ self.fl_range_label.setFixedWidth(55)
814
+ self.fl_range_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
815
+ self.fl_range_label.setStyleSheet("font-size: 11px;")
816
+ fl_controls.addWidget(self.fl_range_label)
817
+
818
+ self.fl_mark_out_btn = QPushButton("Out")
819
+ self.fl_mark_out_btn.setToolTip("Set end of frame range (current frame)")
820
+ self.fl_mark_out_btn.setFixedWidth(32)
821
+ self.fl_mark_out_btn.clicked.connect(self._fl_mark_out)
822
+ fl_controls.addWidget(self.fl_mark_out_btn)
823
+
824
+ self.fl_class_combo = QComboBox()
825
+ self.fl_class_combo.setMinimumWidth(60)
826
+ fl_controls.addWidget(self.fl_class_combo, 1)
827
+
828
+ self.fl_apply_btn = QPushButton("Apply")
829
+ self.fl_apply_btn.setToolTip("Assign selected class to marked frame range")
830
+ self.fl_apply_btn.setFixedWidth(45)
831
+ self.fl_apply_btn.setStyleSheet(
832
+ "QPushButton { background: #4a5568; color: #e2e8f0; font-weight: 600; border-radius: 4px; }"
833
+ "QPushButton:hover { background: #5a6578; }"
834
+ )
835
+ self.fl_apply_btn.clicked.connect(self._fl_apply_label)
836
+ fl_controls.addWidget(self.fl_apply_btn)
837
+
838
+ self.fl_clear_btn = QPushButton("Clr")
839
+ self.fl_clear_btn.setToolTip("Clear all per-frame labels for this clip")
840
+ self.fl_clear_btn.setFixedWidth(30)
841
+ self.fl_clear_btn.clicked.connect(self._fl_clear_labels)
842
+ fl_controls.addWidget(self.fl_clear_btn)
843
+
844
+ label_layout.addLayout(fl_controls)
845
+
846
+ label_group.setLayout(label_layout)
847
+ right_panel.addWidget(_wrap_scroll(label_group, max_height=260, min_height=200))
848
+
849
+ # State for per-frame labeling
850
+ self._fl_mark_in_frame = None
851
+ self._fl_mark_out_frame = None
852
+ self._fl_current_labels = [] # list of class name or None per frame
853
+
854
+ # Bbox for Localization and Hard-Negative Round side by side
855
+ spatial_round_row = QHBoxLayout()
856
+ spatial_round_row.setContentsMargins(0, 0, 0, 0)
857
+ spatial_group = QGroupBox("Bbox for Localization (optional)")
858
+ spatial_layout = QVBoxLayout()
859
+
860
+ bbox_toggle_layout = QHBoxLayout()
861
+ self.bbox_check = QCheckBox("Draw localization bbox")
862
+ self.bbox_check.setToolTip(
863
+ "Draw a bounding box ROI for this clip. The box is saved in normalized "
864
+ "coordinates and can supervise localization during training."
865
+ )
866
+ self.bbox_check.toggled.connect(self._on_bbox_toggled)
867
+ bbox_toggle_layout.addWidget(self.bbox_check)
868
+ bbox_toggle_layout.addStretch()
869
+ spatial_layout.addLayout(bbox_toggle_layout)
870
+
871
+ bbox_btn_layout = QHBoxLayout()
872
+ self.save_bbox_btn = QPushButton("Save bbox")
873
+ self.save_bbox_btn.setToolTip("Save current drawn bbox for this clip")
874
+ self.save_bbox_btn.clicked.connect(self._save_spatial_bbox)
875
+ self.save_bbox_btn.setEnabled(False)
876
+ bbox_btn_layout.addWidget(self.save_bbox_btn)
877
+
878
+ self.clear_bbox_btn = QPushButton("Clear bbox")
879
+ self.clear_bbox_btn.setToolTip("Clear saved bbox from this clip")
880
+ self.clear_bbox_btn.clicked.connect(self._clear_spatial_bbox)
881
+ self.clear_bbox_btn.setEnabled(False)
882
+ bbox_btn_layout.addWidget(self.clear_bbox_btn)
883
+
884
+ self.bbox_info_label = QLabel("")
885
+ bbox_btn_layout.addWidget(self.bbox_info_label)
886
+ bbox_btn_layout.addStretch()
887
+ spatial_layout.addLayout(bbox_btn_layout)
888
+
889
+ spatial_group.setLayout(spatial_layout)
890
+
891
+ self.round_group = QGroupBox("Hard-Negative Round Dataset (optional)")
892
+ round_layout = QFormLayout()
893
+ self.round_name_edit = QLineEdit("rearing_round2")
894
+ round_layout.addRow("Round name:", self.round_name_edit)
895
+ self.round_target_list = QListWidget()
896
+ self.round_target_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
897
+ self.round_target_list.setMaximumHeight(80)
898
+ round_layout.addRow("Target classes (1+):", self.round_target_list)
899
+ self.round_near_list = QListWidget()
900
+ self.round_near_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
901
+ self.round_near_list.setMaximumHeight(80)
902
+ self.round_near_list.setToolTip(
903
+ "Select one or more near-negative classes to build the negative pool."
904
+ )
905
+ round_layout.addRow("Near-negative classes (1+):", self.round_near_list)
906
+ self.round_neg_per_pos_spin = QSpinBox()
907
+ self.round_neg_per_pos_spin.setRange(1, 10)
908
+ self.round_neg_per_pos_spin.setValue(1)
909
+ round_layout.addRow("Negatives per positive:", self.round_neg_per_pos_spin)
910
+ self.round_negative_output_edit = QLineEdit("other")
911
+ round_layout.addRow("Output negative label:", self.round_negative_output_edit)
912
+ round_build_row = QHBoxLayout()
913
+ self.build_round_btn = QPushButton("Build round dataset")
914
+ self.build_round_btn.clicked.connect(self._build_hard_negative_round_dataset)
915
+ round_build_row.addWidget(self.build_round_btn)
916
+ self.round_status_label = QLabel("")
917
+ self.round_status_label.setWordWrap(True)
918
+ round_build_row.addWidget(self.round_status_label, 1)
919
+ round_layout.addRow(round_build_row)
920
+ self.round_group.setLayout(round_layout)
921
+
922
+ self.round_group.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.MinimumExpanding)
923
+ spatial_group.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.MinimumExpanding)
924
+ spatial_round_row.addWidget(_wrap_scroll(spatial_group, max_height=220, min_height=160), 1)
925
+ spatial_round_row.addWidget(_wrap_scroll(self.round_group, max_height=220, min_height=160), 1)
926
+ right_panel.addLayout(spatial_round_row)
927
+
928
+ main_layout.addLayout(right_panel, 1)
929
+ self.setLayout(main_layout)
930
+
931
+ self.timer = QTimer()
932
+ self.timer.timeout.connect(self._update_frame)
933
+ self.timer.setInterval(100)
934
+
935
+ def resizeEvent(self, event):
936
+ super().resizeEvent(event)
937
+ self._position_zoom_buttons()
938
+ if hasattr(self, '_fl_current_labels'):
939
+ self._fl_update_bar()
940
+
941
+ def _style_zoom_button(self, btn):
942
+ btn.setFixedSize(34, 34)
943
+ btn.setCursor(Qt.CursorShape.PointingHandCursor)
944
+ btn.setStyleSheet(
945
+ "QPushButton {"
946
+ "background-color: rgba(20, 20, 20, 190);"
947
+ "color: white;"
948
+ "border: 1px solid rgba(255,255,255,110);"
949
+ "border-radius: 17px;"
950
+ "font-size: 18px;"
951
+ "font-weight: bold;"
952
+ "}"
953
+ "QPushButton:hover {"
954
+ "background-color: rgba(45, 45, 45, 220);"
955
+ "}"
956
+ )
957
+
958
+ def _position_zoom_buttons(self):
959
+ if not hasattr(self, "video_scroll") or not hasattr(self, "btn_zoom_in"):
960
+ return
961
+ viewport = self.video_scroll.viewport()
962
+ margin = 10
963
+ spacing = 8
964
+ x = viewport.width() - self.btn_zoom_in.width() - margin
965
+ y = margin
966
+ self.btn_zoom_in.move(x, y)
967
+ self.btn_zoom_out.move(x, y + self.btn_zoom_in.height() + spacing)
968
+ self.btn_zoom_in.raise_()
969
+ self.btn_zoom_out.raise_()
970
+
971
+ def _zoom_in(self):
972
+ self.zoom_factor = min(self.zoom_max, self.zoom_factor + self.zoom_step)
973
+ self._apply_zoom()
974
+
975
+ def _zoom_out(self):
976
+ self.zoom_factor = max(self.zoom_min, self.zoom_factor - self.zoom_step)
977
+ self._apply_zoom()
978
+
979
+ def _apply_zoom(self):
980
+ if self._base_display_pixmap is None or self._base_display_pixmap.isNull():
981
+ return
982
+ w = max(1, int(self._base_display_pixmap.width() * self.zoom_factor))
983
+ h = max(1, int(self._base_display_pixmap.height() * self.zoom_factor))
984
+ scaled = self._base_display_pixmap.scaled(
985
+ w,
986
+ h,
987
+ Qt.AspectRatioMode.KeepAspectRatio,
988
+ Qt.TransformationMode.SmoothTransformation,
989
+ )
990
+ self.video_label.setPixmap(scaled)
991
+ self.video_label.resize(scaled.size())
992
+ self._position_zoom_buttons()
993
+
994
+ def _update_class_combo(self):
995
+ """Update class combo box with current classes."""
996
+ self.class_combo.clear()
997
+ classes = self.annotation_manager.get_classes()
998
+ if classes:
999
+ self.class_combo.addItem("(No Label)")
1000
+ self.class_combo.addItems(classes)
1001
+ self.class_combo.setEnabled(True)
1002
+ else:
1003
+ self.class_combo.addItem("(No classes - add one first)")
1004
+ self.class_combo.setEnabled(False)
1005
+
1006
+ if self.fullscreen_dialog:
1007
+ self.fullscreen_dialog.update_classes()
1008
+ self._sync_round_builder_classes(classes)
1009
+ if hasattr(self, 'fl_class_combo'):
1010
+ self._fl_update_class_combo()
1011
+
1012
+ def _add_class(self):
1013
+ """Add a new behavior class."""
1014
+ from PyQt6.QtWidgets import QInputDialog
1015
+ class_name, ok = QInputDialog.getText(self, "Add Class", "Class name:")
1016
+ if ok and class_name.strip():
1017
+ class_name = class_name.strip()
1018
+ existing_classes = self.annotation_manager.get_classes()
1019
+ if class_name in existing_classes:
1020
+ QMessageBox.warning(self, "Error", f"Class '{class_name}' already exists.")
1021
+ return
1022
+ self.annotation_manager.add_class(class_name)
1023
+ self._update_class_combo()
1024
+ self.class_combo.setEnabled(True)
1025
+
1026
+ def _remove_class(self):
1027
+ """Remove a behavior class."""
1028
+ classes = self.annotation_manager.get_classes()
1029
+ if not classes:
1030
+ QMessageBox.information(self, "Info", "No classes to remove.")
1031
+ return
1032
+
1033
+ from PyQt6.QtWidgets import QInputDialog, QMessageBox
1034
+ class_name, ok = QInputDialog.getItem(
1035
+ self,
1036
+ "Remove Class",
1037
+ "Select class to remove:",
1038
+ classes,
1039
+ 0,
1040
+ False
1041
+ )
1042
+
1043
+ if ok and class_name:
1044
+ clips_with_class = [
1045
+ clip for clip in self.annotation_manager.get_all_clips()
1046
+ if clip.get("label") == class_name
1047
+ ]
1048
+
1049
+ if clips_with_class:
1050
+ reply = QMessageBox.question(
1051
+ self,
1052
+ "Confirm Removal",
1053
+ f"Class '{class_name}' is used by {len(clips_with_class)} clip(s).\n\n"
1054
+ "Removing this class will also remove labels from those clips.\n\n"
1055
+ "Do you want to continue?",
1056
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1057
+ QMessageBox.StandardButton.No
1058
+ )
1059
+
1060
+ if reply != QMessageBox.StandardButton.Yes:
1061
+ return
1062
+
1063
+ for clip in clips_with_class:
1064
+ clip_id = clip["id"]
1065
+ self.annotation_manager.add_clip(clip_id, "", clip.get("meta"))
1066
+
1067
+ self.annotation_manager.remove_class(class_name)
1068
+ self._update_class_combo()
1069
+
1070
+ if not self.annotation_manager.get_classes():
1071
+ self.class_combo.setEnabled(False)
1072
+
1073
+ def _rename_class(self):
1074
+ """Rename the currently selected class."""
1075
+ current_class = self.class_combo.currentText()
1076
+ if not current_class or current_class.startswith("(No"):
1077
+ QMessageBox.warning(self, "Error", "Please select a valid class to rename.")
1078
+ return
1079
+
1080
+ from PyQt6.QtWidgets import QInputDialog
1081
+ new_name, ok = QInputDialog.getText(self, "Rename Class",
1082
+ f"Rename '{current_class}' to:",
1083
+ text=current_class)
1084
+
1085
+ if ok and new_name.strip():
1086
+ new_name = new_name.strip()
1087
+ if new_name == current_class:
1088
+ return
1089
+
1090
+ if self.annotation_manager.rename_class(current_class, new_name):
1091
+ self._update_class_combo()
1092
+ self.refresh_clip_list()
1093
+
1094
+ # Reselect the renamed class
1095
+ idx = self.class_combo.findText(new_name)
1096
+ if idx >= 0:
1097
+ self.class_combo.setCurrentIndex(idx)
1098
+ else:
1099
+ QMessageBox.warning(self, "Error", "Failed to rename class.")
1100
+
1101
+ def _on_class_filter_changed(self, class_name):
1102
+ self.refresh_clip_list()
1103
+
1104
+ def refresh_clip_list(self):
1105
+ """Refresh the clip list from disk, applying all filters."""
1106
+ self.annotation_manager.reload()
1107
+ # Store current selection
1108
+ current_item = self.clip_list.currentItem()
1109
+ current_clip_path = current_item.data(Qt.ItemDataRole.UserRole) if current_item else None
1110
+
1111
+ self.clip_list.clear()
1112
+
1113
+ if not os.path.exists(self.clip_base_dir):
1114
+ return
1115
+
1116
+ # 1. Gather all clips
1117
+ clips = []
1118
+ video_dirs = set()
1119
+ for root, dirs, files in os.walk(self.clip_base_dir):
1120
+ for file in files:
1121
+ if file.endswith(('.mp4', '.avi', '.mov', '.mkv')):
1122
+ rel_path = os.path.relpath(os.path.join(root, file), self.clip_base_dir)
1123
+ clips.append(rel_path.replace('\\', '/'))
1124
+ video_dir = os.path.dirname(rel_path)
1125
+ if video_dir:
1126
+ video_dirs.add(video_dir)
1127
+
1128
+ clips.sort()
1129
+ video_dirs = sorted(video_dirs)
1130
+
1131
+ # 2. Update Video Filter Combo (preserve selection)
1132
+ selected_video = self.video_filter_combo.currentText()
1133
+ self.video_filter_combo.blockSignals(True)
1134
+ self.video_filter_combo.clear()
1135
+ self.video_filter_combo.addItem("All Videos")
1136
+ for video_dir in video_dirs:
1137
+ self.video_filter_combo.addItem(video_dir)
1138
+
1139
+ if selected_video in [self.video_filter_combo.itemText(i) for i in range(self.video_filter_combo.count())]:
1140
+ self.video_filter_combo.setCurrentText(selected_video)
1141
+ else:
1142
+ self.video_filter_combo.setCurrentText("All Videos")
1143
+ selected_video = "All Videos"
1144
+ self.video_filter_combo.blockSignals(False)
1145
+
1146
+ # 3. Update Class Filter Combo (preserve selection)
1147
+ classes = self.annotation_manager.get_classes()
1148
+ selected_class = self.class_filter_combo.currentText()
1149
+
1150
+ self.class_filter_combo.blockSignals(True)
1151
+ self.class_filter_combo.clear()
1152
+ self.class_filter_combo.addItem("All Classes")
1153
+ self.class_filter_combo.addItem("Unlabeled")
1154
+ self.class_filter_combo.addItems(classes)
1155
+
1156
+ if selected_class in [self.class_filter_combo.itemText(i) for i in range(self.class_filter_combo.count())]:
1157
+ self.class_filter_combo.setCurrentText(selected_class)
1158
+ else:
1159
+ self.class_filter_combo.setCurrentText("All Classes")
1160
+ selected_class = "All Classes"
1161
+ self.class_filter_combo.blockSignals(False)
1162
+
1163
+ # 4. Filter Clips
1164
+ filtered_clips = clips
1165
+ if selected_video != "All Videos":
1166
+ filtered_clips = [c for c in filtered_clips if c.startswith(selected_video + "/")]
1167
+
1168
+ for clip_path in filtered_clips:
1169
+ label = self.annotation_manager.get_clip_label(clip_path)
1170
+ clip_labels = self.annotation_manager.get_clip_labels(clip_path)
1171
+
1172
+ # Apply Class Filter
1173
+ if selected_class == "All Classes":
1174
+ pass
1175
+ elif selected_class == "Unlabeled":
1176
+ if label:
1177
+ continue
1178
+ else:
1179
+ if selected_class not in clip_labels:
1180
+ continue
1181
+
1182
+ display_text = clip_path
1183
+ if clip_labels and len(clip_labels) > 1:
1184
+ display_text += f" [{', '.join(clip_labels)}]"
1185
+ elif label:
1186
+ display_text += f" [{label}]"
1187
+ item = QListWidgetItem(display_text)
1188
+ item.setData(Qt.ItemDataRole.UserRole, clip_path)
1189
+ self.clip_list.addItem(item)
1190
+
1191
+ # Restore selection
1192
+ if current_clip_path and clip_path == current_clip_path:
1193
+ self.clip_list.setCurrentItem(item)
1194
+
1195
+ def _on_video_filter_changed(self, video_name: str):
1196
+ self.refresh_clip_list()
1197
+
1198
+ def _filter_unlabeled(self):
1199
+ """Filter to show only unlabeled clips."""
1200
+ # Find "Unlabeled" in combo box and select it
1201
+ idx = self.class_filter_combo.findText("Unlabeled")
1202
+ if idx >= 0:
1203
+ self.class_filter_combo.setCurrentIndex(idx)
1204
+ else:
1205
+ # Fallback if somehow missing
1206
+ self.refresh_clip_list()
1207
+
1208
+ def _show_all_clips(self):
1209
+ """Reset class filter to show all clips. Video filter is preserved."""
1210
+ self.class_filter_combo.setCurrentIndex(0)
1211
+ self.refresh_clip_list()
1212
+
1213
+ def _on_clip_selected(self, item: QListWidgetItem):
1214
+ """Handle clip selection."""
1215
+ clip_path = item.data(Qt.ItemDataRole.UserRole)
1216
+ self._load_clip(clip_path)
1217
+
1218
+ def _load_clip(self, clip_path: str):
1219
+ """Load a clip for viewing."""
1220
+ full_path = os.path.join(self.clip_base_dir, clip_path)
1221
+ if not os.path.exists(full_path):
1222
+ QMessageBox.warning(self, "Error", f"Clip not found: {full_path}")
1223
+ return
1224
+
1225
+ self.current_clip_path = clip_path
1226
+ cap = cv2.VideoCapture(full_path)
1227
+ self.current_frames = []
1228
+
1229
+ while True:
1230
+ ret, frame = cap.read()
1231
+ if not ret:
1232
+ break
1233
+ frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
1234
+ self.current_frames.append(frame_rgb)
1235
+
1236
+ cap.release()
1237
+
1238
+ if not self.current_frames:
1239
+ QMessageBox.warning(self, "Error", "Could not load frames from clip.")
1240
+ return
1241
+
1242
+ self.current_frame_idx = 0
1243
+ self.frame_slider.blockSignals(True)
1244
+ self.frame_slider.setMaximum(max(0, len(self.current_frames) - 1))
1245
+ self.frame_slider.setValue(0)
1246
+ self.frame_slider.setEnabled(len(self.current_frames) > 1)
1247
+ self.frame_slider.blockSignals(False)
1248
+ self._load_per_frame_bboxes()
1249
+ self._display_frame()
1250
+ self.play_pause_btn.setEnabled(True)
1251
+
1252
+ # Update Full Screen Info
1253
+ if self.fullscreen_dialog and self.fullscreen_dialog.isVisible():
1254
+ self.fullscreen_dialog.update_info(os.path.basename(clip_path))
1255
+
1256
+ label = self.annotation_manager.get_clip_label(clip_path)
1257
+ target_idx = -1
1258
+
1259
+ if label:
1260
+ idx = self.class_combo.findText(label)
1261
+ if idx >= 0:
1262
+ target_idx = idx
1263
+ else:
1264
+ # Set to "No Label" if unlabeled
1265
+ idx = self.class_combo.findText("(No Label)")
1266
+ if idx >= 0:
1267
+ target_idx = idx
1268
+
1269
+ if target_idx >= 0:
1270
+ self.class_combo.setCurrentIndex(target_idx)
1271
+ # Sync to fullscreen
1272
+ if self.fullscreen_dialog:
1273
+ self.fullscreen_dialog.class_combo.setCurrentIndex(target_idx)
1274
+
1275
+ if self.bbox_check.isChecked():
1276
+ self._load_spatial_bbox_for_clip()
1277
+ # Load per-frame labels
1278
+ self._fl_load_labels()
1279
+ # Sync per-frame class dropdown to this clip (avoid showing previous clip's class)
1280
+ if hasattr(self, "fl_class_combo") and self.fl_class_combo.count():
1281
+ if self._fl_current_labels:
1282
+ from collections import Counter
1283
+ labels = [l for l in self._fl_current_labels if l is not None]
1284
+ target = Counter(labels).most_common(1)[0][0] if labels else label
1285
+ else:
1286
+ target = label
1287
+ if target and (idx := self.fl_class_combo.findText(target)) >= 0:
1288
+ self.fl_class_combo.setCurrentIndex(idx)
1289
+
1290
+ def _open_fullscreen(self):
1291
+ """Open full screen labeling view."""
1292
+ if not self.fullscreen_dialog:
1293
+ self.fullscreen_dialog = FullScreenLabelingDialog(self)
1294
+
1295
+ self.fullscreen_dialog.update_classes()
1296
+ self.fullscreen_dialog.update_scrubber()
1297
+ self.fullscreen_dialog.showFullScreen()
1298
+ self._refresh_fullscreen_from_current_state()
1299
+ QTimer.singleShot(0, self._refresh_fullscreen_from_current_state)
1300
+
1301
+ # Update info text
1302
+ if self.current_clip_path:
1303
+ self.fullscreen_dialog.update_info(os.path.basename(self.current_clip_path))
1304
+
1305
+ def _update_fullscreen_view(self, pixmap: QPixmap, info_text: str = None):
1306
+ if not (self.fullscreen_dialog and self.fullscreen_dialog.isVisible()):
1307
+ return
1308
+ fs_label = self.fullscreen_dialog.video_label
1309
+ target_size = fs_label.size()
1310
+ if target_size.width() < 10 or target_size.height() < 10:
1311
+ target_size = self.fullscreen_dialog.size()
1312
+ if target_size.width() < 10 or target_size.height() < 10:
1313
+ screen = self.fullscreen_dialog.screen()
1314
+ target_size = screen.availableGeometry().size() if screen else pixmap.size()
1315
+ fs_pixmap = pixmap.scaled(
1316
+ target_size,
1317
+ Qt.AspectRatioMode.KeepAspectRatio,
1318
+ Qt.TransformationMode.SmoothTransformation,
1319
+ )
1320
+ fs_label.setPixmap(fs_pixmap)
1321
+ if info_text:
1322
+ self.fullscreen_dialog.update_info(info_text)
1323
+ self.fullscreen_dialog.update_scrubber()
1324
+
1325
+ def _display_frame(self):
1326
+ """Display current frame."""
1327
+ if not self.current_frames:
1328
+ return
1329
+
1330
+ fl_txt = ""
1331
+ if (hasattr(self, '_fl_current_labels') and self._fl_current_labels
1332
+ and self.current_frame_idx < len(self._fl_current_labels)
1333
+ and self._fl_current_labels[self.current_frame_idx] is not None):
1334
+ fl_txt = f" [{self._fl_current_labels[self.current_frame_idx]}]"
1335
+ self.frame_label.setText(f"Frame: {self.current_frame_idx + 1}/{len(self.current_frames)}{fl_txt}")
1336
+ self.frame_label.setFixedWidth(max(90, self.frame_label.sizeHint().width()))
1337
+ frame = self.current_frames[self.current_frame_idx]
1338
+ h, w, c = frame.shape
1339
+ bytes_per_line = c * w
1340
+ q_image = QImage(frame.data, w, h, bytes_per_line, QImage.Format.Format_RGB888)
1341
+ pixmap = QPixmap.fromImage(q_image)
1342
+
1343
+ # Update Main Window (fit-to-viewport base, then apply zoom)
1344
+ if hasattr(self, "video_scroll") and self.video_scroll.viewport().width() > 1:
1345
+ viewport_size = self.video_scroll.viewport().size()
1346
+ self._base_display_pixmap = pixmap.scaled(
1347
+ viewport_size,
1348
+ Qt.AspectRatioMode.KeepAspectRatio,
1349
+ Qt.TransformationMode.SmoothTransformation,
1350
+ )
1351
+ else:
1352
+ self._base_display_pixmap = pixmap
1353
+ self._apply_zoom()
1354
+
1355
+ # Update Full Screen Dialog if open
1356
+ self._update_fullscreen_view(pixmap)
1357
+ if self.fullscreen_dialog and self.fullscreen_dialog.isVisible():
1358
+ self.fullscreen_dialog.sync_per_frame_controls()
1359
+
1360
+ if self.fullscreen_dialog and self.fullscreen_dialog.isVisible():
1361
+ txt = "Pause (Space)" if self.is_playing else "Play (Space)"
1362
+ self.fullscreen_dialog.play_btn.setText(txt)
1363
+
1364
+ def _toggle_play(self):
1365
+ """Toggle play/pause."""
1366
+ if not self.current_frames:
1367
+ return
1368
+
1369
+ self.is_playing = not self.is_playing
1370
+ if self.is_playing:
1371
+ self.play_pause_btn.setText("Pause")
1372
+ self.timer.start()
1373
+ else:
1374
+ self.play_pause_btn.setText("Play")
1375
+ self.timer.stop()
1376
+
1377
+ if self.fullscreen_dialog:
1378
+ txt = "Pause (Space)" if self.is_playing else "Play (Space)"
1379
+ self.fullscreen_dialog.play_btn.setText(txt)
1380
+
1381
+ def _prev_clip(self):
1382
+ """Go to previous clip."""
1383
+ current_row = self.clip_list.currentRow()
1384
+ if current_row > 0:
1385
+ self.clip_list.setCurrentRow(current_row - 1)
1386
+ self._on_clip_selected(self.clip_list.currentItem())
1387
+
1388
+ def _next_clip(self):
1389
+ """Go to next clip."""
1390
+ current_row = self.clip_list.currentRow()
1391
+ if current_row < self.clip_list.count() - 1:
1392
+ self.clip_list.setCurrentRow(current_row + 1)
1393
+ self._on_clip_selected(self.clip_list.currentItem())
1394
+
1395
+ def _random_clip(self):
1396
+ """Go to a random clip."""
1397
+ count = self.clip_list.count()
1398
+ if count > 0:
1399
+ idx = random.randint(0, count - 1)
1400
+ self.clip_list.setCurrentRow(idx)
1401
+ self._on_clip_selected(self.clip_list.currentItem())
1402
+
1403
+ # -- Per-frame bbox helpers --
1404
+
1405
+ def _on_frame_slider_changed(self, value):
1406
+ """User dragged the frame slider."""
1407
+ if not self.current_frames:
1408
+ return
1409
+ self._capture_current_frame_bbox()
1410
+ self.current_frame_idx = value
1411
+ self._sync_bbox_to_frame()
1412
+ self._display_frame()
1413
+
1414
+ def _prev_frame(self):
1415
+ if not self.current_frames:
1416
+ return
1417
+ self._capture_current_frame_bbox()
1418
+ self.current_frame_idx = max(0, self.current_frame_idx - 1)
1419
+ self.frame_slider.blockSignals(True)
1420
+ self.frame_slider.setValue(self.current_frame_idx)
1421
+ self.frame_slider.blockSignals(False)
1422
+ self._sync_bbox_to_frame()
1423
+ self._display_frame()
1424
+
1425
+ def _next_frame(self):
1426
+ if not self.current_frames:
1427
+ return
1428
+ self._capture_current_frame_bbox()
1429
+ self.current_frame_idx = min(len(self.current_frames) - 1, self.current_frame_idx + 1)
1430
+ self.frame_slider.blockSignals(True)
1431
+ self.frame_slider.setValue(self.current_frame_idx)
1432
+ self.frame_slider.blockSignals(False)
1433
+ self._sync_bbox_to_frame()
1434
+ self._display_frame()
1435
+
1436
+ def _on_bbox_moved(self):
1437
+ """Auto-advance to next frame after user finishes repositioning the bbox."""
1438
+ self._capture_current_frame_bbox()
1439
+ if self.current_frames and self.current_frame_idx < len(self.current_frames) - 1:
1440
+ self.current_frame_idx += 1
1441
+ self.frame_slider.blockSignals(True)
1442
+ self.frame_slider.setValue(self.current_frame_idx)
1443
+ self.frame_slider.blockSignals(False)
1444
+ self._sync_bbox_to_frame()
1445
+ self._display_frame()
1446
+
1447
+ def _capture_current_frame_bbox(self):
1448
+ """Store the current on-screen bbox into frame_bboxes for the current frame."""
1449
+ bbox = self.video_label.get_bbox_norm()
1450
+ if bbox:
1451
+ self.frame_bboxes[self.current_frame_idx] = bbox
1452
+ # Don't remove if bbox is None — user may not have drawn one for this frame
1453
+
1454
+ def _sync_bbox_to_frame(self):
1455
+ """Load the per-frame bbox for current_frame_idx into the overlay."""
1456
+ if not self.bbox_check.isChecked():
1457
+ return
1458
+ bbox = self.frame_bboxes.get(self.current_frame_idx)
1459
+ if bbox:
1460
+ self.video_label.set_bbox_norm(tuple(bbox))
1461
+ else:
1462
+ # Carry forward from nearest previous frame that has a bbox
1463
+ prev_bbox = None
1464
+ for fi in range(self.current_frame_idx - 1, -1, -1):
1465
+ if fi in self.frame_bboxes:
1466
+ prev_bbox = self.frame_bboxes[fi]
1467
+ break
1468
+ if prev_bbox:
1469
+ self.video_label.set_bbox_norm(tuple(prev_bbox))
1470
+ else:
1471
+ self.video_label.clear_bbox()
1472
+ self._update_bbox_info()
1473
+
1474
+ def _load_per_frame_bboxes(self):
1475
+ """Load saved per-frame bboxes from annotations into self.frame_bboxes."""
1476
+ self.frame_bboxes = {}
1477
+ if not self.current_clip_path:
1478
+ return
1479
+ frames_data = self.annotation_manager.get_spatial_bbox_frames(self.current_clip_path)
1480
+ if frames_data:
1481
+ for i, b in enumerate(frames_data):
1482
+ if b is not None and len(b) == 4:
1483
+ self.frame_bboxes[i] = list(b)
1484
+ else:
1485
+ # Fall back to legacy single bbox (apply to frame 0)
1486
+ bbox = self.annotation_manager.get_spatial_bbox(self.current_clip_path)
1487
+ if bbox:
1488
+ self.frame_bboxes[0] = list(bbox)
1489
+
1490
+ def _update_frame(self):
1491
+ """Update frame during playback."""
1492
+ if not self.current_frames:
1493
+ return
1494
+
1495
+ self.current_frame_idx = (self.current_frame_idx + 1) % len(self.current_frames)
1496
+ self.frame_slider.blockSignals(True)
1497
+ self.frame_slider.setValue(self.current_frame_idx)
1498
+ self.frame_slider.blockSignals(False)
1499
+ self._sync_bbox_to_frame()
1500
+ self._display_frame()
1501
+
1502
+ def _save_label(self):
1503
+ """Save label for current clip."""
1504
+ if not self.current_clip_path:
1505
+ return
1506
+
1507
+ label = self.class_combo.currentText()
1508
+ if label == "(No classes - add one first)":
1509
+ QMessageBox.warning(self, "Error", "Please add a class first, then select a label.")
1510
+ return
1511
+
1512
+ if label == "(No Label)":
1513
+ # Unlabel the clip (remove from annotations)
1514
+ self.annotation_manager.remove_clip(self.current_clip_path)
1515
+ self.refresh_clip_list()
1516
+ return
1517
+
1518
+ if not label:
1519
+ return
1520
+
1521
+ video_name = os.path.dirname(self.current_clip_path)
1522
+ clip_name = os.path.basename(self.current_clip_path)
1523
+
1524
+ meta = {
1525
+ "source_video": video_name,
1526
+ "clip_name": clip_name
1527
+ }
1528
+
1529
+ self.annotation_manager.add_clip(self.current_clip_path, label, meta)
1530
+ # Keep per-frame labels in sync: set all frames to this clip-level label
1531
+ if self.current_frames:
1532
+ T = len(self.current_frames)
1533
+ self.annotation_manager.set_frame_labels(self.current_clip_path, [label] * T)
1534
+ self._fl_current_labels = [label] * T
1535
+ self._fl_update_bar()
1536
+ self.refresh_clip_list()
1537
+
1538
+ # ---- Per-frame labeling methods ----
1539
+
1540
+ def _fl_mark_in(self):
1541
+ """Set the start of a frame range for per-frame labeling."""
1542
+ if not self.current_frames:
1543
+ return
1544
+ self._fl_mark_in_frame = self.current_frame_idx
1545
+ if self._fl_mark_out_frame is not None and self._fl_mark_out_frame < self._fl_mark_in_frame:
1546
+ self._fl_mark_out_frame = None
1547
+ self._fl_update_range_label()
1548
+
1549
+ def _fl_mark_out(self):
1550
+ """Set the end of a frame range for per-frame labeling."""
1551
+ if not self.current_frames:
1552
+ return
1553
+ self._fl_mark_out_frame = self.current_frame_idx
1554
+ if self._fl_mark_in_frame is not None and self._fl_mark_in_frame > self._fl_mark_out_frame:
1555
+ self._fl_mark_in_frame = None
1556
+ self._fl_update_range_label()
1557
+
1558
+ def _fl_update_range_label(self):
1559
+ """Update the range display label."""
1560
+ i = self._fl_mark_in_frame
1561
+ o = self._fl_mark_out_frame
1562
+ if i is not None and o is not None:
1563
+ self.fl_range_label.setText(f"{i+1}–{o+1}")
1564
+ elif i is not None:
1565
+ self.fl_range_label.setText(f"{i+1}–?")
1566
+ elif o is not None:
1567
+ self.fl_range_label.setText(f"?–{o+1}")
1568
+ else:
1569
+ self.fl_range_label.setText("—")
1570
+ if self.fullscreen_dialog and self.fullscreen_dialog.isVisible():
1571
+ self.fullscreen_dialog.sync_per_frame_controls()
1572
+
1573
+ def _fl_apply_label(self):
1574
+ """Apply selected class to the marked frame range."""
1575
+ if not self.current_frames or not self.current_clip_path:
1576
+ return
1577
+ if self._fl_mark_in_frame is None or self._fl_mark_out_frame is None:
1578
+ QMessageBox.warning(self, "No range", "Use 'Mark In' and 'Mark Out' to select a frame range first.")
1579
+ return
1580
+ cls = self.fl_class_combo.currentText()
1581
+ if not cls:
1582
+ return
1583
+ T = len(self.current_frames)
1584
+ # Initialize labels if empty
1585
+ if not self._fl_current_labels or len(self._fl_current_labels) != T:
1586
+ self._fl_current_labels = [None] * T
1587
+ start = max(0, self._fl_mark_in_frame)
1588
+ end = min(T - 1, self._fl_mark_out_frame)
1589
+ for fi in range(start, end + 1):
1590
+ self._fl_current_labels[fi] = cls
1591
+ self._fl_save_labels()
1592
+ self._fl_update_bar()
1593
+ # Auto-derive clip-level label from majority class
1594
+ self._fl_sync_clip_label()
1595
+
1596
+ def _fl_clear_labels(self):
1597
+ """Clear all per-frame labels for the current clip."""
1598
+ if not self.current_clip_path:
1599
+ return
1600
+ self._fl_current_labels = []
1601
+ self._fl_mark_in_frame = None
1602
+ self._fl_mark_out_frame = None
1603
+ self._fl_update_range_label()
1604
+ self.annotation_manager.clear_frame_labels(self.current_clip_path)
1605
+ self._fl_update_bar()
1606
+
1607
+ def _fl_save_labels(self):
1608
+ """Save current per-frame labels to annotation store."""
1609
+ if not self.current_clip_path or not self._fl_current_labels:
1610
+ return
1611
+ self.annotation_manager.set_frame_labels(self.current_clip_path, self._fl_current_labels)
1612
+
1613
+ def _fl_load_labels(self):
1614
+ """Load per-frame labels for the current clip from annotation store."""
1615
+ self._fl_current_labels = []
1616
+ self._fl_mark_in_frame = None
1617
+ self._fl_mark_out_frame = None
1618
+ self._fl_update_range_label()
1619
+ if not self.current_clip_path or not self.current_frames:
1620
+ self._fl_update_bar()
1621
+ return
1622
+ saved = self.annotation_manager.get_frame_labels(self.current_clip_path)
1623
+ if saved and isinstance(saved, list) and len(saved) == len(self.current_frames):
1624
+ self._fl_current_labels = list(saved)
1625
+ else:
1626
+ # Clip has no or mismatched frame_labels; fill from clip-level label for consistency
1627
+ clip_label = self.annotation_manager.get_clip_label(self.current_clip_path)
1628
+ if clip_label:
1629
+ T = len(self.current_frames)
1630
+ self._fl_current_labels = [clip_label] * T
1631
+ self.annotation_manager.set_frame_labels(self.current_clip_path, self._fl_current_labels)
1632
+ self._fl_update_bar()
1633
+
1634
+ def _fl_sync_clip_label(self):
1635
+ """Set the clip-level label to the majority per-frame class."""
1636
+ labels = [l for l in self._fl_current_labels if l is not None]
1637
+ if not labels:
1638
+ return
1639
+ from collections import Counter
1640
+ majority = Counter(labels).most_common(1)[0][0]
1641
+ idx = self.class_combo.findText(majority)
1642
+ if idx >= 0:
1643
+ self.class_combo.setCurrentIndex(idx)
1644
+ meta = {
1645
+ "source_video": os.path.dirname(self.current_clip_path),
1646
+ "clip_name": os.path.basename(self.current_clip_path),
1647
+ }
1648
+ self.annotation_manager.add_clip(self.current_clip_path, majority, meta)
1649
+ self.refresh_clip_list()
1650
+
1651
+ def _fl_update_bar(self):
1652
+ """Redraw the color bar showing per-frame labels."""
1653
+ if not self.current_frames:
1654
+ self.frame_label_bar.setPixmap(QPixmap())
1655
+ if self.fullscreen_dialog and self.fullscreen_dialog.isVisible():
1656
+ self.fullscreen_dialog.sync_per_frame_controls()
1657
+ return
1658
+ T = len(self.current_frames)
1659
+ bar_w = max(self.frame_label_bar.width(), 100)
1660
+ bar_h = self.frame_label_bar.height()
1661
+ pixmap = QPixmap(bar_w, bar_h)
1662
+ pixmap.fill(QColor("#e5e7eb"))
1663
+ if self._fl_current_labels and len(self._fl_current_labels) == T:
1664
+ from PyQt6.QtGui import QPainter
1665
+ painter = QPainter(pixmap)
1666
+ classes = self.annotation_manager.get_classes()
1667
+ # Consistent colors per class
1668
+ palette = [
1669
+ "#ef4444", "#3b82f6", "#22c55e", "#f59e0b", "#a855f7",
1670
+ "#ec4899", "#14b8a6", "#f97316", "#6366f1", "#84cc16",
1671
+ ]
1672
+ cls_color = {}
1673
+ for i, c in enumerate(classes):
1674
+ cls_color[c] = QColor(palette[i % len(palette)])
1675
+ for fi in range(T):
1676
+ lbl = self._fl_current_labels[fi]
1677
+ if lbl is not None and lbl in cls_color:
1678
+ x0 = int(fi * bar_w / T)
1679
+ x1 = int((fi + 1) * bar_w / T)
1680
+ painter.fillRect(x0, 0, x1 - x0, bar_h, cls_color[lbl])
1681
+ painter.end()
1682
+ self.frame_label_bar.setPixmap(pixmap)
1683
+ if self.fullscreen_dialog and self.fullscreen_dialog.isVisible():
1684
+ self.fullscreen_dialog.sync_per_frame_controls()
1685
+
1686
+ def _fl_update_class_combo(self):
1687
+ """Sync the per-frame class combo with available classes."""
1688
+ current = self.fl_class_combo.currentText()
1689
+ self.fl_class_combo.clear()
1690
+ classes = self.annotation_manager.get_classes()
1691
+ self.fl_class_combo.addItems(classes)
1692
+ idx = self.fl_class_combo.findText(current)
1693
+ if idx >= 0:
1694
+ self.fl_class_combo.setCurrentIndex(idx)
1695
+ if self.fullscreen_dialog:
1696
+ self.fullscreen_dialog.update_classes()
1697
+
1698
+ def _open_multi_label_dialog(self):
1699
+ """Open dialog to assign multiple labels to selected clips."""
1700
+ selected_items = self.clip_list.selectedItems()
1701
+ selected_paths = [it.data(Qt.ItemDataRole.UserRole) for it in selected_items if it.data(Qt.ItemDataRole.UserRole)]
1702
+ if not selected_paths and self.current_clip_path:
1703
+ selected_paths = [self.current_clip_path]
1704
+ if not selected_paths:
1705
+ QMessageBox.warning(self, "No clip", "Select one or more clips first.")
1706
+ return
1707
+ classes = self.annotation_manager.get_classes()
1708
+ if not classes:
1709
+ QMessageBox.warning(self, "No classes", "Add behavior classes first.")
1710
+ return
1711
+
1712
+ current_labels = self.annotation_manager.get_clip_labels(selected_paths[0])
1713
+
1714
+ dlg = QDialog(self)
1715
+ dlg.setWindowTitle("Multi-label assignment")
1716
+ dlg.setMinimumWidth(260)
1717
+ layout = QVBoxLayout(dlg)
1718
+ if len(selected_paths) == 1:
1719
+ prompt = f"Select labels for:\n{os.path.basename(selected_paths[0])}"
1720
+ else:
1721
+ prompt = f"Select labels for {len(selected_paths)} selected clips"
1722
+ layout.addWidget(QLabel(prompt))
1723
+
1724
+ checks = {}
1725
+ for cls in classes:
1726
+ cb = QCheckBox(cls)
1727
+ cb.setChecked(cls in current_labels)
1728
+ layout.addWidget(cb)
1729
+ checks[cls] = cb
1730
+
1731
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
1732
+ btns.accepted.connect(dlg.accept)
1733
+ btns.rejected.connect(dlg.reject)
1734
+ layout.addWidget(btns)
1735
+
1736
+ if dlg.exec():
1737
+ selected = [cls for cls, cb in checks.items() if cb.isChecked()]
1738
+ for clip_path in selected_paths:
1739
+ if not selected:
1740
+ self.annotation_manager.remove_clip(clip_path)
1741
+ continue
1742
+ video_name = os.path.dirname(clip_path)
1743
+ clip_name = os.path.basename(clip_path)
1744
+ meta = {"source_video": video_name, "clip_name": clip_name}
1745
+ self.annotation_manager.add_clip(clip_path, selected, meta)
1746
+ # Sync combo to first selected label
1747
+ if selected:
1748
+ idx = self.class_combo.findText(selected[0])
1749
+ if idx >= 0:
1750
+ self.class_combo.setCurrentIndex(idx)
1751
+ self.refresh_clip_list()
1752
+
1753
+ def _next_unlabeled(self):
1754
+ """Select next unlabeled clip."""
1755
+ unlabeled_items = []
1756
+ for i in range(self.clip_list.count()):
1757
+ item = self.clip_list.item(i)
1758
+ clip_path = item.data(Qt.ItemDataRole.UserRole)
1759
+ if not self.annotation_manager.get_clip_label(clip_path):
1760
+ unlabeled_items.append((i, item))
1761
+
1762
+ if not unlabeled_items:
1763
+ QMessageBox.information(self, "Info", "No unlabeled clips found.")
1764
+ return
1765
+
1766
+ current_row = self.clip_list.currentRow()
1767
+ for idx, item in unlabeled_items:
1768
+ if idx > current_row:
1769
+ self.clip_list.setCurrentItem(item)
1770
+ self._on_clip_selected(item)
1771
+ return
1772
+
1773
+ idx, item = unlabeled_items[0]
1774
+ self.clip_list.setCurrentItem(item)
1775
+ self._on_clip_selected(item)
1776
+
1777
+ def _on_bbox_toggled(self, enabled: bool):
1778
+ """Toggle bbox drawing overlay."""
1779
+ self.video_label.set_bbox_enabled(enabled)
1780
+ self.save_bbox_btn.setEnabled(enabled)
1781
+ self.clear_bbox_btn.setEnabled(enabled)
1782
+ if enabled:
1783
+ self._load_spatial_bbox_for_clip()
1784
+
1785
+ def _save_spatial_bbox(self):
1786
+ """Save per-frame bboxes for this clip. Captures the current frame's bbox first."""
1787
+ if not self.current_clip_path:
1788
+ return
1789
+ self._capture_current_frame_bbox()
1790
+ if not self.frame_bboxes:
1791
+ QMessageBox.information(self, "Info", "No bbox drawn. Enable bbox mode and drag on frames first.")
1792
+ return
1793
+ # Build full per-frame list (None for unannotated frames)
1794
+ num_frames = len(self.current_frames) if self.current_frames else 1
1795
+ frame_list = [self.frame_bboxes.get(i) for i in range(num_frames)]
1796
+ self.annotation_manager.set_spatial_bbox_frames(self.current_clip_path, frame_list)
1797
+ self._update_bbox_info()
1798
+
1799
+ def _clear_spatial_bbox(self):
1800
+ """Clear all saved bboxes (per-frame and legacy) from the current clip."""
1801
+ if not self.current_clip_path:
1802
+ return
1803
+ self.annotation_manager.clear_spatial_bbox(self.current_clip_path)
1804
+ self.annotation_manager.clear_spatial_bbox_frames(self.current_clip_path)
1805
+ self.frame_bboxes = {}
1806
+ self.video_label.clear_bbox()
1807
+ self._update_bbox_info()
1808
+
1809
+ def _update_bbox_info(self):
1810
+ """Update bbox info label with per-frame status."""
1811
+ if not self.current_clip_path:
1812
+ self.bbox_info_label.setText("")
1813
+ return
1814
+ saved_frames = self.annotation_manager.get_spatial_bbox_frames(self.current_clip_path)
1815
+ n_total = len(self.current_frames) if self.current_frames else 0
1816
+ if saved_frames:
1817
+ n_saved = sum(1 for b in saved_frames if b is not None)
1818
+ self.bbox_info_label.setText(f"Saved: {n_saved}/{len(saved_frames)} frames")
1819
+ elif self.frame_bboxes:
1820
+ n_drawn = len(self.frame_bboxes)
1821
+ self.bbox_info_label.setText(f"Drawn: {n_drawn}/{n_total} frames (unsaved)")
1822
+ elif self.annotation_manager.get_spatial_bbox(self.current_clip_path):
1823
+ self.bbox_info_label.setText("Saved bbox (legacy single-frame)")
1824
+ else:
1825
+ self.bbox_info_label.setText("")
1826
+
1827
+ def _load_spatial_bbox_for_clip(self):
1828
+ """Load saved bbox(es) for current clip into overlay."""
1829
+ if not self.current_clip_path:
1830
+ return
1831
+ self._load_per_frame_bboxes()
1832
+ self._sync_bbox_to_frame()
1833
+ self._update_bbox_info()
1834
+
1835
+ def keyPressEvent(self, event):
1836
+ """Handle keyboard shortcuts."""
1837
+ if event.key() >= Qt.Key.Key_1 and event.key() <= Qt.Key.Key_9:
1838
+ idx = event.key() - Qt.Key.Key_1
1839
+ if idx < self.class_combo.count():
1840
+ self.class_combo.setCurrentIndex(idx)
1841
+ elif event.key() == Qt.Key.Key_Space:
1842
+ self._toggle_play()
1843
+ elif event.key() == Qt.Key.Key_Q:
1844
+ self._prev_frame()
1845
+ elif event.key() == Qt.Key.Key_E:
1846
+ self._next_frame()
1847
+ elif event.key() == Qt.Key.Key_S and event.modifiers() == Qt.KeyboardModifier.ControlModifier:
1848
+ self._save_label()
1849
+ else:
1850
+ super().keyPressEvent(event)
1851
+
1852
+ def _normalize_video_path(self, path: str) -> str:
1853
+ if not path:
1854
+ return ""
1855
+ return os.path.abspath(path).replace("\\", "/")
1856
+
1857
+ def _sync_round_builder_classes(self, classes):
1858
+ if not hasattr(self, "round_target_list"):
1859
+ return
1860
+ classes = list(classes or [])
1861
+ selected = {item.text() for item in self.round_target_list.selectedItems()}
1862
+ selected_near = set()
1863
+ if hasattr(self, "round_near_list"):
1864
+ selected_near = {item.text() for item in self.round_near_list.selectedItems()}
1865
+ self.round_target_list.clear()
1866
+ self.round_target_list.addItems(classes)
1867
+ for i in range(self.round_target_list.count()):
1868
+ if self.round_target_list.item(i).text() in selected:
1869
+ self.round_target_list.item(i).setSelected(True)
1870
+ if hasattr(self, "round_near_list"):
1871
+ self.round_near_list.clear()
1872
+ self.round_near_list.addItems(classes)
1873
+ default_near = {c for c in classes if c.startswith("near_negative")}
1874
+ to_select = selected_near or default_near
1875
+ for i in range(self.round_near_list.count()):
1876
+ if self.round_near_list.item(i).text() in to_select:
1877
+ self.round_near_list.item(i).setSelected(True)
1878
+
1879
+ def open_timeline_import_dialog(self):
1880
+ video_paths, _ = QFileDialog.getOpenFileNames(
1881
+ self,
1882
+ "Select Source Videos for Timeline Labeling",
1883
+ self.config.get("raw_videos_dir", self.config.get("data_dir", "data/raw_videos")),
1884
+ "Video Files (*.mp4 *.avi *.mov *.mkv);;All Files (*)",
1885
+ )
1886
+ if not video_paths:
1887
+ return
1888
+ self.add_source_videos(video_paths, select_last=True)
1889
+
1890
+ def add_source_videos(self, video_paths, select_last=False):
1891
+ if not video_paths:
1892
+ return
1893
+ from .video_utils import ensure_videos_in_experiment
1894
+ ensured = ensure_videos_in_experiment(video_paths, self.config, self)
1895
+ for vp in ensured:
1896
+ npath = self._normalize_video_path(vp)
1897
+ if not os.path.exists(npath):
1898
+ continue
1899
+ if npath not in self.source_video_paths:
1900
+ self.source_video_paths.append(npath)
1901
+ self._refresh_source_video_list(select_path=self._normalize_video_path(ensured[-1]) if (ensured and select_last) else None)
1902
+
1903
+ def _refresh_source_video_list(self, select_path=None):
1904
+ if not hasattr(self, "source_video_list"):
1905
+ return
1906
+ self.source_video_list.clear()
1907
+ deduped = []
1908
+ for p in self.source_video_paths:
1909
+ if p not in deduped:
1910
+ deduped.append(p)
1911
+ self.source_video_paths = deduped
1912
+ for path in sorted(self.source_video_paths):
1913
+ item = QListWidgetItem(os.path.basename(path))
1914
+ item.setData(Qt.ItemDataRole.UserRole, path)
1915
+ self.source_video_list.addItem(item)
1916
+ if select_path and self._normalize_video_path(path) == self._normalize_video_path(select_path):
1917
+ self.source_video_list.setCurrentItem(item)
1918
+
1919
+ def _remove_selected_source_video(self):
1920
+ item = self.source_video_list.currentItem()
1921
+ if not item:
1922
+ return
1923
+ path = item.data(Qt.ItemDataRole.UserRole)
1924
+ self.source_video_paths = [p for p in self.source_video_paths if self._normalize_video_path(p) != self._normalize_video_path(path)]
1925
+ if self.current_source_video_path and self._normalize_video_path(self.current_source_video_path) == self._normalize_video_path(path):
1926
+ self._close_current_source_video()
1927
+ self._refresh_source_video_list()
1928
+
1929
+ def _clear_source_videos(self):
1930
+ self.source_video_paths = []
1931
+ self._close_current_source_video()
1932
+ self._refresh_source_video_list()
1933
+
1934
+ def _close_current_source_video(self):
1935
+ if self.current_source_cap is not None:
1936
+ self.current_source_cap.release()
1937
+ self.current_source_cap = None
1938
+ self.current_source_video_path = None
1939
+ self.current_source_frame = 0
1940
+ self.current_source_frame_count = 0
1941
+ if hasattr(self, "source_scrub_slider"):
1942
+ self.source_scrub_slider.setMaximum(0)
1943
+ self.source_scrub_slider.setValue(0)
1944
+ if hasattr(self, "source_scrub_widget"):
1945
+ self.source_scrub_widget.setVisible(False)
1946
+ self.source_frame_label.setText("Frame: — / —")
1947
+ if not self.current_clip_path:
1948
+ self.video_label.setText("No clip selected")
1949
+ self.video_label.setPixmap(QPixmap())
1950
+ if self.fullscreen_dialog:
1951
+ self.fullscreen_dialog.update_scrubber()
1952
+
1953
+ def _on_source_video_selected(self):
1954
+ item = self.source_video_list.currentItem()
1955
+ if not item:
1956
+ return
1957
+ self._load_source_video(item.data(Qt.ItemDataRole.UserRole))
1958
+
1959
+ def _load_source_video(self, path: str):
1960
+ npath = self._normalize_video_path(path)
1961
+ if not os.path.exists(npath):
1962
+ QMessageBox.warning(self, "Missing video", f"Video not found:\n{npath}")
1963
+ return
1964
+ if self.current_source_cap is not None:
1965
+ self.current_source_cap.release()
1966
+ cap = cv2.VideoCapture(npath)
1967
+ if not cap.isOpened():
1968
+ QMessageBox.warning(self, "Error", f"Could not open video:\n{npath}")
1969
+ return
1970
+ self.current_source_cap = cap
1971
+ self.current_source_video_path = npath
1972
+ self.current_source_frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
1973
+ self.current_source_frame = 0
1974
+ self.current_clip_path = None
1975
+ self.current_frames = []
1976
+ self.is_playing = False
1977
+ self.timer.stop()
1978
+ self.play_pause_btn.setText("Play")
1979
+ self.play_pause_btn.setEnabled(False)
1980
+ max_frame = max(0, self.current_source_frame_count - 1)
1981
+ self.source_scrub_slider.blockSignals(True)
1982
+ self.source_scrub_slider.setMinimum(0)
1983
+ self.source_scrub_slider.setMaximum(max_frame)
1984
+ self.source_scrub_slider.setValue(0)
1985
+ self.source_scrub_slider.blockSignals(False)
1986
+ self.source_scrub_widget.setVisible(True)
1987
+ self._display_source_frame(0)
1988
+
1989
+ def _read_frame_at(self, frame_idx: int):
1990
+ if self.current_source_cap is None:
1991
+ return None
1992
+ idx = max(0, min(frame_idx, max(0, self.current_source_frame_count - 1)))
1993
+ self.current_source_cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
1994
+ ok, frame = self.current_source_cap.read()
1995
+ if not ok:
1996
+ return None
1997
+ return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
1998
+
1999
+ def _display_source_frame(self, frame_idx: int):
2000
+ frame_rgb = self._read_frame_at(frame_idx)
2001
+ if frame_rgb is None:
2002
+ return
2003
+ self.current_source_frame = int(frame_idx)
2004
+ h, w, c = frame_rgb.shape
2005
+ qimg = QImage(frame_rgb.data, w, h, c * w, QImage.Format.Format_RGB888)
2006
+ pixmap = QPixmap.fromImage(qimg)
2007
+ if hasattr(self, "video_scroll") and self.video_scroll.viewport().width() > 1:
2008
+ viewport_size = self.video_scroll.viewport().size()
2009
+ self._base_display_pixmap = pixmap.scaled(
2010
+ viewport_size,
2011
+ Qt.AspectRatioMode.KeepAspectRatio,
2012
+ Qt.TransformationMode.SmoothTransformation,
2013
+ )
2014
+ else:
2015
+ self._base_display_pixmap = pixmap
2016
+ self._apply_zoom()
2017
+ self.source_frame_label.setText(f"Frame: {self.current_source_frame + 1}/{max(1, self.current_source_frame_count)}")
2018
+ self._update_fullscreen_view(
2019
+ pixmap,
2020
+ info_text=(
2021
+ f"{os.path.basename(self.current_source_video_path or '')} "
2022
+ f"[{self.current_source_frame + 1}/{max(1, self.current_source_frame_count)}]"
2023
+ ),
2024
+ )
2025
+
2026
+ def _on_source_scrub_changed(self, value: int):
2027
+ if not self._is_source_video_mode():
2028
+ return
2029
+ self._display_source_frame(value)
2030
+ if self.fullscreen_dialog and hasattr(self.fullscreen_dialog, "fs_position_slider"):
2031
+ self.fullscreen_dialog.fs_position_slider.blockSignals(True)
2032
+ self.fullscreen_dialog.fs_position_slider.setValue(int(value))
2033
+ self.fullscreen_dialog.fs_position_slider.blockSignals(False)
2034
+
2035
+ def _is_source_video_mode(self) -> bool:
2036
+ """True when timeline source-video labeling is active (not clip playback)."""
2037
+ return self.current_source_cap is not None and not self.current_frames
2038
+
2039
+ def _refresh_fullscreen_from_current_state(self):
2040
+ """Refresh fullscreen content using current active mode/frame."""
2041
+ if not (self.fullscreen_dialog and self.fullscreen_dialog.isVisible()):
2042
+ return
2043
+ if self._is_source_video_mode():
2044
+ self._display_source_frame(self.current_source_frame)
2045
+ elif self.current_frames:
2046
+ self._display_frame()
2047
+
2048
+ def _browse_output_dir(self):
2049
+ out_dir = QFileDialog.getExistingDirectory(
2050
+ self,
2051
+ "Select output clips directory",
2052
+ self.ea_output_dir_edit.text().strip() or self.clip_base_dir,
2053
+ )
2054
+ if out_dir:
2055
+ self.ea_output_dir_edit.setText(out_dir)
2056
+
2057
+ def _save_clip_from_frames(self, frames, out_path, fps):
2058
+ if not frames:
2059
+ return False
2060
+ h, w, _ = frames[0].shape
2061
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
2062
+ writer = cv2.VideoWriter(out_path, cv2.VideoWriter_fourcc(*"mp4v"), float(fps), (w, h))
2063
+ for frame in frames:
2064
+ writer.write(frame)
2065
+ writer.release()
2066
+ return True
2067
+
2068
+ def _extract_window_to_clip(self, video_path, sub_start, clip_length, frame_interval, target_fps, output_path):
2069
+ cap = cv2.VideoCapture(video_path)
2070
+ if not cap.isOpened():
2071
+ return False
2072
+ orig_start = int(sub_start * frame_interval)
2073
+ cap.set(cv2.CAP_PROP_POS_FRAMES, orig_start)
2074
+ frames = []
2075
+ needed = clip_length
2076
+ idx = orig_start
2077
+ while needed > 0:
2078
+ ok, frame = cap.read()
2079
+ if not ok:
2080
+ break
2081
+ if ((idx - orig_start) % frame_interval) == 0:
2082
+ frames.append(frame)
2083
+ needed -= 1
2084
+ idx += 1
2085
+ cap.release()
2086
+ if len(frames) != clip_length:
2087
+ return False
2088
+ return self._save_clip_from_frames(frames, output_path, target_fps)
2089
+
2090
+ def _extract_all_clips_from_videos(self):
2091
+ """Extract all clips from source videos using a sliding window. Clips are saved as unlabeled."""
2092
+ if not self.source_video_paths:
2093
+ QMessageBox.warning(self, "No source videos", "Add source videos in the timeline section first.")
2094
+ return
2095
+
2096
+ target_fps = int(self.ea_target_fps_spin.value())
2097
+ clip_length = int(self.ea_clip_length_spin.value())
2098
+ step_frames = int(self.ea_step_spin.value())
2099
+ max_clips = int(self.ea_max_clips_spin.value())
2100
+
2101
+ if step_frames > clip_length:
2102
+ QMessageBox.warning(self, "Invalid params", "Step frames cannot exceed clip length.")
2103
+ return
2104
+
2105
+ output_root = self.ea_output_dir_edit.text().strip() or self.clip_base_dir
2106
+ os.makedirs(output_root, exist_ok=True)
2107
+ if os.path.abspath(output_root) != os.path.abspath(self.clip_base_dir):
2108
+ self.clip_base_dir = output_root
2109
+ self.config["clips_dir"] = output_root
2110
+
2111
+ self.ea_progress.setVisible(True)
2112
+ self.ea_progress.setValue(0)
2113
+ self.ea_status_label.setText("Extracting...")
2114
+ QApplication.processEvents()
2115
+
2116
+ total_generated = 0
2117
+
2118
+ for vid_idx, video_path in enumerate(self.source_video_paths):
2119
+ cap = cv2.VideoCapture(video_path)
2120
+ if not cap.isOpened():
2121
+ continue
2122
+ orig_fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
2123
+ frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
2124
+ cap.release()
2125
+ if frame_count <= 0:
2126
+ continue
2127
+
2128
+ frame_interval = max(1, int(round(orig_fps / max(1, target_fps))))
2129
+ total_sub = max(1, frame_count // frame_interval)
2130
+ video_name = os.path.splitext(os.path.basename(video_path))[0]
2131
+ video_out_dir = os.path.join(output_root, video_name)
2132
+ os.makedirs(video_out_dir, exist_ok=True)
2133
+
2134
+ # Sliding window over the whole video
2135
+ windows = []
2136
+ pos = 0
2137
+ while pos + clip_length <= total_sub:
2138
+ windows.append(pos)
2139
+ pos += max(1, step_frames)
2140
+
2141
+ if max_clips > 0 and len(windows) > max_clips:
2142
+ stride = len(windows) / float(max_clips)
2143
+ windows = [windows[int(i * stride)] for i in range(max_clips)]
2144
+
2145
+ per_video = 0
2146
+ for win_idx, sub_start in enumerate(windows):
2147
+ out_name = f"clip_{sub_start:06d}_{win_idx:05d}.mp4"
2148
+ out_path = os.path.join(video_out_dir, out_name)
2149
+ if os.path.exists(out_path):
2150
+ rel_id = os.path.relpath(out_path, self.clip_base_dir).replace("\\", "/")
2151
+ if not self.annotation_manager.get_clip_label(rel_id):
2152
+ self.annotation_manager.add_clip(rel_id, "", meta={
2153
+ "source_video": video_name,
2154
+ "bulk_extracted": True,
2155
+ "sub_start_frame": sub_start,
2156
+ }, _defer_save=True)
2157
+ per_video += 1
2158
+ total_generated += 1
2159
+ continue
2160
+
2161
+ ok = self._extract_window_to_clip(
2162
+ video_path, sub_start, clip_length, frame_interval, target_fps, out_path
2163
+ )
2164
+ if not ok:
2165
+ continue
2166
+ rel_id = os.path.relpath(out_path, self.clip_base_dir).replace("\\", "/")
2167
+ self.annotation_manager.add_clip(rel_id, "", meta={
2168
+ "source_video": video_name,
2169
+ "bulk_extracted": True,
2170
+ "sub_start_frame": sub_start,
2171
+ }, _defer_save=True)
2172
+ per_video += 1
2173
+ total_generated += 1
2174
+
2175
+ if win_idx % 20 == 0:
2176
+ pct = int(100 * (vid_idx + (win_idx / max(1, len(windows)))) / len(self.source_video_paths))
2177
+ self.ea_progress.setValue(min(pct, 99))
2178
+ self.ea_status_label.setText(f"{video_name}: {per_video}/{len(windows)} clips")
2179
+ QApplication.processEvents()
2180
+
2181
+ self.ea_progress.setValue(int(100 * (vid_idx + 1) / len(self.source_video_paths)))
2182
+ QApplication.processEvents()
2183
+
2184
+ self.annotation_manager.save()
2185
+ self.ea_progress.setVisible(False)
2186
+ self.ea_status_label.setText(f"Extracted {total_generated} unlabeled clips")
2187
+ self.refresh_clip_list()
2188
+ QMessageBox.information(
2189
+ self, "Done",
2190
+ f"Extracted {total_generated} clips.\n"
2191
+ f"Use 'Show unlabeled only' to browse and label them."
2192
+ )
2193
+
2194
+ def _build_hard_negative_round_dataset(self):
2195
+ """Create a separate round dataset using target class(es) + sampled near negatives."""
2196
+ target_labels = [item.text().strip() for item in self.round_target_list.selectedItems() if item.text().strip()]
2197
+ near_labels = [item.text().strip() for item in self.round_near_list.selectedItems() if item.text().strip()]
2198
+ negative_output_label = self.round_negative_output_edit.text().strip() or "other"
2199
+ round_name = self.round_name_edit.text().strip()
2200
+ neg_per_pos = int(self.round_neg_per_pos_spin.value())
2201
+
2202
+ if not target_labels:
2203
+ QMessageBox.warning(self, "Missing target", "Select at least one target class.")
2204
+ return
2205
+ if not round_name:
2206
+ QMessageBox.warning(self, "Missing round name", "Enter a round name.")
2207
+ return
2208
+ if not near_labels:
2209
+ QMessageBox.warning(self, "Missing near negatives", "Select at least one near-negative class.")
2210
+ return
2211
+ exp_path = self.config.get("experiment_path")
2212
+ if not exp_path:
2213
+ QMessageBox.warning(self, "No experiment", "Load/create an experiment first.")
2214
+ return
2215
+
2216
+ all_clips = self.annotation_manager.get_all_clips()
2217
+ target_set = set(target_labels)
2218
+ near_set = set(near_labels)
2219
+ positives = [c for c in all_clips if c.get("label") in target_set]
2220
+ near_pool = [c for c in all_clips if c.get("label") in near_set]
2221
+ if not positives:
2222
+ QMessageBox.warning(self, "No positives", f"No clips found for target(s) {target_labels}.")
2223
+ return
2224
+ if not near_pool:
2225
+ QMessageBox.warning(
2226
+ self,
2227
+ "No negatives",
2228
+ "No near-negative clips found for the selected near-negative classes.",
2229
+ )
2230
+ return
2231
+
2232
+ desired_neg = max(1, len(positives) * max(1, neg_per_pos))
2233
+
2234
+ rnd = random.Random(42)
2235
+ near_shuf = near_pool.copy()
2236
+ rnd.shuffle(near_shuf)
2237
+
2238
+ selected_near = near_shuf[: min(desired_neg, len(near_shuf))]
2239
+ selected_neg = selected_near
2240
+ if not selected_neg:
2241
+ QMessageBox.warning(self, "No sampled negatives", "Could not sample negatives with the selected settings.")
2242
+ return
2243
+
2244
+ round_root = os.path.join(exp_path, "data", "rounds", round_name)
2245
+ round_clips_dir = os.path.join(round_root, "clips")
2246
+ round_ann_dir = os.path.join(round_root, "annotations")
2247
+ round_ann_file = os.path.join(round_ann_dir, "annotations.json")
2248
+ os.makedirs(round_clips_dir, exist_ok=True)
2249
+ os.makedirs(round_ann_dir, exist_ok=True)
2250
+
2251
+ selected = positives + selected_neg
2252
+ new_clips = []
2253
+ copied = 0
2254
+ missing = 0
2255
+
2256
+ for clip in selected:
2257
+ clip_id = (clip.get("id") or "").replace("\\", "/")
2258
+ if not clip_id:
2259
+ continue
2260
+ src_path = os.path.join(self.clip_base_dir, clip_id)
2261
+ if not os.path.exists(src_path):
2262
+ missing += 1
2263
+ continue
2264
+ dst_path = os.path.join(round_clips_dir, clip_id)
2265
+ os.makedirs(os.path.dirname(dst_path), exist_ok=True)
2266
+ if not os.path.exists(dst_path):
2267
+ shutil.copy2(src_path, dst_path)
2268
+ copied += 1
2269
+
2270
+ out_label = clip.get("label") if clip.get("label") in target_set else negative_output_label
2271
+ meta = dict(clip.get("meta") or {})
2272
+ meta["round_dataset"] = round_name
2273
+ if out_label == negative_output_label:
2274
+ meta["hard_negative_source"] = "near"
2275
+ new_clips.append({
2276
+ "id": clip_id,
2277
+ "label": out_label,
2278
+ "meta": meta,
2279
+ })
2280
+
2281
+ with open(round_ann_file, "w", encoding="utf-8") as f:
2282
+ json.dump(
2283
+ {
2284
+ "classes": list(target_labels) + [negative_output_label],
2285
+ "clips": new_clips,
2286
+ },
2287
+ f,
2288
+ indent=2,
2289
+ )
2290
+
2291
+ # Keep round dataset separate, but set explicit training overrides.
2292
+ self.config["training_clips_dir"] = round_clips_dir
2293
+ self.config["training_annotation_file"] = round_ann_file
2294
+ shortage = max(0, desired_neg - len(selected_neg))
2295
+ shortage_note = f", shortage {shortage}" if shortage else ""
2296
+ self.round_status_label.setText(
2297
+ f"Round ready: {len(positives)} pos, {len(selected_neg)} neg (near {len(selected_near)}{shortage_note})"
2298
+ )
2299
+ QMessageBox.information(
2300
+ self,
2301
+ "Round dataset built",
2302
+ "Created new round dataset and pointed Training tab to it.\n\n"
2303
+ f"Round: {round_name}\n"
2304
+ f"Clips: {round_clips_dir}\n"
2305
+ f"Annotations: {round_ann_file}\n"
2306
+ f"Copied: {copied}, Missing: {missing}",
2307
+ )
2308
+
2309
+ def update_config(self, config: dict):
2310
+ """Apply new configuration (experiment change)."""
2311
+ self.config = config
2312
+ self.clip_base_dir = self.config.get("clips_dir", "data/clips")
2313
+ if hasattr(self, "ea_output_dir_edit"):
2314
+ self.ea_output_dir_edit.setText(self.clip_base_dir)
2315
+ if hasattr(self, "ea_target_fps_spin"):
2316
+ self.ea_target_fps_spin.setValue(int(self.config.get("default_target_fps", 12)))
2317
+ self.ea_clip_length_spin.setValue(int(self.config.get("default_clip_length", 8)))
2318
+ self.ea_step_spin.setValue(int(self.config.get("default_step_frames", 8)))
2319
+ self.annotation_manager = AnnotationManager(
2320
+ self.config.get("annotation_file", "data/annotations/annotations.json")
2321
+ )
2322
+ self.refresh_clip_list()
2323
+ self._update_class_combo()
2324
+