singlebehaviorlab 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. sam2/__init__.py +11 -0
  2. sam2/automatic_mask_generator.py +454 -0
  3. sam2/benchmark.py +92 -0
  4. sam2/build_sam.py +174 -0
  5. sam2/configs/sam2/sam2_hiera_b+.yaml +113 -0
  6. sam2/configs/sam2/sam2_hiera_l.yaml +117 -0
  7. sam2/configs/sam2/sam2_hiera_s.yaml +116 -0
  8. sam2/configs/sam2/sam2_hiera_t.yaml +118 -0
  9. sam2/configs/sam2.1/sam2.1_hiera_b+.yaml +116 -0
  10. sam2/configs/sam2.1/sam2.1_hiera_l.yaml +120 -0
  11. sam2/configs/sam2.1/sam2.1_hiera_s.yaml +119 -0
  12. sam2/configs/sam2.1/sam2.1_hiera_t.yaml +121 -0
  13. sam2/configs/sam2.1_training/sam2.1_hiera_b+_MOSE_finetune.yaml +339 -0
  14. sam2/modeling/__init__.py +5 -0
  15. sam2/modeling/backbones/__init__.py +5 -0
  16. sam2/modeling/backbones/hieradet.py +317 -0
  17. sam2/modeling/backbones/image_encoder.py +134 -0
  18. sam2/modeling/backbones/utils.py +93 -0
  19. sam2/modeling/memory_attention.py +169 -0
  20. sam2/modeling/memory_encoder.py +181 -0
  21. sam2/modeling/position_encoding.py +239 -0
  22. sam2/modeling/sam/__init__.py +5 -0
  23. sam2/modeling/sam/mask_decoder.py +295 -0
  24. sam2/modeling/sam/prompt_encoder.py +202 -0
  25. sam2/modeling/sam/transformer.py +311 -0
  26. sam2/modeling/sam2_base.py +913 -0
  27. sam2/modeling/sam2_utils.py +323 -0
  28. sam2/sam2_hiera_b+.yaml +113 -0
  29. sam2/sam2_hiera_l.yaml +117 -0
  30. sam2/sam2_hiera_s.yaml +116 -0
  31. sam2/sam2_hiera_t.yaml +118 -0
  32. sam2/sam2_image_predictor.py +466 -0
  33. sam2/sam2_video_predictor.py +1388 -0
  34. sam2/sam2_video_predictor_legacy.py +1172 -0
  35. sam2/utils/__init__.py +5 -0
  36. sam2/utils/amg.py +348 -0
  37. sam2/utils/misc.py +349 -0
  38. sam2/utils/transforms.py +118 -0
  39. singlebehaviorlab/__init__.py +4 -0
  40. singlebehaviorlab/__main__.py +130 -0
  41. singlebehaviorlab/_paths.py +100 -0
  42. singlebehaviorlab/backend/__init__.py +2 -0
  43. singlebehaviorlab/backend/augmentations.py +320 -0
  44. singlebehaviorlab/backend/data_store.py +420 -0
  45. singlebehaviorlab/backend/model.py +1290 -0
  46. singlebehaviorlab/backend/train.py +4667 -0
  47. singlebehaviorlab/backend/uncertainty.py +578 -0
  48. singlebehaviorlab/backend/video_processor.py +688 -0
  49. singlebehaviorlab/backend/video_utils.py +139 -0
  50. singlebehaviorlab/data/config/config.yaml +85 -0
  51. singlebehaviorlab/data/training_profiles.json +334 -0
  52. singlebehaviorlab/gui/__init__.py +4 -0
  53. singlebehaviorlab/gui/analysis_widget.py +2291 -0
  54. singlebehaviorlab/gui/attention_export.py +311 -0
  55. singlebehaviorlab/gui/clip_extraction_widget.py +481 -0
  56. singlebehaviorlab/gui/clustering_widget.py +3187 -0
  57. singlebehaviorlab/gui/inference_popups.py +1138 -0
  58. singlebehaviorlab/gui/inference_widget.py +4550 -0
  59. singlebehaviorlab/gui/inference_worker.py +651 -0
  60. singlebehaviorlab/gui/labeling_widget.py +2324 -0
  61. singlebehaviorlab/gui/main_window.py +754 -0
  62. singlebehaviorlab/gui/metadata_management_widget.py +1119 -0
  63. singlebehaviorlab/gui/motion_tracking.py +764 -0
  64. singlebehaviorlab/gui/overlay_export.py +1234 -0
  65. singlebehaviorlab/gui/plot_integration.py +729 -0
  66. singlebehaviorlab/gui/qt_helpers.py +29 -0
  67. singlebehaviorlab/gui/registration_widget.py +1485 -0
  68. singlebehaviorlab/gui/review_widget.py +1330 -0
  69. singlebehaviorlab/gui/segmentation_tracking_widget.py +2752 -0
  70. singlebehaviorlab/gui/tab_tutorial_dialog.py +312 -0
  71. singlebehaviorlab/gui/timeline_themes.py +131 -0
  72. singlebehaviorlab/gui/training_profiles.py +418 -0
  73. singlebehaviorlab/gui/training_widget.py +3719 -0
  74. singlebehaviorlab/gui/video_utils.py +233 -0
  75. singlebehaviorlab/licenses/SAM2-LICENSE +201 -0
  76. singlebehaviorlab/licenses/VideoPrism-LICENSE +202 -0
  77. singlebehaviorlab-2.0.0.dist-info/METADATA +447 -0
  78. singlebehaviorlab-2.0.0.dist-info/RECORD +88 -0
  79. singlebehaviorlab-2.0.0.dist-info/WHEEL +5 -0
  80. singlebehaviorlab-2.0.0.dist-info/entry_points.txt +2 -0
  81. singlebehaviorlab-2.0.0.dist-info/licenses/LICENSE +21 -0
  82. singlebehaviorlab-2.0.0.dist-info/top_level.txt +3 -0
  83. videoprism/__init__.py +0 -0
  84. videoprism/encoders.py +910 -0
  85. videoprism/layers.py +1136 -0
  86. videoprism/models.py +407 -0
  87. videoprism/tokenizers.py +167 -0
  88. videoprism/utils.py +168 -0
@@ -0,0 +1,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