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,1119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Metadata Management Widget for SingleBehavior Lab.
|
|
3
|
+
Allows adding columns, managing classes, and assigning values to videos/objects.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import numpy as np
|
|
9
|
+
from PyQt6.QtWidgets import (
|
|
10
|
+
QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
|
|
11
|
+
QComboBox, QLineEdit, QMessageBox, QGroupBox, QFileDialog,
|
|
12
|
+
QHeaderView, QDialog, QDialogButtonBox, QFormLayout, QSpinBox,
|
|
13
|
+
QTextEdit, QListWidget, QListWidgetItem, QSplitter, QScrollArea,
|
|
14
|
+
QTabWidget, QFrame, QSizePolicy, QTableView, QAbstractItemView,
|
|
15
|
+
QProgressBar, QApplication, QTableWidget, QTableWidgetItem, QCheckBox
|
|
16
|
+
)
|
|
17
|
+
from PyQt6.QtCore import Qt, QAbstractTableModel, QModelIndex, QVariant
|
|
18
|
+
from PyQt6.QtGui import QFont
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AddColumnDialog(QDialog):
|
|
22
|
+
"""Dialog for adding a new categorical column to metadata."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, parent=None):
|
|
25
|
+
super().__init__(parent)
|
|
26
|
+
self.setWindowTitle("Add new category column")
|
|
27
|
+
self.setMinimumWidth(400)
|
|
28
|
+
|
|
29
|
+
layout = QVBoxLayout(self)
|
|
30
|
+
|
|
31
|
+
# Instructions
|
|
32
|
+
info = QLabel("Add a new categorical column. Define the allowed options (classes) for this column.")
|
|
33
|
+
info.setStyleSheet("color: gray; font-style: italic; margin-bottom: 10px;")
|
|
34
|
+
info.setWordWrap(True)
|
|
35
|
+
layout.addWidget(info)
|
|
36
|
+
|
|
37
|
+
form = QFormLayout()
|
|
38
|
+
form.setSpacing(10)
|
|
39
|
+
|
|
40
|
+
self.column_name_edit = QLineEdit()
|
|
41
|
+
self.column_name_edit.setPlaceholderText("e.g., Treatment, Condition, Genotype")
|
|
42
|
+
form.addRow("Column Name:", self.column_name_edit)
|
|
43
|
+
|
|
44
|
+
self.categories_edit = QLineEdit()
|
|
45
|
+
self.categories_edit.setPlaceholderText("Comma-separated options (e.g., Control, Drug, Placebo)")
|
|
46
|
+
form.addRow("Options (Classes):", self.categories_edit)
|
|
47
|
+
|
|
48
|
+
layout.addLayout(form)
|
|
49
|
+
|
|
50
|
+
# Buttons
|
|
51
|
+
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
|
52
|
+
buttons.accepted.connect(self.accept)
|
|
53
|
+
buttons.rejected.connect(self.reject)
|
|
54
|
+
layout.addWidget(buttons)
|
|
55
|
+
|
|
56
|
+
def get_column_info(self):
|
|
57
|
+
"""Get column information from dialog."""
|
|
58
|
+
name = self.column_name_edit.text().strip()
|
|
59
|
+
categories_text = self.categories_edit.text().strip()
|
|
60
|
+
categories = [c.strip() for c in categories_text.split(",") if c.strip()]
|
|
61
|
+
|
|
62
|
+
return name, categories
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PandasTableModel(QAbstractTableModel):
|
|
66
|
+
"""Lightweight pandas-backed table model with paging."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, df: pd.DataFrame, start: int = 0, end: int = 0, parent=None):
|
|
69
|
+
super().__init__(parent)
|
|
70
|
+
self.df = df
|
|
71
|
+
self.start = start
|
|
72
|
+
self.end = end if end else len(df)
|
|
73
|
+
|
|
74
|
+
def update_range(self, start: int, end: int):
|
|
75
|
+
self.start = start
|
|
76
|
+
self.end = min(end, len(self.df))
|
|
77
|
+
self.layoutChanged.emit()
|
|
78
|
+
|
|
79
|
+
def rowCount(self, parent=QModelIndex()) -> int:
|
|
80
|
+
return max(0, self.end - self.start)
|
|
81
|
+
|
|
82
|
+
def columnCount(self, parent=QModelIndex()) -> int:
|
|
83
|
+
return 0 if self.df is None else self.df.shape[1]
|
|
84
|
+
|
|
85
|
+
def data(self, index: QModelIndex, role=Qt.ItemDataRole.DisplayRole):
|
|
86
|
+
if not index.isValid() or self.df is None:
|
|
87
|
+
return QVariant()
|
|
88
|
+
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
|
|
89
|
+
try:
|
|
90
|
+
value = self.df.iat[self.start + index.row(), index.column()]
|
|
91
|
+
return "" if pd.isna(value) else str(value)
|
|
92
|
+
except Exception:
|
|
93
|
+
return QVariant()
|
|
94
|
+
return QVariant()
|
|
95
|
+
|
|
96
|
+
def headerData(self, section: int, orientation: Qt.Orientation, role=Qt.ItemDataRole.DisplayRole):
|
|
97
|
+
if role != Qt.ItemDataRole.DisplayRole or self.df is None:
|
|
98
|
+
return QVariant()
|
|
99
|
+
if orientation == Qt.Orientation.Horizontal:
|
|
100
|
+
try:
|
|
101
|
+
return self.df.columns[section]
|
|
102
|
+
except Exception:
|
|
103
|
+
return QVariant()
|
|
104
|
+
else:
|
|
105
|
+
return section + self.start + 1
|
|
106
|
+
|
|
107
|
+
def flags(self, index: QModelIndex):
|
|
108
|
+
if not index.isValid():
|
|
109
|
+
return Qt.ItemFlag.NoItemFlags
|
|
110
|
+
return (
|
|
111
|
+
Qt.ItemFlag.ItemIsSelectable
|
|
112
|
+
| Qt.ItemFlag.ItemIsEnabled
|
|
113
|
+
| Qt.ItemFlag.ItemIsEditable
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def setData(self, index: QModelIndex, value, role=Qt.ItemDataRole.EditRole):
|
|
117
|
+
if role != Qt.ItemDataRole.EditRole or self.df is None or not index.isValid():
|
|
118
|
+
return False
|
|
119
|
+
r = self.start + index.row()
|
|
120
|
+
c = index.column()
|
|
121
|
+
col_name = self.df.columns[c]
|
|
122
|
+
|
|
123
|
+
new_val = value
|
|
124
|
+
# Enforce category constraints
|
|
125
|
+
if isinstance(self.df[col_name].dtype, pd.CategoricalDtype):
|
|
126
|
+
cats = list(self.df[col_name].dtype.categories)
|
|
127
|
+
if new_val not in cats:
|
|
128
|
+
return False
|
|
129
|
+
else:
|
|
130
|
+
# Try to cast numeric columns
|
|
131
|
+
if self.df[col_name].dtype in [np.int64, np.float64]:
|
|
132
|
+
try:
|
|
133
|
+
new_val = float(value) if value != "" else np.nan
|
|
134
|
+
except Exception:
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
self.df.iat[r, c] = new_val
|
|
139
|
+
self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole])
|
|
140
|
+
return True
|
|
141
|
+
except Exception:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class EditColumnDialog(QDialog):
|
|
146
|
+
"""Dialog for editing column name and renaming values within a column."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, column_name: str, metadata: pd.DataFrame, parent=None):
|
|
149
|
+
super().__init__(parent)
|
|
150
|
+
self.column_name = column_name
|
|
151
|
+
self.metadata = metadata
|
|
152
|
+
self.setWindowTitle(f"Edit column: {column_name}")
|
|
153
|
+
self.setMinimumWidth(500)
|
|
154
|
+
self.setMinimumHeight(400)
|
|
155
|
+
|
|
156
|
+
layout = QVBoxLayout(self)
|
|
157
|
+
|
|
158
|
+
# Column name section
|
|
159
|
+
name_group = QGroupBox("Column name")
|
|
160
|
+
name_layout = QVBoxLayout()
|
|
161
|
+
self.column_name_edit = QLineEdit(column_name)
|
|
162
|
+
name_layout.addWidget(QLabel("Rename column to:"))
|
|
163
|
+
name_layout.addWidget(self.column_name_edit)
|
|
164
|
+
name_group.setLayout(name_layout)
|
|
165
|
+
layout.addWidget(name_group)
|
|
166
|
+
|
|
167
|
+
# Value renaming section
|
|
168
|
+
values_group = QGroupBox("Rename values (Classes)")
|
|
169
|
+
values_layout = QVBoxLayout()
|
|
170
|
+
|
|
171
|
+
info_label = QLabel("Rename unique values in this column. Leave unchanged to keep original value.")
|
|
172
|
+
info_label.setWordWrap(True)
|
|
173
|
+
info_label.setStyleSheet("color: gray; font-style: italic;")
|
|
174
|
+
values_layout.addWidget(info_label)
|
|
175
|
+
|
|
176
|
+
# Get unique values
|
|
177
|
+
unique_vals = sorted(self.metadata[column_name].dropna().unique().astype(str))
|
|
178
|
+
|
|
179
|
+
# Scroll area for value edits
|
|
180
|
+
scroll = QScrollArea()
|
|
181
|
+
scroll_widget = QWidget()
|
|
182
|
+
scroll_layout = QVBoxLayout(scroll_widget)
|
|
183
|
+
scroll_layout.setSpacing(5)
|
|
184
|
+
|
|
185
|
+
self.value_edits = {}
|
|
186
|
+
for val in unique_vals:
|
|
187
|
+
row = QHBoxLayout()
|
|
188
|
+
old_label = QLabel(f"{val} →")
|
|
189
|
+
old_label.setMinimumWidth(150)
|
|
190
|
+
old_label.setStyleSheet("font-weight: bold;")
|
|
191
|
+
new_edit = QLineEdit(val)
|
|
192
|
+
new_edit.setPlaceholderText("New name (leave unchanged to keep)")
|
|
193
|
+
self.value_edits[val] = new_edit
|
|
194
|
+
row.addWidget(old_label)
|
|
195
|
+
row.addWidget(new_edit)
|
|
196
|
+
scroll_layout.addLayout(row)
|
|
197
|
+
|
|
198
|
+
scroll_layout.addStretch()
|
|
199
|
+
scroll_widget.setLayout(scroll_layout)
|
|
200
|
+
scroll.setWidget(scroll_widget)
|
|
201
|
+
scroll.setWidgetResizable(True)
|
|
202
|
+
values_layout.addWidget(scroll)
|
|
203
|
+
|
|
204
|
+
values_group.setLayout(values_layout)
|
|
205
|
+
layout.addWidget(values_group)
|
|
206
|
+
|
|
207
|
+
# Buttons
|
|
208
|
+
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
|
209
|
+
buttons.accepted.connect(self.accept)
|
|
210
|
+
buttons.rejected.connect(self.reject)
|
|
211
|
+
layout.addWidget(buttons)
|
|
212
|
+
|
|
213
|
+
def get_changes(self):
|
|
214
|
+
"""Get the changes: (new_column_name, value_mapping_dict)."""
|
|
215
|
+
new_name = self.column_name_edit.text().strip()
|
|
216
|
+
if not new_name:
|
|
217
|
+
new_name = self.column_name
|
|
218
|
+
|
|
219
|
+
# Build value mapping
|
|
220
|
+
value_map = {}
|
|
221
|
+
for old_val, edit in self.value_edits.items():
|
|
222
|
+
new_val = edit.text().strip()
|
|
223
|
+
if new_val and new_val != old_val:
|
|
224
|
+
value_map[old_val] = new_val
|
|
225
|
+
|
|
226
|
+
return new_name, value_map
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class MetadataManagementDialog(QDialog):
|
|
230
|
+
"""Dialog for managing metadata columns and assignments."""
|
|
231
|
+
|
|
232
|
+
def __init__(self, metadata: pd.DataFrame, metadata_file_path: str, config: dict, parent=None):
|
|
233
|
+
super().__init__(parent)
|
|
234
|
+
self.config = config
|
|
235
|
+
self.metadata = metadata.copy()
|
|
236
|
+
self.metadata_file_path = metadata_file_path
|
|
237
|
+
|
|
238
|
+
self.setWindowTitle("Metadata management")
|
|
239
|
+
self.resize(1200, 800)
|
|
240
|
+
|
|
241
|
+
self._setup_ui()
|
|
242
|
+
|
|
243
|
+
def get_metadata(self):
|
|
244
|
+
return self.metadata
|
|
245
|
+
|
|
246
|
+
def get_metadata_path(self):
|
|
247
|
+
return self.metadata_file_path
|
|
248
|
+
|
|
249
|
+
def _setup_ui(self):
|
|
250
|
+
layout = QVBoxLayout(self)
|
|
251
|
+
layout.setContentsMargins(6, 2, 6, 6) # Minimal margins
|
|
252
|
+
layout.setSpacing(2) # Tight vertical spacing
|
|
253
|
+
|
|
254
|
+
# -- Header --
|
|
255
|
+
header_layout = QHBoxLayout()
|
|
256
|
+
header_layout.setContentsMargins(0, 0, 0, 0)
|
|
257
|
+
header_layout.setSpacing(5)
|
|
258
|
+
title = QLabel("Metadata Editor")
|
|
259
|
+
title.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
|
260
|
+
title.setContentsMargins(0, 0, 0, 0)
|
|
261
|
+
title.setStyleSheet("margin: 0px; padding: 0px;")
|
|
262
|
+
header_layout.addWidget(title)
|
|
263
|
+
|
|
264
|
+
header_layout.addStretch()
|
|
265
|
+
|
|
266
|
+
# Load status
|
|
267
|
+
self.status_label = QLabel()
|
|
268
|
+
if self.metadata_file_path:
|
|
269
|
+
self.status_label.setText(f"Editing: {os.path.basename(self.metadata_file_path)}")
|
|
270
|
+
else:
|
|
271
|
+
self.status_label.setText("Editing: New/Unsaved Metadata")
|
|
272
|
+
self.status_label.setStyleSheet("color: gray; margin: 0px; padding: 0px;")
|
|
273
|
+
header_layout.addWidget(self.status_label)
|
|
274
|
+
|
|
275
|
+
layout.addLayout(header_layout)
|
|
276
|
+
|
|
277
|
+
# -- Main Content (Splitter) --
|
|
278
|
+
splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
279
|
+
splitter.setContentsMargins(0, 0, 0, 0)
|
|
280
|
+
|
|
281
|
+
# 1. Left Panel: Controls (Tabbed)
|
|
282
|
+
controls_panel = QWidget()
|
|
283
|
+
controls_layout = QVBoxLayout(controls_panel)
|
|
284
|
+
controls_layout.setContentsMargins(0, 0, 0, 0)
|
|
285
|
+
controls_panel.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
|
|
286
|
+
|
|
287
|
+
self.tabs = QTabWidget()
|
|
288
|
+
|
|
289
|
+
# Tab 1: Manage Structure (Columns) - FIRST
|
|
290
|
+
self.structure_tab = QWidget()
|
|
291
|
+
self._setup_structure_tab()
|
|
292
|
+
self.tabs.addTab(self.structure_tab, "Manage Columns")
|
|
293
|
+
|
|
294
|
+
# Tab 2: Edit Data (Bulk & Quick Actions) - SECOND
|
|
295
|
+
self.edit_tab = QWidget()
|
|
296
|
+
self._setup_edit_tab()
|
|
297
|
+
self.tabs.addTab(self.edit_tab, "Edit Values")
|
|
298
|
+
|
|
299
|
+
controls_layout.addWidget(self.tabs)
|
|
300
|
+
|
|
301
|
+
# Save/Load buttons at bottom of controls
|
|
302
|
+
file_group = QGroupBox("File operations")
|
|
303
|
+
file_layout = QVBoxLayout()
|
|
304
|
+
|
|
305
|
+
hbox_load = QHBoxLayout()
|
|
306
|
+
self.load_btn = QPushButton("Load experiment data")
|
|
307
|
+
self.load_btn.clicked.connect(self.load_metadata)
|
|
308
|
+
self.load_file_btn = QPushButton("Load CSV...")
|
|
309
|
+
self.load_file_btn.clicked.connect(self.load_external_metadata)
|
|
310
|
+
hbox_load.addWidget(self.load_btn)
|
|
311
|
+
hbox_load.addWidget(self.load_file_btn)
|
|
312
|
+
file_layout.addLayout(hbox_load)
|
|
313
|
+
|
|
314
|
+
self.save_btn = QPushButton("Save changes to file")
|
|
315
|
+
self.save_btn.setStyleSheet("font-weight: bold;")
|
|
316
|
+
self.save_btn.clicked.connect(self.save_metadata)
|
|
317
|
+
file_layout.addWidget(self.save_btn)
|
|
318
|
+
|
|
319
|
+
file_group.setLayout(file_layout)
|
|
320
|
+
controls_layout.addWidget(file_group)
|
|
321
|
+
|
|
322
|
+
controls_panel.setMinimumWidth(350)
|
|
323
|
+
controls_panel.setMaximumWidth(400)
|
|
324
|
+
splitter.addWidget(controls_panel)
|
|
325
|
+
|
|
326
|
+
# 2. Right Panel: Data Table
|
|
327
|
+
table_panel = QWidget()
|
|
328
|
+
table_layout = QVBoxLayout(table_panel)
|
|
329
|
+
table_layout.setContentsMargins(0, 0, 0, 0)
|
|
330
|
+
table_layout.setSpacing(0) # No spacing
|
|
331
|
+
table_panel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
332
|
+
|
|
333
|
+
preview_label = QLabel("<b>Data preview</b>")
|
|
334
|
+
preview_label.setContentsMargins(0, 0, 0, 0)
|
|
335
|
+
preview_label.setStyleSheet("margin: 0px; padding: 0px 0px 2px 0px;")
|
|
336
|
+
preview_label.setMaximumHeight(18)
|
|
337
|
+
table_layout.addWidget(preview_label)
|
|
338
|
+
|
|
339
|
+
# Paging controls
|
|
340
|
+
paging_layout = QHBoxLayout()
|
|
341
|
+
self.prev_page_btn = QPushButton("Prev")
|
|
342
|
+
self.next_page_btn = QPushButton("Next")
|
|
343
|
+
self.page_info_label = QLabel("")
|
|
344
|
+
self.page_size_spin = QSpinBox()
|
|
345
|
+
self.page_size_spin.setRange(100, 20000)
|
|
346
|
+
self.page_size_spin.setSingleStep(500)
|
|
347
|
+
self.page_size_spin.setValue(5000)
|
|
348
|
+
paging_layout.addWidget(self.prev_page_btn)
|
|
349
|
+
paging_layout.addWidget(self.next_page_btn)
|
|
350
|
+
paging_layout.addWidget(self.page_info_label, 1)
|
|
351
|
+
paging_layout.addWidget(QLabel("Page size:"))
|
|
352
|
+
paging_layout.addWidget(self.page_size_spin)
|
|
353
|
+
table_layout.addLayout(paging_layout)
|
|
354
|
+
|
|
355
|
+
# Table view with model
|
|
356
|
+
self.table_model = PandasTableModel(self.metadata if hasattr(self, "metadata") else pd.DataFrame())
|
|
357
|
+
self.table = QTableView()
|
|
358
|
+
self.table.setModel(self.table_model)
|
|
359
|
+
self.table.setAlternatingRowColors(True)
|
|
360
|
+
self.table.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked | QAbstractItemView.EditTrigger.SelectedClicked)
|
|
361
|
+
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
|
362
|
+
self.table.horizontalHeader().setStretchLastSection(True)
|
|
363
|
+
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectItems)
|
|
364
|
+
table_layout.addWidget(self.table, 1) # Give table stretch factor
|
|
365
|
+
|
|
366
|
+
# Progress bar for heavy operations
|
|
367
|
+
self.progress_bar = QProgressBar()
|
|
368
|
+
self.progress_bar.setVisible(False)
|
|
369
|
+
self.progress_bar.setRange(0, 0) # Indeterminate
|
|
370
|
+
table_layout.addWidget(self.progress_bar)
|
|
371
|
+
|
|
372
|
+
table_panel.setLayout(table_layout)
|
|
373
|
+
splitter.addWidget(table_panel)
|
|
374
|
+
|
|
375
|
+
splitter.setStretchFactor(1, 1) # Give table more space
|
|
376
|
+
splitter.setCollapsible(0, False)
|
|
377
|
+
layout.addWidget(splitter, 1)
|
|
378
|
+
|
|
379
|
+
# -- Footer Buttons --
|
|
380
|
+
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
|
381
|
+
button_box.accepted.connect(self.accept)
|
|
382
|
+
button_box.rejected.connect(self.reject)
|
|
383
|
+
layout.addWidget(button_box)
|
|
384
|
+
|
|
385
|
+
# Initial Load
|
|
386
|
+
self.page_size = self.page_size_spin.value()
|
|
387
|
+
self.current_page = 0
|
|
388
|
+
self.prev_page_btn.clicked.connect(self._prev_page)
|
|
389
|
+
self.next_page_btn.clicked.connect(self._next_page)
|
|
390
|
+
self.page_size_spin.valueChanged.connect(self._on_page_size_changed)
|
|
391
|
+
if self.metadata is not None:
|
|
392
|
+
self._update_table()
|
|
393
|
+
self._update_combos()
|
|
394
|
+
self._update_paging()
|
|
395
|
+
|
|
396
|
+
def _setup_edit_tab(self):
|
|
397
|
+
layout = QVBoxLayout(self.edit_tab)
|
|
398
|
+
layout.setSpacing(15)
|
|
399
|
+
|
|
400
|
+
# Initialize filter rows list
|
|
401
|
+
self.filter_rows = []
|
|
402
|
+
|
|
403
|
+
# --- Bulk Assignment ---
|
|
404
|
+
bulk_group = QGroupBox("Bulk edit rule")
|
|
405
|
+
bulk_group.setStyleSheet("QGroupBox { font-weight: bold; }")
|
|
406
|
+
bulk_layout = QVBoxLayout()
|
|
407
|
+
|
|
408
|
+
# Sentence builder style
|
|
409
|
+
|
|
410
|
+
# Row 1: "Set [Target Column] to [Value]"
|
|
411
|
+
row1 = QHBoxLayout()
|
|
412
|
+
row1.addWidget(QLabel("Set column"))
|
|
413
|
+
self.target_column_combo = QComboBox()
|
|
414
|
+
row1.addWidget(self.target_column_combo, 1)
|
|
415
|
+
row1.addWidget(QLabel("to value"))
|
|
416
|
+
bulk_layout.addLayout(row1)
|
|
417
|
+
|
|
418
|
+
self.target_value_combo = QComboBox()
|
|
419
|
+
self.target_value_combo.setEditable(True) # allow free text but suggest classes
|
|
420
|
+
self.target_value_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
|
|
421
|
+
self.target_value_combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
|
|
422
|
+
self.target_value_combo.setMinimumHeight(26)
|
|
423
|
+
bulk_layout.addWidget(self.target_value_combo)
|
|
424
|
+
|
|
425
|
+
# Separator line
|
|
426
|
+
line = QFrame()
|
|
427
|
+
line.setFrameShape(QFrame.Shape.HLine)
|
|
428
|
+
line.setFrameShadow(QFrame.Shadow.Sunken)
|
|
429
|
+
bulk_layout.addWidget(line)
|
|
430
|
+
|
|
431
|
+
# Conditions Header
|
|
432
|
+
bulk_layout.addWidget(QLabel("Conditions (Match ALL):"))
|
|
433
|
+
|
|
434
|
+
# Scroll area for conditions
|
|
435
|
+
scroll = QScrollArea()
|
|
436
|
+
scroll.setWidgetResizable(True)
|
|
437
|
+
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
|
438
|
+
scroll.setMinimumHeight(150)
|
|
439
|
+
|
|
440
|
+
self.conditions_container = QWidget()
|
|
441
|
+
self.conditions_layout = QVBoxLayout(self.conditions_container)
|
|
442
|
+
self.conditions_layout.setContentsMargins(0, 0, 0, 0)
|
|
443
|
+
self.conditions_layout.setSpacing(5)
|
|
444
|
+
self.conditions_layout.addStretch() # Push items up
|
|
445
|
+
|
|
446
|
+
scroll.setWidget(self.conditions_container)
|
|
447
|
+
bulk_layout.addWidget(scroll)
|
|
448
|
+
|
|
449
|
+
# Add Condition Button
|
|
450
|
+
add_cond_btn = QPushButton("+ Add Condition")
|
|
451
|
+
add_cond_btn.clicked.connect(self.add_condition_row)
|
|
452
|
+
bulk_layout.addWidget(add_cond_btn)
|
|
453
|
+
|
|
454
|
+
# Apply Button
|
|
455
|
+
self.apply_bulk_btn = QPushButton("Apply rule")
|
|
456
|
+
self.apply_bulk_btn.setStyleSheet("background-color: #e1f5fe; color: #0277bd; border: 1px solid #0277bd; padding: 5px; font-weight: bold;")
|
|
457
|
+
self.apply_bulk_btn.clicked.connect(self.apply_bulk_assignment)
|
|
458
|
+
bulk_layout.addWidget(self.apply_bulk_btn)
|
|
459
|
+
|
|
460
|
+
bulk_group.setLayout(bulk_layout)
|
|
461
|
+
layout.addWidget(bulk_group)
|
|
462
|
+
|
|
463
|
+
# --- Quick Helpers ---
|
|
464
|
+
quick_group = QGroupBox("Quick helpers")
|
|
465
|
+
quick_layout = QVBoxLayout()
|
|
466
|
+
|
|
467
|
+
self.assign_by_video_btn = QPushButton("Set values per video...")
|
|
468
|
+
self.assign_by_video_btn.setToolTip("Assign a specific value to a column for all rows belonging to a specific video.")
|
|
469
|
+
self.assign_by_video_btn.clicked.connect(self.assign_by_video)
|
|
470
|
+
quick_layout.addWidget(self.assign_by_video_btn)
|
|
471
|
+
|
|
472
|
+
self.assign_by_object_btn = QPushButton("Set values per object...")
|
|
473
|
+
self.assign_by_object_btn.setToolTip("Assign a specific value to a column for all rows belonging to a specific object ID.")
|
|
474
|
+
self.assign_by_object_btn.clicked.connect(self.assign_by_object)
|
|
475
|
+
quick_layout.addWidget(self.assign_by_object_btn)
|
|
476
|
+
|
|
477
|
+
quick_group.setLayout(quick_layout)
|
|
478
|
+
layout.addWidget(quick_group)
|
|
479
|
+
|
|
480
|
+
layout.addStretch()
|
|
481
|
+
|
|
482
|
+
def _setup_structure_tab(self):
|
|
483
|
+
layout = QVBoxLayout(self.structure_tab)
|
|
484
|
+
layout.setSpacing(10)
|
|
485
|
+
|
|
486
|
+
info = QLabel("Manage the columns in your metadata table.")
|
|
487
|
+
info.setWordWrap(True)
|
|
488
|
+
layout.addWidget(info)
|
|
489
|
+
|
|
490
|
+
self.columns_list = QListWidget()
|
|
491
|
+
self.columns_list.setSelectionMode(QListWidget.SelectionMode.SingleSelection)
|
|
492
|
+
layout.addWidget(self.columns_list)
|
|
493
|
+
|
|
494
|
+
btn_layout = QHBoxLayout()
|
|
495
|
+
self.add_col_btn = QPushButton("Add")
|
|
496
|
+
self.add_col_btn.clicked.connect(self.add_column)
|
|
497
|
+
self.edit_col_btn = QPushButton("Edit")
|
|
498
|
+
self.edit_col_btn.clicked.connect(self.edit_column)
|
|
499
|
+
self.remove_col_btn = QPushButton("Delete")
|
|
500
|
+
self.remove_col_btn.clicked.connect(self.remove_column)
|
|
501
|
+
|
|
502
|
+
btn_layout.addWidget(self.add_col_btn)
|
|
503
|
+
btn_layout.addWidget(self.edit_col_btn)
|
|
504
|
+
btn_layout.addWidget(self.remove_col_btn)
|
|
505
|
+
layout.addLayout(btn_layout)
|
|
506
|
+
|
|
507
|
+
def load_metadata(self):
|
|
508
|
+
"""Load metadata from experiment folder."""
|
|
509
|
+
experiment_path = self.config.get("experiment_path")
|
|
510
|
+
if not experiment_path:
|
|
511
|
+
QMessageBox.warning(self, "No Experiment", "Please create or load an experiment first.")
|
|
512
|
+
return
|
|
513
|
+
|
|
514
|
+
registered_clips_dir = os.path.join(experiment_path, "registered_clips")
|
|
515
|
+
if not os.path.exists(registered_clips_dir):
|
|
516
|
+
QMessageBox.warning(self, "No Data", "No registered clips directory found.")
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
# Look for behaviorome metadata CSV
|
|
520
|
+
csv_files = [f for f in os.listdir(registered_clips_dir)
|
|
521
|
+
if f.startswith("behaviorome_") and f.endswith("_metadata.csv")]
|
|
522
|
+
|
|
523
|
+
if not csv_files:
|
|
524
|
+
QMessageBox.warning(self, "No Data", "No metadata files found.")
|
|
525
|
+
return
|
|
526
|
+
|
|
527
|
+
csv_files.sort(reverse=True)
|
|
528
|
+
metadata_file = os.path.join(registered_clips_dir, csv_files[0])
|
|
529
|
+
self._load_metadata_file(metadata_file)
|
|
530
|
+
|
|
531
|
+
def load_external_metadata(self):
|
|
532
|
+
"""Load external metadata CSV."""
|
|
533
|
+
metadata_path, _ = QFileDialog.getOpenFileName(self, "Open Metadata CSV", "", "CSV Files (*.csv)")
|
|
534
|
+
if metadata_path:
|
|
535
|
+
self._load_metadata_file(metadata_path)
|
|
536
|
+
|
|
537
|
+
def accept(self):
|
|
538
|
+
"""Override accept to save metadata before closing."""
|
|
539
|
+
if self.metadata_file_path:
|
|
540
|
+
try:
|
|
541
|
+
self._save_metadata_to_file(self.metadata, self.metadata_file_path)
|
|
542
|
+
except Exception as e:
|
|
543
|
+
QMessageBox.warning(self, "Save Warning", f"Could not save metadata: {e}")
|
|
544
|
+
|
|
545
|
+
super().accept()
|
|
546
|
+
|
|
547
|
+
def _load_metadata_file(self, file_path):
|
|
548
|
+
try:
|
|
549
|
+
self._set_busy(True, "Loading metadata...")
|
|
550
|
+
self.metadata = pd.read_csv(file_path)
|
|
551
|
+
self.metadata_file_path = file_path
|
|
552
|
+
|
|
553
|
+
# Restore categorical columns if they exist
|
|
554
|
+
# Check if any columns are already categorical
|
|
555
|
+
for col in self.metadata.columns:
|
|
556
|
+
if isinstance(self.metadata[col].dtype, pd.CategoricalDtype):
|
|
557
|
+
# Categories are already preserved in Categorical dtype
|
|
558
|
+
pass
|
|
559
|
+
|
|
560
|
+
self._update_table()
|
|
561
|
+
self._update_combos()
|
|
562
|
+
self._update_paging()
|
|
563
|
+
self.status_label.setText(f"Editing: {os.path.basename(file_path)}")
|
|
564
|
+
except Exception as e:
|
|
565
|
+
QMessageBox.critical(self, "Load Error", f"Failed to load metadata: {e}")
|
|
566
|
+
finally:
|
|
567
|
+
self._set_busy(False)
|
|
568
|
+
|
|
569
|
+
def _update_table(self):
|
|
570
|
+
if self.metadata is None:
|
|
571
|
+
return
|
|
572
|
+
self._update_paging()
|
|
573
|
+
|
|
574
|
+
def _update_combos(self):
|
|
575
|
+
if self.metadata is None:
|
|
576
|
+
return
|
|
577
|
+
|
|
578
|
+
columns = self.metadata.columns.tolist()
|
|
579
|
+
|
|
580
|
+
self.target_column_combo.blockSignals(True)
|
|
581
|
+
self.target_column_combo.clear()
|
|
582
|
+
self.target_column_combo.addItems(columns)
|
|
583
|
+
self.target_column_combo.blockSignals(False)
|
|
584
|
+
|
|
585
|
+
# Columns list
|
|
586
|
+
self.columns_list.clear()
|
|
587
|
+
self.columns_list.addItems(columns)
|
|
588
|
+
|
|
589
|
+
# Update existing filter rows
|
|
590
|
+
for row in self.filter_rows:
|
|
591
|
+
curr = row['combo'].currentText()
|
|
592
|
+
row['combo'].blockSignals(True)
|
|
593
|
+
row['combo'].clear()
|
|
594
|
+
row['combo'].addItems(columns)
|
|
595
|
+
if curr in columns:
|
|
596
|
+
row['combo'].setCurrentText(curr)
|
|
597
|
+
row['combo'].blockSignals(False)
|
|
598
|
+
|
|
599
|
+
self._update_target_values()
|
|
600
|
+
self.target_column_combo.currentTextChanged.connect(self._update_target_values)
|
|
601
|
+
|
|
602
|
+
def add_condition_row(self):
|
|
603
|
+
"""Add a new filter condition row."""
|
|
604
|
+
if self.metadata is None:
|
|
605
|
+
return
|
|
606
|
+
|
|
607
|
+
row_widget = QWidget()
|
|
608
|
+
row_layout = QHBoxLayout(row_widget)
|
|
609
|
+
row_layout.setContentsMargins(0, 0, 0, 0)
|
|
610
|
+
|
|
611
|
+
# Column combo
|
|
612
|
+
combo = QComboBox()
|
|
613
|
+
combo.addItems(self.metadata.columns.tolist())
|
|
614
|
+
row_layout.addWidget(combo, 1)
|
|
615
|
+
|
|
616
|
+
# Values button
|
|
617
|
+
val_btn = QPushButton("Select Values...")
|
|
618
|
+
row_layout.addWidget(val_btn, 2)
|
|
619
|
+
|
|
620
|
+
# Remove button
|
|
621
|
+
remove_btn = QPushButton("X")
|
|
622
|
+
remove_btn.setMaximumWidth(30)
|
|
623
|
+
remove_btn.setStyleSheet("color: red; font-weight: bold;")
|
|
624
|
+
row_layout.addWidget(remove_btn)
|
|
625
|
+
|
|
626
|
+
# Row data structure
|
|
627
|
+
row_data = {
|
|
628
|
+
'widget': row_widget,
|
|
629
|
+
'combo': combo,
|
|
630
|
+
'val_btn': val_btn,
|
|
631
|
+
'values': set() # Selected values
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
# Connect signals
|
|
635
|
+
val_btn.clicked.connect(lambda: self.open_values_dialog(row_data))
|
|
636
|
+
remove_btn.clicked.connect(lambda: self.remove_condition_row(row_data))
|
|
637
|
+
combo.currentTextChanged.connect(lambda: self.reset_row_values(row_data))
|
|
638
|
+
|
|
639
|
+
# Insert before the stretch item
|
|
640
|
+
self.conditions_layout.insertWidget(self.conditions_layout.count() - 1, row_widget)
|
|
641
|
+
self.filter_rows.append(row_data)
|
|
642
|
+
|
|
643
|
+
def remove_condition_row(self, row_data):
|
|
644
|
+
"""Remove a filter row."""
|
|
645
|
+
if row_data in self.filter_rows:
|
|
646
|
+
self.filter_rows.remove(row_data)
|
|
647
|
+
row_data['widget'].deleteLater()
|
|
648
|
+
|
|
649
|
+
def reset_row_values(self, row_data):
|
|
650
|
+
"""Reset values when column changes."""
|
|
651
|
+
row_data['values'] = set()
|
|
652
|
+
row_data['val_btn'].setText("Select Values...")
|
|
653
|
+
|
|
654
|
+
def open_values_dialog(self, row_data):
|
|
655
|
+
"""Open dialog with checkboxes to select values."""
|
|
656
|
+
col = row_data['combo'].currentText()
|
|
657
|
+
if not col or col not in self.metadata.columns:
|
|
658
|
+
return
|
|
659
|
+
|
|
660
|
+
unique_vals = sorted(self.metadata[col].dropna().unique().astype(str))
|
|
661
|
+
|
|
662
|
+
dialog = QDialog(self)
|
|
663
|
+
dialog.setWindowTitle(f"Select values for '{col}'")
|
|
664
|
+
dialog.setMinimumWidth(300)
|
|
665
|
+
dialog.setMinimumHeight(400)
|
|
666
|
+
layout = QVBoxLayout(dialog)
|
|
667
|
+
|
|
668
|
+
# Search/Filter
|
|
669
|
+
search_edit = QLineEdit()
|
|
670
|
+
search_edit.setPlaceholderText("Search...")
|
|
671
|
+
layout.addWidget(search_edit)
|
|
672
|
+
|
|
673
|
+
# Checkboxes area
|
|
674
|
+
scroll = QScrollArea()
|
|
675
|
+
scroll.setWidgetResizable(True)
|
|
676
|
+
container = QWidget()
|
|
677
|
+
chk_layout = QVBoxLayout(container)
|
|
678
|
+
chk_layout.setSpacing(2)
|
|
679
|
+
|
|
680
|
+
checkboxes = []
|
|
681
|
+
for val in unique_vals:
|
|
682
|
+
chk = QCheckBox(val)
|
|
683
|
+
if val in row_data['values']:
|
|
684
|
+
chk.setChecked(True)
|
|
685
|
+
chk_layout.addWidget(chk)
|
|
686
|
+
checkboxes.append(chk)
|
|
687
|
+
|
|
688
|
+
chk_layout.addStretch()
|
|
689
|
+
container.setLayout(chk_layout)
|
|
690
|
+
scroll.setWidget(container)
|
|
691
|
+
layout.addWidget(scroll)
|
|
692
|
+
|
|
693
|
+
# Filter logic
|
|
694
|
+
def filter_items(text):
|
|
695
|
+
text = text.lower()
|
|
696
|
+
for chk in checkboxes:
|
|
697
|
+
chk.setVisible(text in chk.text().lower())
|
|
698
|
+
search_edit.textChanged.connect(filter_items)
|
|
699
|
+
|
|
700
|
+
# Select All / None
|
|
701
|
+
btn_row = QHBoxLayout()
|
|
702
|
+
all_btn = QPushButton("All")
|
|
703
|
+
none_btn = QPushButton("None")
|
|
704
|
+
btn_row.addWidget(all_btn)
|
|
705
|
+
btn_row.addWidget(none_btn)
|
|
706
|
+
layout.addLayout(btn_row)
|
|
707
|
+
|
|
708
|
+
def select_all():
|
|
709
|
+
for chk in checkboxes:
|
|
710
|
+
if chk.isVisible(): chk.setChecked(True)
|
|
711
|
+
def select_none():
|
|
712
|
+
for chk in checkboxes:
|
|
713
|
+
if chk.isVisible(): chk.setChecked(False)
|
|
714
|
+
|
|
715
|
+
all_btn.clicked.connect(select_all)
|
|
716
|
+
none_btn.clicked.connect(select_none)
|
|
717
|
+
|
|
718
|
+
# Dialog buttons
|
|
719
|
+
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
|
720
|
+
buttons.accepted.connect(dialog.accept)
|
|
721
|
+
buttons.rejected.connect(dialog.reject)
|
|
722
|
+
layout.addWidget(buttons)
|
|
723
|
+
|
|
724
|
+
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
725
|
+
new_selection = set()
|
|
726
|
+
for chk in checkboxes:
|
|
727
|
+
if chk.isChecked():
|
|
728
|
+
new_selection.add(chk.text())
|
|
729
|
+
|
|
730
|
+
row_data['values'] = new_selection
|
|
731
|
+
|
|
732
|
+
# Update button text
|
|
733
|
+
if not new_selection:
|
|
734
|
+
row_data['val_btn'].setText("Select Values...")
|
|
735
|
+
elif len(new_selection) <= 3:
|
|
736
|
+
row_data['val_btn'].setText(", ".join(sorted(new_selection)))
|
|
737
|
+
else:
|
|
738
|
+
row_data['val_btn'].setText(f"{len(new_selection)} selected")
|
|
739
|
+
|
|
740
|
+
def _update_target_values(self):
|
|
741
|
+
"""Populate target value combo with existing values when column is categorical."""
|
|
742
|
+
if self.metadata is None:
|
|
743
|
+
return
|
|
744
|
+
col = self.target_column_combo.currentText()
|
|
745
|
+
if col and col in self.metadata.columns:
|
|
746
|
+
# Check if column is categorical - use categories if available
|
|
747
|
+
if isinstance(self.metadata[col].dtype, pd.CategoricalDtype):
|
|
748
|
+
# Use the defined categories
|
|
749
|
+
categories = list(self.metadata[col].dtype.categories)
|
|
750
|
+
self.target_value_combo.blockSignals(True)
|
|
751
|
+
self.target_value_combo.clear()
|
|
752
|
+
self.target_value_combo.addItems(categories)
|
|
753
|
+
self.target_value_combo.setEditText("") # allow user to type new value
|
|
754
|
+
self.target_value_combo.blockSignals(False)
|
|
755
|
+
else:
|
|
756
|
+
# Regular column - use unique values
|
|
757
|
+
unique_vals = sorted(self.metadata[col].dropna().unique().astype(str))
|
|
758
|
+
self.target_value_combo.blockSignals(True)
|
|
759
|
+
self.target_value_combo.clear()
|
|
760
|
+
self.target_value_combo.addItems(unique_vals)
|
|
761
|
+
self.target_value_combo.setEditText("") # allow user to type new value
|
|
762
|
+
self.target_value_combo.blockSignals(False)
|
|
763
|
+
|
|
764
|
+
def _update_target_values(self):
|
|
765
|
+
"""Populate target value combo with existing values when column is categorical."""
|
|
766
|
+
if self.metadata is None:
|
|
767
|
+
return
|
|
768
|
+
col = self.target_column_combo.currentText()
|
|
769
|
+
if col and col in self.metadata.columns:
|
|
770
|
+
# Check if column is categorical - use categories if available
|
|
771
|
+
if isinstance(self.metadata[col].dtype, pd.CategoricalDtype):
|
|
772
|
+
# Use the defined categories
|
|
773
|
+
categories = list(self.metadata[col].dtype.categories)
|
|
774
|
+
self.target_value_combo.blockSignals(True)
|
|
775
|
+
self.target_value_combo.clear()
|
|
776
|
+
self.target_value_combo.addItems(categories)
|
|
777
|
+
self.target_value_combo.setEditText("") # allow user to type new value
|
|
778
|
+
self.target_value_combo.blockSignals(False)
|
|
779
|
+
else:
|
|
780
|
+
# Regular column - use unique values
|
|
781
|
+
unique_vals = sorted(self.metadata[col].dropna().unique().astype(str))
|
|
782
|
+
self.target_value_combo.blockSignals(True)
|
|
783
|
+
self.target_value_combo.clear()
|
|
784
|
+
self.target_value_combo.addItems(unique_vals)
|
|
785
|
+
self.target_value_combo.setEditText("") # allow user to type new value
|
|
786
|
+
self.target_value_combo.blockSignals(False)
|
|
787
|
+
|
|
788
|
+
def _open_filter_value_dialog(self, item: QTableWidgetItem):
|
|
789
|
+
"""Open dialog to select filter values for a column."""
|
|
790
|
+
if item.column() != 1: # Only handle the values column
|
|
791
|
+
return
|
|
792
|
+
|
|
793
|
+
col = item.data(Qt.ItemDataRole.UserRole)
|
|
794
|
+
if not col or col not in self.metadata.columns:
|
|
795
|
+
return
|
|
796
|
+
|
|
797
|
+
# Get unique values for this column
|
|
798
|
+
unique_vals = sorted(self.metadata[col].dropna().unique().astype(str))
|
|
799
|
+
|
|
800
|
+
# Create dialog with multi-select list
|
|
801
|
+
dialog = QDialog(self)
|
|
802
|
+
dialog.setWindowTitle(f"Select values for '{col}'")
|
|
803
|
+
dialog.setMinimumWidth(300)
|
|
804
|
+
dialog.setMinimumHeight(400)
|
|
805
|
+
layout = QVBoxLayout(dialog)
|
|
806
|
+
|
|
807
|
+
info = QLabel("Select one or more values (Ctrl+Click for multiple):")
|
|
808
|
+
layout.addWidget(info)
|
|
809
|
+
|
|
810
|
+
list_widget = QListWidget()
|
|
811
|
+
list_widget.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
|
|
812
|
+
for val in unique_vals:
|
|
813
|
+
list_widget.addItem(val)
|
|
814
|
+
|
|
815
|
+
# Pre-select previously selected values
|
|
816
|
+
if not hasattr(self, 'filter_values_dict'):
|
|
817
|
+
self.filter_values_dict = {}
|
|
818
|
+
if col in self.filter_values_dict:
|
|
819
|
+
for i in range(list_widget.count()):
|
|
820
|
+
item_widget = list_widget.item(i)
|
|
821
|
+
if item_widget.text() in self.filter_values_dict[col]:
|
|
822
|
+
item_widget.setSelected(True)
|
|
823
|
+
|
|
824
|
+
layout.addWidget(list_widget)
|
|
825
|
+
|
|
826
|
+
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
|
827
|
+
buttons.accepted.connect(dialog.accept)
|
|
828
|
+
buttons.rejected.connect(dialog.reject)
|
|
829
|
+
layout.addWidget(buttons)
|
|
830
|
+
|
|
831
|
+
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
832
|
+
selected_items = list_widget.selectedItems()
|
|
833
|
+
selected_vals = [item.text() for item in selected_items]
|
|
834
|
+
self.filter_values_dict[col] = selected_vals
|
|
835
|
+
# Update the table display
|
|
836
|
+
val_text = ", ".join(selected_vals) if selected_vals else "(click to select)"
|
|
837
|
+
item.setText(val_text)
|
|
838
|
+
|
|
839
|
+
def _update_paging(self):
|
|
840
|
+
"""Refresh table model to current page."""
|
|
841
|
+
total = len(self.metadata) if self.metadata is not None else 0
|
|
842
|
+
if total == 0:
|
|
843
|
+
self.table_model.update_range(0, 0)
|
|
844
|
+
self._update_paging_label(total, 0, 0)
|
|
845
|
+
return
|
|
846
|
+
self.page_size = self.page_size_spin.value()
|
|
847
|
+
max_page = max(0, (total - 1) // self.page_size)
|
|
848
|
+
if self.current_page > max_page:
|
|
849
|
+
self.current_page = max_page
|
|
850
|
+
start = self.current_page * self.page_size
|
|
851
|
+
end = min(start + self.page_size, total)
|
|
852
|
+
self.table_model.df = self.metadata
|
|
853
|
+
self.table_model.update_range(start, end)
|
|
854
|
+
self._update_paging_label(total, start, end)
|
|
855
|
+
|
|
856
|
+
def _update_paging_label(self, total: int, start: int, end: int):
|
|
857
|
+
if total == 0:
|
|
858
|
+
self.page_info_label.setText("No rows")
|
|
859
|
+
else:
|
|
860
|
+
page_num = self.current_page + 1
|
|
861
|
+
page_count = max(1, (total + self.page_size - 1) // self.page_size)
|
|
862
|
+
self.page_info_label.setText(f"Page {page_num}/{page_count} rows {start+1}–{end} of {total}")
|
|
863
|
+
self.prev_page_btn.setEnabled(self.current_page > 0)
|
|
864
|
+
self.next_page_btn.setEnabled(end < total)
|
|
865
|
+
|
|
866
|
+
def _prev_page(self):
|
|
867
|
+
if self.current_page > 0:
|
|
868
|
+
self.current_page -= 1
|
|
869
|
+
self._update_paging()
|
|
870
|
+
|
|
871
|
+
def _next_page(self):
|
|
872
|
+
total = len(self.metadata) if self.metadata is not None else 0
|
|
873
|
+
if (self.current_page + 1) * self.page_size < total:
|
|
874
|
+
self.current_page += 1
|
|
875
|
+
self._update_paging()
|
|
876
|
+
|
|
877
|
+
def _on_page_size_changed(self, val: int):
|
|
878
|
+
self.page_size = val
|
|
879
|
+
self.current_page = 0
|
|
880
|
+
self._update_paging()
|
|
881
|
+
|
|
882
|
+
def _set_busy(self, busy: bool, message: str = "Working..."):
|
|
883
|
+
"""Show/hide the progress bar for long operations."""
|
|
884
|
+
self.progress_bar.setVisible(busy)
|
|
885
|
+
if busy:
|
|
886
|
+
self.progress_bar.setFormat(message)
|
|
887
|
+
QApplication.processEvents()
|
|
888
|
+
|
|
889
|
+
def add_column(self):
|
|
890
|
+
if self.metadata is None:
|
|
891
|
+
return
|
|
892
|
+
|
|
893
|
+
dialog = AddColumnDialog(self)
|
|
894
|
+
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
895
|
+
name, categories = dialog.get_column_info()
|
|
896
|
+
|
|
897
|
+
if not name or name in self.metadata.columns:
|
|
898
|
+
QMessageBox.warning(self, "Error", "Invalid or duplicate column name.")
|
|
899
|
+
return
|
|
900
|
+
|
|
901
|
+
if not categories:
|
|
902
|
+
QMessageBox.warning(self, "Error", "Please provide at least one option (class) for the category column.")
|
|
903
|
+
return
|
|
904
|
+
|
|
905
|
+
# Create column with Categorical dtype to preserve categories
|
|
906
|
+
self.metadata[name] = pd.Categorical([categories[0]] * len(self.metadata), categories=categories)
|
|
907
|
+
|
|
908
|
+
# Persist categories in DataFrame.attrs (pandas 1.5+).
|
|
909
|
+
if not hasattr(self.metadata, 'attrs'):
|
|
910
|
+
pass
|
|
911
|
+
else:
|
|
912
|
+
if '_column_categories' not in self.metadata.attrs:
|
|
913
|
+
self.metadata.attrs['_column_categories'] = {}
|
|
914
|
+
self.metadata.attrs['_column_categories'][name] = categories
|
|
915
|
+
|
|
916
|
+
self._update_table()
|
|
917
|
+
self._update_combos()
|
|
918
|
+
|
|
919
|
+
QMessageBox.information(self, "Success", f"Category column '{name}' created with {len(categories)} options: {', '.join(categories)}")
|
|
920
|
+
|
|
921
|
+
def edit_column(self):
|
|
922
|
+
"""Edit column name and rename values within the column."""
|
|
923
|
+
if self.metadata is None:
|
|
924
|
+
QMessageBox.warning(self, "No Data", "Please load metadata first.")
|
|
925
|
+
return
|
|
926
|
+
|
|
927
|
+
item = self.columns_list.currentItem()
|
|
928
|
+
if not item:
|
|
929
|
+
QMessageBox.warning(self, "Selection", "Please select a column from the list to edit.")
|
|
930
|
+
return
|
|
931
|
+
|
|
932
|
+
col = item.text()
|
|
933
|
+
|
|
934
|
+
# Open edit dialog
|
|
935
|
+
dialog = EditColumnDialog(col, self.metadata, self)
|
|
936
|
+
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
937
|
+
new_name, value_map = dialog.get_changes()
|
|
938
|
+
|
|
939
|
+
# Apply value renamings first (before column rename)
|
|
940
|
+
if value_map:
|
|
941
|
+
self.metadata[col] = self.metadata[col].astype(str).replace(value_map)
|
|
942
|
+
QMessageBox.information(self, "Values Updated", f"Renamed {len(value_map)} value(s) in column '{col}'.")
|
|
943
|
+
|
|
944
|
+
# Rename column if changed
|
|
945
|
+
if new_name != col:
|
|
946
|
+
if new_name in self.metadata.columns:
|
|
947
|
+
QMessageBox.warning(self, "Duplicate", f"Column '{new_name}' already exists.")
|
|
948
|
+
return
|
|
949
|
+
self.metadata.rename(columns={col: new_name}, inplace=True)
|
|
950
|
+
QMessageBox.information(self, "Column Renamed", f"Column '{col}' renamed to '{new_name}'.")
|
|
951
|
+
|
|
952
|
+
# Refresh UI
|
|
953
|
+
self._update_table()
|
|
954
|
+
self._update_combos()
|
|
955
|
+
|
|
956
|
+
def remove_column(self):
|
|
957
|
+
if self.metadata is None:
|
|
958
|
+
return
|
|
959
|
+
|
|
960
|
+
item = self.columns_list.currentItem()
|
|
961
|
+
if not item:
|
|
962
|
+
QMessageBox.warning(self, "Selection", "Please select a column from the list to remove.")
|
|
963
|
+
return
|
|
964
|
+
|
|
965
|
+
col = item.text()
|
|
966
|
+
if col in ['snippet', 'span_id', 'video_id', 'object_id', 'clip_index']: # span_id for backward compatibility
|
|
967
|
+
QMessageBox.warning(self, "Protected", f"Cannot remove essential column '{col}'.")
|
|
968
|
+
return
|
|
969
|
+
|
|
970
|
+
reply = QMessageBox.question(self, "Confirm", f"Remove column '{col}'?",
|
|
971
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
|
972
|
+
if reply == QMessageBox.StandardButton.Yes:
|
|
973
|
+
self.metadata = self.metadata.drop(columns=[col])
|
|
974
|
+
self._update_table()
|
|
975
|
+
self._update_combos()
|
|
976
|
+
|
|
977
|
+
def apply_bulk_assignment(self):
|
|
978
|
+
if self.metadata is None:
|
|
979
|
+
return
|
|
980
|
+
|
|
981
|
+
target_col = self.target_column_combo.currentText()
|
|
982
|
+
target_val = self.target_value_combo.currentText().strip()
|
|
983
|
+
|
|
984
|
+
if not self.filter_rows:
|
|
985
|
+
QMessageBox.information(self, "No Filter", "Please add at least one condition.")
|
|
986
|
+
return
|
|
987
|
+
|
|
988
|
+
if not target_col:
|
|
989
|
+
QMessageBox.information(self, "No Target", "Please select a target column.")
|
|
990
|
+
return
|
|
991
|
+
|
|
992
|
+
# Check for missing values in rows
|
|
993
|
+
missing_cols = []
|
|
994
|
+
for row in self.filter_rows:
|
|
995
|
+
if not row['values']:
|
|
996
|
+
missing_cols.append(row['combo'].currentText())
|
|
997
|
+
|
|
998
|
+
if missing_cols:
|
|
999
|
+
QMessageBox.information(
|
|
1000
|
+
self,
|
|
1001
|
+
"Missing Values",
|
|
1002
|
+
f"Please select values for the following columns:\n{', '.join(missing_cols)}"
|
|
1003
|
+
)
|
|
1004
|
+
return
|
|
1005
|
+
|
|
1006
|
+
# Build combined mask: all conditions must match (AND logic)
|
|
1007
|
+
mask = pd.Series([True] * len(self.metadata), index=self.metadata.index)
|
|
1008
|
+
for row in self.filter_rows:
|
|
1009
|
+
col = row['combo'].currentText()
|
|
1010
|
+
filter_vals = list(row['values'])
|
|
1011
|
+
col_mask = self.metadata[col].astype(str).isin(filter_vals)
|
|
1012
|
+
mask = mask & col_mask
|
|
1013
|
+
|
|
1014
|
+
count = mask.sum()
|
|
1015
|
+
|
|
1016
|
+
if count == 0:
|
|
1017
|
+
QMessageBox.information(self, "No Matches", "No rows matched all the criteria.")
|
|
1018
|
+
return
|
|
1019
|
+
|
|
1020
|
+
# Validate target value for categorical columns
|
|
1021
|
+
if isinstance(self.metadata[target_col].dtype, pd.CategoricalDtype):
|
|
1022
|
+
if target_val not in self.metadata[target_col].dtype.categories:
|
|
1023
|
+
QMessageBox.warning(self, "Invalid Category",
|
|
1024
|
+
f"Value '{target_val}' is not a valid option for column '{target_col}'.\n"
|
|
1025
|
+
f"Valid options are: {', '.join(self.metadata[target_col].dtype.categories)}")
|
|
1026
|
+
return
|
|
1027
|
+
# Numeric conversion
|
|
1028
|
+
elif self.metadata[target_col].dtype in [np.int64, np.float64]:
|
|
1029
|
+
try:
|
|
1030
|
+
target_val = float(target_val)
|
|
1031
|
+
except:
|
|
1032
|
+
QMessageBox.warning(self, "Invalid", "Target column is numeric.")
|
|
1033
|
+
return
|
|
1034
|
+
|
|
1035
|
+
self._set_busy(True, "Applying rule...")
|
|
1036
|
+
try:
|
|
1037
|
+
self.metadata.loc[mask, target_col] = target_val
|
|
1038
|
+
self._update_table()
|
|
1039
|
+
QMessageBox.information(self, "Success", f"Updated {count} rows.")
|
|
1040
|
+
finally:
|
|
1041
|
+
self._set_busy(False)
|
|
1042
|
+
|
|
1043
|
+
def assign_by_video(self):
|
|
1044
|
+
"""Assign by video helper."""
|
|
1045
|
+
if self.metadata is None or 'video_id' not in self.metadata.columns:
|
|
1046
|
+
return
|
|
1047
|
+
|
|
1048
|
+
from PyQt6.QtWidgets import QInputDialog
|
|
1049
|
+
target_col, ok = QInputDialog.getItem(self, "Select Column", "Target column to set:",
|
|
1050
|
+
self.metadata.columns.tolist(), 0, False)
|
|
1051
|
+
if not ok: return
|
|
1052
|
+
|
|
1053
|
+
unique_videos = sorted(self.metadata['video_id'].dropna().unique())
|
|
1054
|
+
for video_id in unique_videos:
|
|
1055
|
+
count = (self.metadata['video_id'] == video_id).sum()
|
|
1056
|
+
val, ok = QInputDialog.getText(self, f"Assign: {video_id}", f"Value for '{target_col}' ({count} rows):")
|
|
1057
|
+
if ok and val:
|
|
1058
|
+
self.metadata.loc[self.metadata['video_id'] == video_id, target_col] = val
|
|
1059
|
+
self._update_table()
|
|
1060
|
+
|
|
1061
|
+
def assign_by_object(self):
|
|
1062
|
+
"""Assign by object helper."""
|
|
1063
|
+
if self.metadata is None or 'object_id' not in self.metadata.columns:
|
|
1064
|
+
return
|
|
1065
|
+
|
|
1066
|
+
from PyQt6.QtWidgets import QInputDialog
|
|
1067
|
+
target_col, ok = QInputDialog.getItem(self, "Select Column", "Target column to set:",
|
|
1068
|
+
self.metadata.columns.tolist(), 0, False)
|
|
1069
|
+
if not ok: return
|
|
1070
|
+
|
|
1071
|
+
unique_objs = sorted(self.metadata['object_id'].dropna().unique())
|
|
1072
|
+
for obj in unique_objs:
|
|
1073
|
+
count = (self.metadata['object_id'].astype(str) == str(obj)).sum()
|
|
1074
|
+
val, ok = QInputDialog.getText(self, f"Assign: Object {obj}", f"Value for '{target_col}' ({count} rows):")
|
|
1075
|
+
if ok and val:
|
|
1076
|
+
self.metadata.loc[self.metadata['object_id'].astype(str) == str(obj), target_col] = val
|
|
1077
|
+
self._update_table()
|
|
1078
|
+
|
|
1079
|
+
def _sync_table_to_metadata(self):
|
|
1080
|
+
# No-op because edits are committed directly via the model
|
|
1081
|
+
return
|
|
1082
|
+
|
|
1083
|
+
def _save_metadata_to_file(self, df: pd.DataFrame, path: str):
|
|
1084
|
+
"""Save metadata DataFrame to file, respecting the file format based on extension."""
|
|
1085
|
+
if path.endswith(".npz"):
|
|
1086
|
+
# Save as NPZ format
|
|
1087
|
+
np.savez_compressed(
|
|
1088
|
+
path,
|
|
1089
|
+
metadata=df.values,
|
|
1090
|
+
columns=np.array(df.columns, dtype=object),
|
|
1091
|
+
)
|
|
1092
|
+
elif path.endswith(".parquet"):
|
|
1093
|
+
df.to_parquet(path, index=False)
|
|
1094
|
+
else:
|
|
1095
|
+
# Default to CSV
|
|
1096
|
+
df.to_csv(path, index=False)
|
|
1097
|
+
|
|
1098
|
+
def save_metadata(self):
|
|
1099
|
+
# Edits are already in the model/DataFrame
|
|
1100
|
+
if self.metadata_file_path:
|
|
1101
|
+
# Save categorical columns properly - they will be saved as strings in CSV
|
|
1102
|
+
# but we preserve the categorical structure
|
|
1103
|
+
self._save_metadata_to_file(self.metadata, self.metadata_file_path)
|
|
1104
|
+
QMessageBox.information(self, "Saved", f"Saved to {os.path.basename(self.metadata_file_path)}")
|
|
1105
|
+
else:
|
|
1106
|
+
path, _ = QFileDialog.getSaveFileName(self, "Save As", "", "CSV (*.csv);;NPZ (*.npz);;Parquet (*.parquet)")
|
|
1107
|
+
if path:
|
|
1108
|
+
self._save_metadata_to_file(self.metadata, path)
|
|
1109
|
+
self.metadata_file_path = path
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
# Keep the old widget class for backward compatibility
|
|
1113
|
+
class MetadataManagementWidget(QWidget):
|
|
1114
|
+
def __init__(self, config: dict):
|
|
1115
|
+
super().__init__()
|
|
1116
|
+
self.config = config
|
|
1117
|
+
def update_config(self, config: dict): self.config = config
|
|
1118
|
+
def load_metadata(self): pass
|
|
1119
|
+
def _sync_table_to_metadata(self): pass
|