singlebehaviorlab 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sam2/__init__.py +11 -0
- sam2/automatic_mask_generator.py +454 -0
- sam2/benchmark.py +92 -0
- sam2/build_sam.py +174 -0
- sam2/configs/sam2/sam2_hiera_b+.yaml +113 -0
- sam2/configs/sam2/sam2_hiera_l.yaml +117 -0
- sam2/configs/sam2/sam2_hiera_s.yaml +116 -0
- sam2/configs/sam2/sam2_hiera_t.yaml +118 -0
- sam2/configs/sam2.1/sam2.1_hiera_b+.yaml +116 -0
- sam2/configs/sam2.1/sam2.1_hiera_l.yaml +120 -0
- sam2/configs/sam2.1/sam2.1_hiera_s.yaml +119 -0
- sam2/configs/sam2.1/sam2.1_hiera_t.yaml +121 -0
- sam2/configs/sam2.1_training/sam2.1_hiera_b+_MOSE_finetune.yaml +339 -0
- sam2/modeling/__init__.py +5 -0
- sam2/modeling/backbones/__init__.py +5 -0
- sam2/modeling/backbones/hieradet.py +317 -0
- sam2/modeling/backbones/image_encoder.py +134 -0
- sam2/modeling/backbones/utils.py +93 -0
- sam2/modeling/memory_attention.py +169 -0
- sam2/modeling/memory_encoder.py +181 -0
- sam2/modeling/position_encoding.py +239 -0
- sam2/modeling/sam/__init__.py +5 -0
- sam2/modeling/sam/mask_decoder.py +295 -0
- sam2/modeling/sam/prompt_encoder.py +202 -0
- sam2/modeling/sam/transformer.py +311 -0
- sam2/modeling/sam2_base.py +913 -0
- sam2/modeling/sam2_utils.py +323 -0
- sam2/sam2_hiera_b+.yaml +113 -0
- sam2/sam2_hiera_l.yaml +117 -0
- sam2/sam2_hiera_s.yaml +116 -0
- sam2/sam2_hiera_t.yaml +118 -0
- sam2/sam2_image_predictor.py +466 -0
- sam2/sam2_video_predictor.py +1388 -0
- sam2/sam2_video_predictor_legacy.py +1172 -0
- sam2/utils/__init__.py +5 -0
- sam2/utils/amg.py +348 -0
- sam2/utils/misc.py +349 -0
- sam2/utils/transforms.py +118 -0
- singlebehaviorlab/__init__.py +4 -0
- singlebehaviorlab/__main__.py +130 -0
- singlebehaviorlab/_paths.py +100 -0
- singlebehaviorlab/backend/__init__.py +2 -0
- singlebehaviorlab/backend/augmentations.py +320 -0
- singlebehaviorlab/backend/data_store.py +420 -0
- singlebehaviorlab/backend/model.py +1290 -0
- singlebehaviorlab/backend/train.py +4667 -0
- singlebehaviorlab/backend/uncertainty.py +578 -0
- singlebehaviorlab/backend/video_processor.py +688 -0
- singlebehaviorlab/backend/video_utils.py +139 -0
- singlebehaviorlab/data/config/config.yaml +85 -0
- singlebehaviorlab/data/training_profiles.json +334 -0
- singlebehaviorlab/gui/__init__.py +4 -0
- singlebehaviorlab/gui/analysis_widget.py +2291 -0
- singlebehaviorlab/gui/attention_export.py +311 -0
- singlebehaviorlab/gui/clip_extraction_widget.py +481 -0
- singlebehaviorlab/gui/clustering_widget.py +3187 -0
- singlebehaviorlab/gui/inference_popups.py +1138 -0
- singlebehaviorlab/gui/inference_widget.py +4550 -0
- singlebehaviorlab/gui/inference_worker.py +651 -0
- singlebehaviorlab/gui/labeling_widget.py +2324 -0
- singlebehaviorlab/gui/main_window.py +754 -0
- singlebehaviorlab/gui/metadata_management_widget.py +1119 -0
- singlebehaviorlab/gui/motion_tracking.py +764 -0
- singlebehaviorlab/gui/overlay_export.py +1234 -0
- singlebehaviorlab/gui/plot_integration.py +729 -0
- singlebehaviorlab/gui/qt_helpers.py +29 -0
- singlebehaviorlab/gui/registration_widget.py +1485 -0
- singlebehaviorlab/gui/review_widget.py +1330 -0
- singlebehaviorlab/gui/segmentation_tracking_widget.py +2752 -0
- singlebehaviorlab/gui/tab_tutorial_dialog.py +312 -0
- singlebehaviorlab/gui/timeline_themes.py +131 -0
- singlebehaviorlab/gui/training_profiles.py +418 -0
- singlebehaviorlab/gui/training_widget.py +3719 -0
- singlebehaviorlab/gui/video_utils.py +233 -0
- singlebehaviorlab/licenses/SAM2-LICENSE +201 -0
- singlebehaviorlab/licenses/VideoPrism-LICENSE +202 -0
- singlebehaviorlab-2.0.0.dist-info/METADATA +447 -0
- singlebehaviorlab-2.0.0.dist-info/RECORD +88 -0
- singlebehaviorlab-2.0.0.dist-info/WHEEL +5 -0
- singlebehaviorlab-2.0.0.dist-info/entry_points.txt +2 -0
- singlebehaviorlab-2.0.0.dist-info/licenses/LICENSE +21 -0
- singlebehaviorlab-2.0.0.dist-info/top_level.txt +3 -0
- videoprism/__init__.py +0 -0
- videoprism/encoders.py +910 -0
- videoprism/layers.py +1136 -0
- videoprism/models.py +407 -0
- videoprism/tokenizers.py +167 -0
- videoprism/utils.py +168 -0
|
@@ -0,0 +1,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
|
+
|