coralnet-toolbox 0.0.74__py2.py3-none-any.whl → 0.0.76__py2.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 (49) hide show
  1. coralnet_toolbox/Annotations/QtPolygonAnnotation.py +57 -12
  2. coralnet_toolbox/Annotations/QtRectangleAnnotation.py +44 -14
  3. coralnet_toolbox/Explorer/QtDataItem.py +52 -22
  4. coralnet_toolbox/Explorer/QtExplorer.py +277 -1600
  5. coralnet_toolbox/Explorer/QtSettingsWidgets.py +101 -15
  6. coralnet_toolbox/Explorer/QtViewers.py +1568 -0
  7. coralnet_toolbox/Explorer/transformer_models.py +70 -0
  8. coralnet_toolbox/Explorer/yolo_models.py +112 -0
  9. coralnet_toolbox/IO/QtExportMaskAnnotations.py +538 -403
  10. coralnet_toolbox/Icons/system_monitor.png +0 -0
  11. coralnet_toolbox/MachineLearning/ImportDataset/QtBase.py +239 -147
  12. coralnet_toolbox/MachineLearning/VideoInference/YOLO3D/run.py +102 -16
  13. coralnet_toolbox/QtAnnotationWindow.py +16 -10
  14. coralnet_toolbox/QtEventFilter.py +4 -4
  15. coralnet_toolbox/QtImageWindow.py +3 -7
  16. coralnet_toolbox/QtMainWindow.py +104 -64
  17. coralnet_toolbox/QtProgressBar.py +1 -0
  18. coralnet_toolbox/QtSystemMonitor.py +370 -0
  19. coralnet_toolbox/Rasters/RasterTableModel.py +20 -0
  20. coralnet_toolbox/Results/ConvertResults.py +14 -8
  21. coralnet_toolbox/Results/ResultsProcessor.py +3 -2
  22. coralnet_toolbox/SAM/QtDeployGenerator.py +2 -5
  23. coralnet_toolbox/SAM/QtDeployPredictor.py +11 -3
  24. coralnet_toolbox/SeeAnything/QtDeployGenerator.py +146 -116
  25. coralnet_toolbox/SeeAnything/QtDeployPredictor.py +55 -9
  26. coralnet_toolbox/Tile/QtTileBatchInference.py +4 -4
  27. coralnet_toolbox/Tools/QtPolygonTool.py +42 -3
  28. coralnet_toolbox/Tools/QtRectangleTool.py +30 -0
  29. coralnet_toolbox/Tools/QtSAMTool.py +140 -91
  30. coralnet_toolbox/Transformers/Models/GroundingDINO.py +72 -0
  31. coralnet_toolbox/Transformers/Models/OWLViT.py +72 -0
  32. coralnet_toolbox/Transformers/Models/OmDetTurbo.py +68 -0
  33. coralnet_toolbox/Transformers/Models/QtBase.py +120 -0
  34. coralnet_toolbox/{AutoDistill → Transformers}/Models/__init__.py +1 -1
  35. coralnet_toolbox/{AutoDistill → Transformers}/QtBatchInference.py +15 -15
  36. coralnet_toolbox/{AutoDistill → Transformers}/QtDeployModel.py +18 -16
  37. coralnet_toolbox/{AutoDistill → Transformers}/__init__.py +1 -1
  38. coralnet_toolbox/__init__.py +1 -1
  39. coralnet_toolbox/utilities.py +21 -15
  40. {coralnet_toolbox-0.0.74.dist-info → coralnet_toolbox-0.0.76.dist-info}/METADATA +13 -10
  41. {coralnet_toolbox-0.0.74.dist-info → coralnet_toolbox-0.0.76.dist-info}/RECORD +45 -40
  42. coralnet_toolbox/AutoDistill/Models/GroundingDINO.py +0 -81
  43. coralnet_toolbox/AutoDistill/Models/OWLViT.py +0 -76
  44. coralnet_toolbox/AutoDistill/Models/OmDetTurbo.py +0 -75
  45. coralnet_toolbox/AutoDistill/Models/QtBase.py +0 -112
  46. {coralnet_toolbox-0.0.74.dist-info → coralnet_toolbox-0.0.76.dist-info}/WHEEL +0 -0
  47. {coralnet_toolbox-0.0.74.dist-info → coralnet_toolbox-0.0.76.dist-info}/entry_points.txt +0 -0
  48. {coralnet_toolbox-0.0.74.dist-info → coralnet_toolbox-0.0.76.dist-info}/licenses/LICENSE.txt +0 -0
  49. {coralnet_toolbox-0.0.74.dist-info → coralnet_toolbox-0.0.76.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1568 @@
1
+ import warnings
2
+
3
+ import os
4
+
5
+ from PyQt5.QtGui import QPen, QColor, QPainter, QBrush, QPainterPath, QMouseEvent
6
+ from PyQt5.QtCore import Qt, QTimer, QRect, QRectF, QPointF, pyqtSignal, QSignalBlocker, pyqtSlot, QEvent
7
+ from PyQt5.QtWidgets import (QVBoxLayout, QHBoxLayout, QGraphicsView, QScrollArea,
8
+ QGraphicsScene, QPushButton, QComboBox, QLabel, QWidget,
9
+ QSlider, QMessageBox, QGraphicsRectItem, QRubberBand, QMenu,
10
+ QWidgetAction, QToolButton, QAction)
11
+
12
+ from coralnet_toolbox.Explorer.QtDataItem import EmbeddingPointItem
13
+ from coralnet_toolbox.Explorer.QtDataItem import AnnotationImageWidget
14
+ from coralnet_toolbox.Explorer.QtSettingsWidgets import SimilaritySettingsWidget
15
+ from coralnet_toolbox.Explorer.QtSettingsWidgets import UncertaintySettingsWidget
16
+ from coralnet_toolbox.Explorer.QtSettingsWidgets import MislabelSettingsWidget
17
+ from coralnet_toolbox.Explorer.QtSettingsWidgets import DuplicateSettingsWidget
18
+
19
+ from coralnet_toolbox.Annotations.QtRectangleAnnotation import RectangleAnnotation
20
+
21
+ from coralnet_toolbox.Icons import get_icon
22
+
23
+ warnings.filterwarnings("ignore", category=DeprecationWarning)
24
+
25
+
26
+ # ----------------------------------------------------------------------------------------------------------------------
27
+ # Constants
28
+ # ----------------------------------------------------------------------------------------------------------------------
29
+
30
+ POINT_WIDTH = 3
31
+
32
+ # ----------------------------------------------------------------------------------------------------------------------
33
+ # Viewers
34
+ # ----------------------------------------------------------------------------------------------------------------------
35
+
36
+
37
+ class EmbeddingViewer(QWidget):
38
+ """Custom QGraphicsView for interactive embedding visualization with an isolate mode."""
39
+ selection_changed = pyqtSignal(list)
40
+ reset_view_requested = pyqtSignal()
41
+ find_mislabels_requested = pyqtSignal()
42
+ mislabel_parameters_changed = pyqtSignal(dict)
43
+ find_uncertain_requested = pyqtSignal()
44
+ uncertainty_parameters_changed = pyqtSignal(dict)
45
+ find_duplicates_requested = pyqtSignal()
46
+ duplicate_parameters_changed = pyqtSignal(dict)
47
+
48
+ def __init__(self, parent=None):
49
+ """Initialize the EmbeddingViewer widget."""
50
+ super(EmbeddingViewer, self).__init__(parent)
51
+ self.explorer_window = parent
52
+
53
+ self.graphics_scene = QGraphicsScene()
54
+ self.graphics_scene.setSceneRect(-5000, -5000, 10000, 10000)
55
+
56
+ self.graphics_view = QGraphicsView(self.graphics_scene)
57
+ self.graphics_view.setRenderHint(QPainter.Antialiasing)
58
+ self.graphics_view.setDragMode(QGraphicsView.ScrollHandDrag)
59
+ self.graphics_view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
60
+ self.graphics_view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
61
+ self.graphics_view.setMinimumHeight(200)
62
+
63
+ self.rubber_band = None
64
+ self.rubber_band_origin = QPointF()
65
+ self.selection_at_press = None
66
+ self.points_by_id = {}
67
+ self.previous_selection_ids = set()
68
+
69
+ # State for isolate mode
70
+ self.isolated_mode = False
71
+ self.isolated_points = set()
72
+
73
+ self.is_uncertainty_analysis_available = False
74
+
75
+ self.animation_offset = 0
76
+ self.animation_timer = QTimer()
77
+ self.animation_timer.timeout.connect(self.animate_selection)
78
+ self.animation_timer.setInterval(100)
79
+
80
+ # New timer for virtualization
81
+ self.view_update_timer = QTimer(self)
82
+ self.view_update_timer.setSingleShot(True)
83
+ self.view_update_timer.timeout.connect(self._update_visible_points)
84
+
85
+ self.graphics_scene.selectionChanged.connect(self.on_selection_changed)
86
+ self.setup_ui()
87
+ self.graphics_view.mousePressEvent = self.mousePressEvent
88
+ self.graphics_view.mouseDoubleClickEvent = self.mouseDoubleClickEvent
89
+ self.graphics_view.mouseReleaseEvent = self.mouseReleaseEvent
90
+ self.graphics_view.mouseMoveEvent = self.mouseMoveEvent
91
+ self.graphics_view.wheelEvent = self.wheelEvent
92
+
93
+ def setup_ui(self):
94
+ """Set up the UI with toolbar layout and graphics view."""
95
+ layout = QVBoxLayout(self)
96
+ layout.setContentsMargins(0, 0, 0, 0)
97
+
98
+ toolbar_layout = QHBoxLayout()
99
+
100
+ # Isolate/Show All buttons
101
+ self.isolate_button = QPushButton("Isolate Selection")
102
+ self.isolate_button.setToolTip("Hide all non-selected points")
103
+ self.isolate_button.clicked.connect(self.isolate_selection)
104
+ toolbar_layout.addWidget(self.isolate_button)
105
+
106
+ self.show_all_button = QPushButton("Show All")
107
+ self.show_all_button.setToolTip("Show all embedding points")
108
+ self.show_all_button.clicked.connect(self.show_all_points)
109
+ toolbar_layout.addWidget(self.show_all_button)
110
+
111
+ toolbar_layout.addWidget(self._create_separator())
112
+
113
+ # Create a QToolButton to have both a primary action and a dropdown menu
114
+ self.find_mislabels_button = QToolButton()
115
+ self.find_mislabels_button.setText("Find Potential Mislabels")
116
+ self.find_mislabels_button.setPopupMode(QToolButton.MenuButtonPopup) # Key change for split-button style
117
+ self.find_mislabels_button.setToolButtonStyle(Qt.ToolButtonTextOnly)
118
+ self.find_mislabels_button.setStyleSheet(
119
+ "QToolButton::menu-indicator {"
120
+ " subcontrol-position: right center;"
121
+ " subcontrol-origin: padding;"
122
+ " left: -4px;"
123
+ " }"
124
+ )
125
+
126
+ # The primary action (clicking the button) triggers the analysis
127
+ run_analysis_action = QAction("Find Potential Mislabels", self)
128
+ run_analysis_action.triggered.connect(self.find_mislabels_requested.emit)
129
+ self.find_mislabels_button.setDefaultAction(run_analysis_action)
130
+
131
+ # The dropdown menu contains the settings
132
+ mislabel_settings_widget = MislabelSettingsWidget()
133
+ settings_menu = QMenu(self)
134
+ widget_action = QWidgetAction(settings_menu)
135
+ widget_action.setDefaultWidget(mislabel_settings_widget)
136
+ settings_menu.addAction(widget_action)
137
+ self.find_mislabels_button.setMenu(settings_menu)
138
+
139
+ # Connect the widget's signal to the viewer's signal
140
+ mislabel_settings_widget.parameters_changed.connect(self.mislabel_parameters_changed.emit)
141
+ toolbar_layout.addWidget(self.find_mislabels_button)
142
+
143
+ # Create a QToolButton for uncertainty analysis
144
+ self.find_uncertain_button = QToolButton()
145
+ self.find_uncertain_button.setText("Review Uncertain")
146
+ self.find_uncertain_button.setToolTip(
147
+ "Find annotations where the model is least confident.\n"
148
+ "Requires a .pt classification model and 'Predictions' mode."
149
+ )
150
+ self.find_uncertain_button.setPopupMode(QToolButton.MenuButtonPopup)
151
+ self.find_uncertain_button.setToolButtonStyle(Qt.ToolButtonTextOnly)
152
+ self.find_uncertain_button.setStyleSheet(
153
+ "QToolButton::menu-indicator { "
154
+ "subcontrol-position: right center; "
155
+ "subcontrol-origin: padding; "
156
+ "left: -4px; }"
157
+ )
158
+
159
+ run_uncertainty_action = QAction("Review Uncertain", self)
160
+ run_uncertainty_action.triggered.connect(self.find_uncertain_requested.emit)
161
+ self.find_uncertain_button.setDefaultAction(run_uncertainty_action)
162
+
163
+ uncertainty_settings_widget = UncertaintySettingsWidget()
164
+ uncertainty_menu = QMenu(self)
165
+ uncertainty_widget_action = QWidgetAction(uncertainty_menu)
166
+ uncertainty_widget_action.setDefaultWidget(uncertainty_settings_widget)
167
+ uncertainty_menu.addAction(uncertainty_widget_action)
168
+ self.find_uncertain_button.setMenu(uncertainty_menu)
169
+
170
+ uncertainty_settings_widget.parameters_changed.connect(self.uncertainty_parameters_changed.emit)
171
+ toolbar_layout.addWidget(self.find_uncertain_button)
172
+
173
+ # Create a QToolButton for duplicate detection
174
+ self.find_duplicates_button = QToolButton()
175
+ self.find_duplicates_button.setText("Find Duplicates")
176
+ self.find_duplicates_button.setToolTip(
177
+ "Find annotations that are likely duplicates based on feature similarity."
178
+ )
179
+ self.find_duplicates_button.setPopupMode(QToolButton.MenuButtonPopup)
180
+ self.find_duplicates_button.setToolButtonStyle(Qt.ToolButtonTextOnly)
181
+ self.find_duplicates_button.setStyleSheet(
182
+ "QToolButton::menu-indicator { "
183
+ "subcontrol-position: right center; "
184
+ "subcontrol-origin: padding; "
185
+ "left: -4px; }"
186
+ )
187
+
188
+ run_duplicates_action = QAction("Find Duplicates", self)
189
+ run_duplicates_action.triggered.connect(self.find_duplicates_requested.emit)
190
+ self.find_duplicates_button.setDefaultAction(run_duplicates_action)
191
+
192
+ duplicate_settings_widget = DuplicateSettingsWidget()
193
+ duplicate_menu = QMenu(self)
194
+ duplicate_widget_action = QWidgetAction(duplicate_menu)
195
+ duplicate_widget_action.setDefaultWidget(duplicate_settings_widget)
196
+ duplicate_menu.addAction(duplicate_widget_action)
197
+ self.find_duplicates_button.setMenu(duplicate_menu)
198
+
199
+ duplicate_settings_widget.parameters_changed.connect(self.duplicate_parameters_changed.emit)
200
+ toolbar_layout.addWidget(self.find_duplicates_button)
201
+
202
+ # Add a stretch and separator
203
+ toolbar_layout.addStretch()
204
+ toolbar_layout.addWidget(self._create_separator())
205
+
206
+ # Center on selection button
207
+ self.center_on_selection_button = QPushButton()
208
+ self.center_on_selection_button.setIcon(get_icon("target.png"))
209
+ self.center_on_selection_button.setToolTip("Center view on selected point(s)")
210
+ self.center_on_selection_button.clicked.connect(self.center_on_selection)
211
+ toolbar_layout.addWidget(self.center_on_selection_button)
212
+
213
+ # Home button to reset view
214
+ self.home_button = QPushButton()
215
+ self.home_button.setIcon(get_icon("home.png"))
216
+ self.home_button.setToolTip("Reset view to fit all points")
217
+ self.home_button.clicked.connect(self.reset_view)
218
+ toolbar_layout.addWidget(self.home_button)
219
+
220
+ layout.addLayout(toolbar_layout)
221
+ layout.addWidget(self.graphics_view)
222
+
223
+ self.placeholder_label = QLabel(
224
+ "No embedding data available.\nPress 'Apply Embedding' to generate visualization."
225
+ )
226
+ self.placeholder_label.setAlignment(Qt.AlignCenter)
227
+ self.placeholder_label.setStyleSheet("color: gray; font-size: 14px;")
228
+ layout.addWidget(self.placeholder_label)
229
+
230
+ self.show_placeholder()
231
+ self._update_toolbar_state()
232
+
233
+ def _create_separator(self):
234
+ """Creates a vertical separator for the toolbar."""
235
+ separator = QLabel("|")
236
+ separator.setStyleSheet("color: gray; margin: 0 5px;")
237
+ return separator
238
+
239
+ def _schedule_view_update(self):
240
+ """Schedules a delayed update of visible points to avoid performance issues."""
241
+ self.view_update_timer.start(50) # 50ms delay
242
+
243
+ def _update_visible_points(self):
244
+ """Sets visibility for points based on whether they are in the viewport."""
245
+ if self.isolated_mode or not self.points_by_id:
246
+ return
247
+
248
+ # Get the visible rectangle in scene coordinates
249
+ visible_rect = self.graphics_view.mapToScene(self.graphics_view.viewport().rect()).boundingRect()
250
+
251
+ # Add a buffer to make scrolling smoother by loading points before they enter the view
252
+ buffer_x = visible_rect.width() * 0.2
253
+ buffer_y = visible_rect.height() * 0.2
254
+ buffered_visible_rect = visible_rect.adjusted(-buffer_x, -buffer_y, buffer_x, buffer_y)
255
+
256
+ for point in self.points_by_id.values():
257
+ point.setVisible(buffered_visible_rect.contains(point.pos()) or point.isSelected())
258
+
259
+ @pyqtSlot()
260
+ def isolate_selection(self):
261
+ """Hides all points that are not currently selected."""
262
+ selected_items = self.graphics_scene.selectedItems()
263
+ if not selected_items or self.isolated_mode:
264
+ return
265
+
266
+ self.isolated_points = set(selected_items)
267
+ self.graphics_view.setUpdatesEnabled(False)
268
+ try:
269
+ for point in self.points_by_id.values():
270
+ point.setVisible(point in self.isolated_points)
271
+ self.isolated_mode = True
272
+ finally:
273
+ self.graphics_view.setUpdatesEnabled(True)
274
+
275
+ self._update_toolbar_state()
276
+
277
+ @pyqtSlot()
278
+ def show_all_points(self):
279
+ """Shows all embedding points, exiting isolated mode."""
280
+ if not self.isolated_mode:
281
+ return
282
+
283
+ self.isolated_mode = False
284
+ self.isolated_points.clear()
285
+ self.graphics_view.setUpdatesEnabled(False)
286
+ try:
287
+ # Instead of showing all, let the virtualization logic take over
288
+ self._update_visible_points()
289
+ finally:
290
+ self.graphics_view.setUpdatesEnabled(True)
291
+
292
+ self._update_toolbar_state()
293
+
294
+ def _update_toolbar_state(self):
295
+ """Updates toolbar buttons based on selection and isolation mode."""
296
+ selection_exists = bool(self.graphics_scene.selectedItems())
297
+ points_exist = bool(self.points_by_id)
298
+
299
+ self.find_mislabels_button.setEnabled(points_exist)
300
+ self.find_uncertain_button.setEnabled(points_exist and self.is_uncertainty_analysis_available)
301
+ self.find_duplicates_button.setEnabled(points_exist)
302
+ self.center_on_selection_button.setEnabled(points_exist and selection_exists)
303
+
304
+ if self.isolated_mode:
305
+ self.isolate_button.hide()
306
+ self.show_all_button.show()
307
+ else:
308
+ self.isolate_button.show()
309
+ self.show_all_button.hide()
310
+ self.isolate_button.setEnabled(selection_exists)
311
+
312
+ def reset_view(self):
313
+ """Reset the view to fit all embedding points."""
314
+ self.fit_view_to_points()
315
+
316
+ def center_on_selection(self):
317
+ """Centers the view on selected point(s) or maintains the current view if no points are selected."""
318
+ selected_items = self.graphics_scene.selectedItems()
319
+ if not selected_items:
320
+ # No selection, show a message
321
+ QMessageBox.information(self, "No Selection", "Please select one or more points first.")
322
+ return
323
+
324
+ # Create a bounding rect that encompasses all selected points
325
+ selection_rect = None
326
+
327
+ for item in selected_items:
328
+ if isinstance(item, EmbeddingPointItem):
329
+ # Get the item's bounding rect in scene coordinates
330
+ item_rect = item.sceneBoundingRect()
331
+
332
+ # Add padding around the point for better visibility
333
+ padding = 50 # pixels
334
+ item_rect = item_rect.adjusted(-padding, -padding, padding, padding)
335
+
336
+ if selection_rect is None:
337
+ selection_rect = item_rect
338
+ else:
339
+ selection_rect = selection_rect.united(item_rect)
340
+
341
+ if selection_rect:
342
+ # Add extra margin for better visibility
343
+ margin = 20
344
+ selection_rect = selection_rect.adjusted(-margin, -margin, margin, margin)
345
+
346
+ # Fit the view to the selection rect
347
+ self.graphics_view.fitInView(selection_rect, Qt.KeepAspectRatio)
348
+
349
+ def show_placeholder(self):
350
+ """Show the placeholder message and hide the graphics view."""
351
+ self.graphics_view.setVisible(False)
352
+ self.placeholder_label.setVisible(True)
353
+ self.home_button.setEnabled(False)
354
+ self.center_on_selection_button.setEnabled(False) # Disable center button
355
+ self.find_mislabels_button.setEnabled(False)
356
+ self.find_uncertain_button.setEnabled(False)
357
+ self.find_duplicates_button.setEnabled(False)
358
+
359
+ self.isolate_button.show()
360
+ self.isolate_button.setEnabled(False)
361
+ self.show_all_button.hide()
362
+
363
+ def show_embedding(self):
364
+ """Show the graphics view and hide the placeholder message."""
365
+ self.graphics_view.setVisible(True)
366
+ self.placeholder_label.setVisible(False)
367
+ self.home_button.setEnabled(True)
368
+ self._update_toolbar_state()
369
+
370
+ # Delegate graphics view methods
371
+ def setRenderHint(self, hint):
372
+ """Set render hint for the graphics view."""
373
+ self.graphics_view.setRenderHint(hint)
374
+
375
+ def setDragMode(self, mode):
376
+ """Set drag mode for the graphics view."""
377
+ self.graphics_view.setDragMode(mode)
378
+
379
+ def setTransformationAnchor(self, anchor):
380
+ """Set transformation anchor for the graphics view."""
381
+ self.graphics_view.setTransformationAnchor(anchor)
382
+
383
+ def setResizeAnchor(self, anchor):
384
+ """Set resize anchor for the graphics view."""
385
+ self.graphics_view.setResizeAnchor(anchor)
386
+
387
+ def mapToScene(self, point):
388
+ """Map a point to the scene coordinates."""
389
+ return self.graphics_view.mapToScene(point)
390
+
391
+ def scale(self, sx, sy):
392
+ """Scale the graphics view."""
393
+ self.graphics_view.scale(sx, sy)
394
+
395
+ def translate(self, dx, dy):
396
+ """Translate the graphics view."""
397
+ self.graphics_view.translate(dx, dy)
398
+
399
+ def fitInView(self, rect, aspect_ratio):
400
+ """Fit the view to a rectangle with aspect ratio."""
401
+ self.graphics_view.fitInView(rect, aspect_ratio)
402
+
403
+ def keyPressEvent(self, event):
404
+ """Handles key presses for deleting selected points."""
405
+ if event.key() in (Qt.Key_Delete, Qt.Key_Backspace) and event.modifiers() == Qt.ControlModifier:
406
+ selected_items = self.graphics_scene.selectedItems()
407
+ if not selected_items:
408
+ super().keyPressEvent(event)
409
+ return
410
+
411
+ # Extract the central data items from the selected graphics points
412
+ data_items_to_delete = [
413
+ item.data_item for item in selected_items if isinstance(item, EmbeddingPointItem)
414
+ ]
415
+
416
+ # Delegate the actual deletion to the main ExplorerWindow
417
+ if data_items_to_delete:
418
+ self.explorer_window.delete_data_items(data_items_to_delete)
419
+
420
+ event.accept()
421
+ else:
422
+ super().keyPressEvent(event)
423
+
424
+ def mousePressEvent(self, event):
425
+ """Handle mouse press for selection (point or rubber band) and panning."""
426
+ # Ctrl+Right-Click for context menu selection
427
+ if event.button() == Qt.RightButton and event.modifiers() == Qt.ControlModifier:
428
+ item_at_pos = self.graphics_view.itemAt(event.pos())
429
+ if isinstance(item_at_pos, EmbeddingPointItem):
430
+ # 1. Clear all selections in both viewers
431
+ self.graphics_scene.clearSelection()
432
+ item_at_pos.setSelected(True)
433
+ self.on_selection_changed() # Updates internal state and emits signals
434
+
435
+ # 2. Sync annotation viewer selection
436
+ ann_id = item_at_pos.data_item.annotation.id
437
+ self.explorer_window.annotation_viewer.render_selection_from_ids({ann_id})
438
+
439
+ # 3. Update annotation window (set image, select, center)
440
+ explorer = self.explorer_window
441
+ annotation = item_at_pos.data_item.annotation
442
+ image_path = annotation.image_path
443
+
444
+ if hasattr(explorer, 'annotation_window'):
445
+ if explorer.annotation_window.current_image_path != image_path:
446
+ if hasattr(explorer.annotation_window, 'set_image'):
447
+ explorer.annotation_window.set_image(image_path)
448
+ if hasattr(explorer.annotation_window, 'select_annotation'):
449
+ explorer.annotation_window.select_annotation(annotation)
450
+ if hasattr(explorer.annotation_window, 'center_on_annotation'):
451
+ explorer.annotation_window.center_on_annotation(annotation)
452
+
453
+ explorer.update_label_window_selection()
454
+ explorer.update_button_states()
455
+ event.accept()
456
+ return
457
+
458
+ # Handle left-click for selection or rubber band
459
+ if event.button() == Qt.LeftButton and event.modifiers() == Qt.ControlModifier:
460
+ item_at_pos = self.graphics_view.itemAt(event.pos())
461
+ if isinstance(item_at_pos, EmbeddingPointItem):
462
+ self.graphics_view.setDragMode(QGraphicsView.NoDrag)
463
+ # The viewer (controller) directly changes the state on the data item.
464
+ is_currently_selected = item_at_pos.data_item.is_selected
465
+ item_at_pos.data_item.set_selected(not is_currently_selected)
466
+ item_at_pos.setSelected(not is_currently_selected) # Keep scene selection in sync
467
+ self.on_selection_changed() # Manually trigger update
468
+ return
469
+
470
+ self.selection_at_press = set(self.graphics_scene.selectedItems())
471
+ self.graphics_view.setDragMode(QGraphicsView.NoDrag)
472
+ self.rubber_band_origin = self.graphics_view.mapToScene(event.pos())
473
+ self.rubber_band = QGraphicsRectItem(QRectF(self.rubber_band_origin, self.rubber_band_origin))
474
+ self.rubber_band.setPen(QPen(QColor(0, 100, 255), 1, Qt.DotLine))
475
+ self.rubber_band.setBrush(QBrush(QColor(0, 100, 255, 50)))
476
+ self.graphics_scene.addItem(self.rubber_band)
477
+
478
+ elif event.button() == Qt.RightButton:
479
+ self.graphics_view.setDragMode(QGraphicsView.ScrollHandDrag)
480
+ left_event = QMouseEvent(event.type(), event.localPos(), Qt.LeftButton, Qt.LeftButton, event.modifiers())
481
+ QGraphicsView.mousePressEvent(self.graphics_view, left_event)
482
+ else:
483
+ self.graphics_view.setDragMode(QGraphicsView.NoDrag)
484
+ QGraphicsView.mousePressEvent(self.graphics_view, event)
485
+
486
+ def mouseDoubleClickEvent(self, event):
487
+ """Handle double-click to clear selection and reset the main view."""
488
+ if event.button() == Qt.LeftButton:
489
+ if self.graphics_scene.selectedItems():
490
+ self.graphics_scene.clearSelection()
491
+ self.reset_view_requested.emit()
492
+ event.accept()
493
+ else:
494
+ super().mouseDoubleClickEvent(event)
495
+
496
+ def mouseMoveEvent(self, event):
497
+ """Handle mouse move for dynamic selection and panning."""
498
+ if self.rubber_band:
499
+ # Update the rubber band rectangle as the mouse moves
500
+ current_pos = self.graphics_view.mapToScene(event.pos())
501
+ self.rubber_band.setRect(QRectF(self.rubber_band_origin, current_pos).normalized())
502
+ # Create a selection path from the rubber band rectangle
503
+ path = QPainterPath()
504
+ path.addRect(self.rubber_band.rect())
505
+ # Block signals to avoid recursive selectionChanged events
506
+ self.graphics_scene.blockSignals(True)
507
+ self.graphics_scene.setSelectionArea(path)
508
+ # Restore selection for items that were already selected at press
509
+ if self.selection_at_press:
510
+ for item in self.selection_at_press:
511
+ item.setSelected(True)
512
+ self.graphics_scene.blockSignals(False)
513
+ # Manually trigger selection changed logic
514
+ self.on_selection_changed()
515
+ elif event.buttons() == Qt.RightButton:
516
+ # Forward right-drag as left-drag for panning
517
+ left_event = QMouseEvent(event.type(), event.localPos(), Qt.LeftButton, Qt.LeftButton, event.modifiers())
518
+ QGraphicsView.mouseMoveEvent(self.graphics_view, left_event)
519
+ self._schedule_view_update()
520
+ else:
521
+ # Default mouse move handling
522
+ QGraphicsView.mouseMoveEvent(self.graphics_view, event)
523
+
524
+ def mouseReleaseEvent(self, event):
525
+ """Handle mouse release to finalize the action and clean up."""
526
+ if self.rubber_band:
527
+ self.graphics_scene.removeItem(self.rubber_band)
528
+ self.rubber_band = None
529
+ self.selection_at_press = None
530
+ elif event.button() == Qt.RightButton:
531
+ left_event = QMouseEvent(event.type(), event.localPos(), Qt.LeftButton, Qt.LeftButton, event.modifiers())
532
+ QGraphicsView.mouseReleaseEvent(self.graphics_view, left_event)
533
+ self._schedule_view_update()
534
+ self.graphics_view.setDragMode(QGraphicsView.NoDrag)
535
+ else:
536
+ QGraphicsView.mouseReleaseEvent(self.graphics_view, event)
537
+ self.graphics_view.setDragMode(QGraphicsView.NoDrag)
538
+
539
+ def wheelEvent(self, event):
540
+ """Handle mouse wheel for zooming."""
541
+ zoom_in_factor = 1.25
542
+ zoom_out_factor = 1 / zoom_in_factor
543
+
544
+ # Set anchor points so zoom occurs at mouse position
545
+ self.graphics_view.setTransformationAnchor(QGraphicsView.NoAnchor)
546
+ self.graphics_view.setResizeAnchor(QGraphicsView.NoAnchor)
547
+
548
+ # Get the scene position before zooming
549
+ old_pos = self.graphics_view.mapToScene(event.pos())
550
+
551
+ # Determine zoom direction
552
+ zoom_factor = zoom_in_factor if event.angleDelta().y() > 0 else zoom_out_factor
553
+
554
+ # Apply zoom
555
+ self.graphics_view.scale(zoom_factor, zoom_factor)
556
+
557
+ # Get the scene position after zooming
558
+ new_pos = self.graphics_view.mapToScene(event.pos())
559
+
560
+ # Translate view to keep mouse position stable
561
+ delta = new_pos - old_pos
562
+ self.graphics_view.translate(delta.x(), delta.y())
563
+ self._schedule_view_update()
564
+
565
+ def update_embeddings(self, data_items):
566
+ """Update the embedding visualization. Creates an EmbeddingPointItem for
567
+ each AnnotationDataItem and links them."""
568
+ # Reset isolation state when loading new points
569
+ if self.isolated_mode:
570
+ self.show_all_points()
571
+
572
+ self.clear_points()
573
+ for item in data_items:
574
+ point = EmbeddingPointItem(item)
575
+ self.graphics_scene.addItem(point)
576
+ self.points_by_id[item.annotation.id] = point
577
+
578
+ # Ensure buttons are in the correct initial state
579
+ self._update_toolbar_state()
580
+ # Set initial visibility
581
+ self._update_visible_points()
582
+
583
+ def clear_points(self):
584
+ """Clear all embedding points from the scene."""
585
+ if self.isolated_mode:
586
+ self.show_all_points()
587
+
588
+ for point in self.points_by_id.values():
589
+ self.graphics_scene.removeItem(point)
590
+ self.points_by_id.clear()
591
+ self._update_toolbar_state()
592
+
593
+ def on_selection_changed(self):
594
+ """
595
+ Handles selection changes in the scene. Updates the central data model
596
+ and emits a signal to notify other parts of the application.
597
+ """
598
+ if not self.graphics_scene:
599
+ return
600
+ try:
601
+ selected_items = self.graphics_scene.selectedItems()
602
+ except RuntimeError:
603
+ return
604
+
605
+ current_selection_ids = {item.data_item.annotation.id for item in selected_items}
606
+
607
+ if current_selection_ids != self.previous_selection_ids:
608
+ for point_id, point in self.points_by_id.items():
609
+ is_selected = point_id in current_selection_ids
610
+ point.data_item.set_selected(is_selected)
611
+
612
+ self.selection_changed.emit(list(current_selection_ids))
613
+ self.previous_selection_ids = current_selection_ids
614
+
615
+ if hasattr(self, 'animation_timer') and self.animation_timer:
616
+ self.animation_timer.stop()
617
+
618
+ for point in self.points_by_id.values():
619
+ if not point.isSelected():
620
+ point.setPen(QPen(QColor("black"), POINT_WIDTH))
621
+ if selected_items and hasattr(self, 'animation_timer') and self.animation_timer:
622
+ self.animation_timer.start()
623
+
624
+ # Update button states based on new selection
625
+ self._update_toolbar_state()
626
+
627
+ # A selection change can affect visibility (e.g., deselecting an off-screen point)
628
+ self._schedule_view_update()
629
+
630
+ def animate_selection(self):
631
+ """Animate selected points with a marching ants effect."""
632
+ if not self.graphics_scene:
633
+ return
634
+ try:
635
+ selected_items = self.graphics_scene.selectedItems()
636
+ except RuntimeError:
637
+ return
638
+
639
+ self.animation_offset = (self.animation_offset + 1) % 20
640
+ for item in selected_items:
641
+ # Get the color directly from the source of truth
642
+ original_color = item.data_item.effective_color
643
+ darker_color = original_color.darker(150)
644
+ animated_pen = QPen(darker_color, POINT_WIDTH)
645
+ animated_pen.setStyle(Qt.CustomDashLine)
646
+ animated_pen.setDashPattern([1, 2])
647
+ animated_pen.setDashOffset(self.animation_offset)
648
+ item.setPen(animated_pen)
649
+
650
+ def render_selection_from_ids(self, selected_ids):
651
+ """
652
+ Updates the visual selection of points based on a set of annotation IDs
653
+ provided by an external controller.
654
+ """
655
+ blocker = QSignalBlocker(self.graphics_scene)
656
+
657
+ for ann_id, point in self.points_by_id.items():
658
+ is_selected = ann_id in selected_ids
659
+ # 1. Update the state on the central data item
660
+ point.data_item.set_selected(is_selected)
661
+ # 2. Update the selection state of the graphics item itself
662
+ point.setSelected(is_selected)
663
+
664
+ blocker.unblock()
665
+
666
+ # Manually trigger on_selection_changed to update animation and emit signals
667
+ self.on_selection_changed()
668
+
669
+ # After selection, update visibility to ensure newly selected points are shown
670
+ self._update_visible_points()
671
+
672
+ def fit_view_to_points(self):
673
+ """Fit the view to show all embedding points."""
674
+ if self.points_by_id:
675
+ self.graphics_view.fitInView(self.graphics_scene.itemsBoundingRect(), Qt.KeepAspectRatio)
676
+ else:
677
+ self.graphics_view.fitInView(-2500, -2500, 5000, 5000, Qt.KeepAspectRatio)
678
+
679
+
680
+ class AnnotationViewer(QWidget):
681
+ """
682
+ Widget containing a toolbar and a scrollable grid for displaying annotation image crops.
683
+ Implements virtualization to only render visible widgets.
684
+ """
685
+ selection_changed = pyqtSignal(list)
686
+ preview_changed = pyqtSignal(list)
687
+ reset_view_requested = pyqtSignal()
688
+ find_similar_requested = pyqtSignal()
689
+
690
+ def __init__(self, parent=None):
691
+ """Initialize the AnnotationViewer widget."""
692
+ super(AnnotationViewer, self).__init__(parent)
693
+ self.explorer_window = parent
694
+
695
+ self.annotation_widgets_by_id = {}
696
+ self.selected_widgets = []
697
+ self.last_selected_item_id = None # Use a persistent ID for the selection anchor
698
+ self.current_widget_size = 96
699
+ self.selection_at_press = set()
700
+ self.rubber_band = None
701
+ self.rubber_band_origin = None
702
+ self.drag_threshold = 5
703
+ self.mouse_pressed_on_widget = False
704
+ self.preview_label_assignments = {}
705
+ self.original_label_assignments = {}
706
+ self.isolated_mode = False
707
+ self.isolated_widgets = set()
708
+
709
+ # State for sorting options
710
+ self.active_ordered_ids = []
711
+ self.is_confidence_sort_available = False
712
+
713
+ # New attributes for virtualization
714
+ self.all_data_items = []
715
+ self.widget_positions = {} # ann_id -> QRect
716
+ self.update_timer = QTimer(self)
717
+ self.update_timer.setSingleShot(True)
718
+ self.update_timer.timeout.connect(self._update_visible_widgets)
719
+
720
+ self.setup_ui()
721
+
722
+ # Connect scrollbar value changed to schedule an update for virtualization
723
+ self.scroll_area.verticalScrollBar().valueChanged.connect(self._schedule_update)
724
+ # Install an event filter on the viewport to handle mouse events for rubber band selection
725
+ self.scroll_area.viewport().installEventFilter(self)
726
+
727
+ def setup_ui(self):
728
+ """Set up the UI with a toolbar and a scrollable content area."""
729
+ # This widget is the main container with its own layout
730
+ main_layout = QVBoxLayout(self)
731
+ main_layout.setContentsMargins(0, 0, 0, 0)
732
+ main_layout.setSpacing(4)
733
+
734
+ # Create and add the toolbar to the main layout
735
+ toolbar_widget = QWidget()
736
+ toolbar_layout = QHBoxLayout(toolbar_widget)
737
+ toolbar_layout.setContentsMargins(4, 2, 4, 2)
738
+
739
+ self.isolate_button = QPushButton("Isolate Selection")
740
+ self.isolate_button.setToolTip("Hide all non-selected annotations")
741
+ self.isolate_button.clicked.connect(self.isolate_selection)
742
+ toolbar_layout.addWidget(self.isolate_button)
743
+
744
+ self.show_all_button = QPushButton("Show All")
745
+ self.show_all_button.setToolTip("Show all filtered annotations")
746
+ self.show_all_button.clicked.connect(self.show_all_annotations)
747
+ toolbar_layout.addWidget(self.show_all_button)
748
+
749
+ toolbar_layout.addWidget(self._create_separator())
750
+
751
+ sort_label = QLabel("Sort By:")
752
+ toolbar_layout.addWidget(sort_label)
753
+ self.sort_combo = QComboBox()
754
+ # Remove "Similarity" as it's now an implicit action
755
+ self.sort_combo.addItems(["None", "Label", "Image", "Confidence"])
756
+ self.sort_combo.insertSeparator(3) # Add separator before "Confidence"
757
+ self.sort_combo.currentTextChanged.connect(self.on_sort_changed)
758
+ toolbar_layout.addWidget(self.sort_combo)
759
+
760
+ toolbar_layout.addWidget(self._create_separator())
761
+
762
+ self.find_similar_button = QToolButton()
763
+ self.find_similar_button.setText("Find Similar")
764
+ self.find_similar_button.setToolTip("Find annotations visually similar to the selection.")
765
+ self.find_similar_button.setPopupMode(QToolButton.MenuButtonPopup)
766
+ self.find_similar_button.setToolButtonStyle(Qt.ToolButtonTextOnly)
767
+ self.find_similar_button.setStyleSheet(
768
+ "QToolButton::menu-indicator { subcontrol-position: right center; subcontrol-origin: padding; left: -4px; }"
769
+ )
770
+
771
+ run_similar_action = QAction("Find Similar", self)
772
+ run_similar_action.triggered.connect(self.find_similar_requested.emit)
773
+ self.find_similar_button.setDefaultAction(run_similar_action)
774
+
775
+ self.similarity_settings_widget = SimilaritySettingsWidget()
776
+ settings_menu = QMenu(self)
777
+ widget_action = QWidgetAction(settings_menu)
778
+ widget_action.setDefaultWidget(self.similarity_settings_widget)
779
+ settings_menu.addAction(widget_action)
780
+ self.find_similar_button.setMenu(settings_menu)
781
+ toolbar_layout.addWidget(self.find_similar_button)
782
+
783
+ toolbar_layout.addStretch()
784
+
785
+ size_label = QLabel("Size:")
786
+ toolbar_layout.addWidget(size_label)
787
+ self.size_slider = QSlider(Qt.Horizontal)
788
+ self.size_slider.setMinimum(32)
789
+ self.size_slider.setMaximum(256)
790
+ self.size_slider.setValue(96)
791
+ self.size_slider.setTickPosition(QSlider.TicksBelow)
792
+ self.size_slider.setTickInterval(32)
793
+ self.size_slider.valueChanged.connect(self.on_size_changed)
794
+ toolbar_layout.addWidget(self.size_slider)
795
+
796
+ self.size_value_label = QLabel("96")
797
+ self.size_value_label.setMinimumWidth(30)
798
+ toolbar_layout.addWidget(self.size_value_label)
799
+ main_layout.addWidget(toolbar_widget)
800
+
801
+ # Create the scroll area which will contain the content
802
+ self.scroll_area = QScrollArea()
803
+ self.scroll_area.setWidgetResizable(True)
804
+ self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
805
+ self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
806
+
807
+ self.content_widget = QWidget()
808
+ self.scroll_area.setWidget(self.content_widget)
809
+ main_layout.addWidget(self.scroll_area)
810
+
811
+ # Set the initial state of the sort options
812
+ self._update_sort_options_state()
813
+ self._update_toolbar_state()
814
+
815
+ def _create_separator(self):
816
+ """Creates a vertical separator for the toolbar."""
817
+ separator = QLabel("|")
818
+ separator.setStyleSheet("color: gray; margin: 0 5px;")
819
+ return separator
820
+
821
+ def _update_sort_options_state(self):
822
+ """Enable/disable sort options based on available data."""
823
+ model = self.sort_combo.model()
824
+
825
+ # Enable/disable "Confidence" option
826
+ confidence_item_index = self.sort_combo.findText("Confidence")
827
+ if confidence_item_index != -1:
828
+ model.item(confidence_item_index).setEnabled(self.is_confidence_sort_available)
829
+
830
+ def handle_annotation_context_menu(self, widget, event):
831
+ """Handle context menu requests (e.g., right-click) on an annotation widget."""
832
+ if event.modifiers() == Qt.ControlModifier:
833
+ explorer = self.explorer_window
834
+ image_path = widget.annotation.image_path
835
+ annotation_to_select = widget.annotation
836
+
837
+ # ctrl+right click to only select this annotation (single selection):
838
+ self.clear_selection()
839
+ self.select_widget(widget)
840
+ changed_ids = [widget.data_item.annotation.id]
841
+
842
+ if changed_ids:
843
+ self.selection_changed.emit(changed_ids)
844
+
845
+ if hasattr(explorer, 'annotation_window'):
846
+ # Check if the image needs to be changed
847
+ if explorer.annotation_window.current_image_path != image_path:
848
+ if hasattr(explorer.annotation_window, 'set_image'):
849
+ explorer.annotation_window.set_image(image_path)
850
+
851
+ # Now, select the annotation in the annotation_window (activates animation)
852
+ if hasattr(explorer.annotation_window, 'select_annotation'):
853
+ explorer.annotation_window.select_annotation(annotation_to_select, quiet_mode=True)
854
+
855
+ # Center the annotation window view on the selected annotation
856
+ if hasattr(explorer.annotation_window, 'center_on_annotation'):
857
+ explorer.annotation_window.center_on_annotation(annotation_to_select)
858
+
859
+ # Show resize handles for Rectangle annotations
860
+ if isinstance(annotation_to_select, RectangleAnnotation):
861
+ explorer.annotation_window.set_selected_tool('select') # Accidentally unselects in AnnotationWindow
862
+ explorer.annotation_window.select_annotation(annotation_to_select, quiet_mode=True)
863
+ select_tool = explorer.annotation_window.tools.get('select')
864
+
865
+ if select_tool:
866
+ # Engage the selection lock.
867
+ select_tool.selection_locked = True
868
+ # Show the resize handles for the now-selected annotation.
869
+ select_tool._show_resize_handles()
870
+
871
+ # Also clear any existing selection in the explorer window itself
872
+ explorer.embedding_viewer.render_selection_from_ids({widget.data_item.annotation.id})
873
+ explorer.update_label_window_selection()
874
+ explorer.update_button_states()
875
+
876
+ event.accept()
877
+
878
+ @pyqtSlot()
879
+ def isolate_selection(self):
880
+ """Hides all annotation widgets that are not currently selected."""
881
+ if not self.selected_widgets:
882
+ return
883
+
884
+ self.isolated_widgets = set(self.selected_widgets)
885
+ self.content_widget.setUpdatesEnabled(False)
886
+ try:
887
+ for widget in self.annotation_widgets_by_id.values():
888
+ if widget not in self.isolated_widgets:
889
+ widget.hide()
890
+ self.isolated_mode = True
891
+ self.recalculate_layout()
892
+ finally:
893
+ self.content_widget.setUpdatesEnabled(True)
894
+
895
+ self._update_toolbar_state()
896
+ self.explorer_window.main_window.label_window.update_annotation_count()
897
+
898
+ def isolate_and_select_from_ids(self, ids_to_isolate):
899
+ """
900
+ Enters isolated mode showing only widgets for the given IDs, and also
901
+ selects them. This is the primary entry point from external viewers.
902
+ The isolated set is 'sticky' and will not change on subsequent internal
903
+ selection changes.
904
+ """
905
+ # Get the widget objects from the IDs
906
+ widgets_to_isolate = {
907
+ self.annotation_widgets_by_id[ann_id]
908
+ for ann_id in ids_to_isolate
909
+ if ann_id in self.annotation_widgets_by_id
910
+ }
911
+
912
+ if not widgets_to_isolate:
913
+ return
914
+
915
+ self.isolated_widgets = widgets_to_isolate
916
+ self.isolated_mode = True
917
+
918
+ self.render_selection_from_ids(ids_to_isolate)
919
+ self.recalculate_layout()
920
+
921
+ def display_and_isolate_ordered_results(self, ordered_ids):
922
+ """
923
+ Isolates the view to a specific set of ordered widgets, ensuring the
924
+ grid is always updated. This is the new primary method for showing
925
+ similarity results.
926
+ """
927
+ self.active_ordered_ids = ordered_ids
928
+
929
+ # Render the selection based on the new order
930
+ self.render_selection_from_ids(set(ordered_ids))
931
+
932
+ # Now, perform the isolation logic directly to bypass the guard clause
933
+ self.isolated_widgets = set(self.selected_widgets)
934
+ self.content_widget.setUpdatesEnabled(False)
935
+ try:
936
+ for widget in self.annotation_widgets_by_id.values():
937
+ # Show widget if it's in our target set, hide otherwise
938
+ if widget in self.isolated_widgets:
939
+ widget.show()
940
+ else:
941
+ widget.hide()
942
+
943
+ self.isolated_mode = True
944
+ self.recalculate_layout() # Crucial grid update
945
+ finally:
946
+ self.content_widget.setUpdatesEnabled(True)
947
+
948
+ self._update_toolbar_state()
949
+ self.explorer_window.main_window.label_window.update_annotation_count()
950
+
951
+ @pyqtSlot()
952
+ def show_all_annotations(self):
953
+ """Shows all annotation widgets, exiting the isolated mode."""
954
+ if not self.isolated_mode:
955
+ return
956
+
957
+ self.isolated_mode = False
958
+ self.isolated_widgets.clear()
959
+ self.active_ordered_ids = [] # Clear similarity sort context
960
+
961
+ self.content_widget.setUpdatesEnabled(False)
962
+ try:
963
+ # Show all widgets that are managed by the viewer
964
+ for widget in self.annotation_widgets_by_id.values():
965
+ widget.show()
966
+
967
+ self.recalculate_layout()
968
+ finally:
969
+ self.content_widget.setUpdatesEnabled(True)
970
+
971
+ self._update_toolbar_state()
972
+ self.explorer_window.main_window.label_window.update_annotation_count()
973
+
974
+ def _update_toolbar_state(self):
975
+ """Updates the toolbar buttons based on selection and isolation mode."""
976
+ selection_exists = bool(self.selected_widgets)
977
+ if self.isolated_mode:
978
+ self.isolate_button.hide()
979
+ self.show_all_button.show()
980
+ self.show_all_button.setEnabled(True)
981
+ else:
982
+ self.isolate_button.show()
983
+ self.show_all_button.hide()
984
+ self.isolate_button.setEnabled(selection_exists)
985
+
986
+ def on_sort_changed(self, sort_type):
987
+ """Handle sort type change."""
988
+ self.active_ordered_ids = [] # Clear any special ordering
989
+ self.recalculate_layout()
990
+
991
+ def set_confidence_sort_availability(self, is_available):
992
+ """Sets the availability of the confidence sort option."""
993
+ self.is_confidence_sort_available = is_available
994
+ self._update_sort_options_state()
995
+
996
+ def _get_sorted_data_items(self):
997
+ """Get data items sorted according to the current sort setting."""
998
+ # If a specific order is active (e.g., from similarity search), use it.
999
+ if self.active_ordered_ids:
1000
+ item_map = {i.annotation.id: i for i in self.all_data_items}
1001
+ ordered_items = [item_map[ann_id] for ann_id in self.active_ordered_ids if ann_id in item_map]
1002
+ return ordered_items
1003
+
1004
+ # Otherwise, use the dropdown sort logic
1005
+ sort_type = self.sort_combo.currentText()
1006
+ items = list(self.all_data_items)
1007
+
1008
+ if sort_type == "Label":
1009
+ items.sort(key=lambda i: i.effective_label.short_label_code)
1010
+ elif sort_type == "Image":
1011
+ items.sort(key=lambda i: os.path.basename(i.annotation.image_path))
1012
+ elif sort_type == "Confidence":
1013
+ # Sort by confidence, descending. Handles cases with no confidence gracefully.
1014
+ items.sort(key=lambda i: i.get_effective_confidence(), reverse=True)
1015
+
1016
+ return items
1017
+
1018
+ def _get_sorted_widgets(self):
1019
+ """
1020
+ Get widgets sorted according to the current sort setting.
1021
+ This is kept for compatibility with selection logic.
1022
+ """
1023
+ sorted_data_items = self._get_sorted_data_items()
1024
+ return [self.annotation_widgets_by_id[item.annotation.id]
1025
+ for item in sorted_data_items if item.annotation.id in self.annotation_widgets_by_id]
1026
+
1027
+ def _group_data_items_by_sort_key(self, data_items):
1028
+ """Group data items by the current sort key."""
1029
+ sort_type = self.sort_combo.currentText()
1030
+ if not self.active_ordered_ids and sort_type == "None":
1031
+ return [("", data_items)]
1032
+
1033
+ if self.active_ordered_ids: # Don't show group headers for similarity results
1034
+ return [("", data_items)]
1035
+
1036
+ groups = []
1037
+ current_group = []
1038
+ current_key = None
1039
+ for item in data_items:
1040
+ if sort_type == "Label":
1041
+ key = item.effective_label.short_label_code
1042
+ elif sort_type == "Image":
1043
+ key = os.path.basename(item.annotation.image_path)
1044
+ else:
1045
+ key = "" # No headers for Confidence or None
1046
+
1047
+ if key and current_key != key:
1048
+ if current_group:
1049
+ groups.append((current_key, current_group))
1050
+ current_group = [item]
1051
+ current_key = key
1052
+ else:
1053
+ current_group.append(item)
1054
+ if current_group:
1055
+ groups.append((current_key, current_group))
1056
+ return groups
1057
+
1058
+ def _clear_separator_labels(self):
1059
+ """Remove any existing group header labels."""
1060
+ if hasattr(self, '_group_headers'):
1061
+ for header in self._group_headers:
1062
+ header.setParent(None)
1063
+ header.deleteLater()
1064
+ self._group_headers = []
1065
+
1066
+ def _create_group_header(self, text):
1067
+ """Create a group header label."""
1068
+ if not hasattr(self, '_group_headers'):
1069
+ self._group_headers = []
1070
+ header = QLabel(text, self.content_widget)
1071
+ header.setStyleSheet(
1072
+ "QLabel {"
1073
+ " font-weight: bold;"
1074
+ " font-size: 12px;"
1075
+ " color: #555;"
1076
+ " background-color: #f0f0f0;"
1077
+ " border: 1px solid #ccc;"
1078
+ " border-radius: 3px;"
1079
+ " padding: 5px 8px;"
1080
+ " margin: 2px 0px;"
1081
+ " }"
1082
+ )
1083
+ header.setFixedHeight(30)
1084
+ header.setMinimumWidth(self.scroll_area.viewport().width() - 20)
1085
+ header.show()
1086
+ self._group_headers.append(header)
1087
+ return header
1088
+
1089
+ def on_size_changed(self, value):
1090
+ """Handle slider value change to resize annotation widgets."""
1091
+ if value % 2 != 0:
1092
+ value -= 1
1093
+
1094
+ self.current_widget_size = value
1095
+ self.size_value_label.setText(str(value))
1096
+ self.recalculate_layout()
1097
+
1098
+ def _schedule_update(self):
1099
+ """Schedules a delayed update of visible widgets to avoid performance issues during rapid scrolling."""
1100
+ self.update_timer.start(50) # 50ms delay
1101
+
1102
+ def _update_visible_widgets(self):
1103
+ """Shows and loads widgets that are in the viewport, and hides/unloads others."""
1104
+ if not self.widget_positions:
1105
+ return
1106
+
1107
+ self.content_widget.setUpdatesEnabled(False)
1108
+
1109
+ # Determine the visible rectangle in the content widget's coordinates
1110
+ scroll_y = self.scroll_area.verticalScrollBar().value()
1111
+ visible_content_rect = QRect(0,
1112
+ scroll_y,
1113
+ self.scroll_area.viewport().width(),
1114
+ self.scroll_area.viewport().height())
1115
+
1116
+ # Add a buffer to load images slightly before they become visible
1117
+ buffer = self.scroll_area.viewport().height() // 2
1118
+ visible_content_rect.adjust(0, -buffer, 0, buffer)
1119
+
1120
+ visible_ids = set()
1121
+ for ann_id, rect in self.widget_positions.items():
1122
+ if rect.intersects(visible_content_rect):
1123
+ visible_ids.add(ann_id)
1124
+
1125
+ # Update widgets based on visibility
1126
+ for ann_id, widget in self.annotation_widgets_by_id.items():
1127
+ if ann_id in visible_ids:
1128
+ # This widget should be visible
1129
+ widget.setGeometry(self.widget_positions[ann_id])
1130
+ widget.load_image() # Lazy-loads the image
1131
+ widget.show()
1132
+ else:
1133
+ # This widget is not visible
1134
+ if widget.isVisible():
1135
+ widget.hide()
1136
+ widget.unload_image() # Free up memory
1137
+
1138
+ self.content_widget.setUpdatesEnabled(True)
1139
+
1140
+ def recalculate_layout(self):
1141
+ """Calculates the positions for all widgets and the total size of the content area."""
1142
+ if not self.all_data_items:
1143
+ self.content_widget.setMinimumSize(1, 1)
1144
+ return
1145
+
1146
+ self._clear_separator_labels()
1147
+ sorted_data_items = self._get_sorted_data_items()
1148
+
1149
+ # If in isolated mode, only consider the isolated widgets for layout
1150
+ if self.isolated_mode:
1151
+ isolated_ids = {w.data_item.annotation.id for w in self.isolated_widgets}
1152
+ sorted_data_items = [item for item in sorted_data_items if item.annotation.id in isolated_ids]
1153
+
1154
+ if not sorted_data_items:
1155
+ self.content_widget.setMinimumSize(1, 1)
1156
+ return
1157
+
1158
+ # Create groups based on the current sort key
1159
+ groups = self._group_data_items_by_sort_key(sorted_data_items)
1160
+ spacing = max(5, int(self.current_widget_size * 0.08))
1161
+ available_width = self.scroll_area.viewport().width()
1162
+ x, y = spacing, spacing
1163
+ max_height_in_row = 0
1164
+
1165
+ self.widget_positions.clear()
1166
+
1167
+ # Calculate positions
1168
+ for group_name, group_data_items in groups:
1169
+ if group_name and self.sort_combo.currentText() != "None":
1170
+ if x > spacing:
1171
+ x = spacing
1172
+ y += max_height_in_row + spacing
1173
+ max_height_in_row = 0
1174
+ header_label = self._create_group_header(group_name)
1175
+ header_label.move(x, y)
1176
+ y += header_label.height() + spacing
1177
+ x = spacing
1178
+ max_height_in_row = 0
1179
+
1180
+ for data_item in group_data_items:
1181
+ ann_id = data_item.annotation.id
1182
+ if ann_id in self.annotation_widgets_by_id:
1183
+ widget = self.annotation_widgets_by_id[ann_id]
1184
+ # Make sure this is present:
1185
+ widget.update_height(self.current_widget_size)
1186
+ else:
1187
+ widget = AnnotationImageWidget(data_item, self.current_widget_size, self, self.content_widget)
1188
+ # Ensure aspect ratio is calculated on creation:
1189
+ widget.recalculate_aspect_ratio()
1190
+ self.annotation_widgets_by_id[ann_id] = widget
1191
+
1192
+ widget_size = widget.size()
1193
+ if x > spacing and x + widget_size.width() > available_width:
1194
+ x = spacing
1195
+ y += max_height_in_row + spacing
1196
+ max_height_in_row = 0
1197
+
1198
+ self.widget_positions[ann_id] = QRect(x, y, widget_size.width(), widget_size.height())
1199
+
1200
+ x += widget_size.width() + spacing
1201
+ max_height_in_row = max(max_height_in_row, widget_size.height())
1202
+
1203
+ total_height = y + max_height_in_row + spacing
1204
+ self.content_widget.setMinimumSize(available_width, total_height)
1205
+
1206
+ # After calculating layout, update what's visible
1207
+ self._update_visible_widgets()
1208
+
1209
+ def update_annotations(self, data_items):
1210
+ """Update displayed annotations, creating new widgets for them."""
1211
+ if self.isolated_mode:
1212
+ self.show_all_annotations()
1213
+
1214
+ # Clear out widgets for data items that are no longer in the new set
1215
+ all_ann_ids = {item.annotation.id for item in data_items}
1216
+ for ann_id, widget in list(self.annotation_widgets_by_id.items()):
1217
+ if ann_id not in all_ann_ids:
1218
+ if widget in self.selected_widgets:
1219
+ self.selected_widgets.remove(widget)
1220
+ widget.setParent(None)
1221
+ widget.deleteLater()
1222
+ del self.annotation_widgets_by_id[ann_id]
1223
+
1224
+ self.all_data_items = data_items
1225
+ self.selected_widgets.clear()
1226
+ self.last_selected_item_id = None
1227
+
1228
+ self.recalculate_layout()
1229
+ self._update_toolbar_state()
1230
+ # Update the label window with the new annotation count
1231
+ self.explorer_window.main_window.label_window.update_annotation_count()
1232
+
1233
+ def resizeEvent(self, event):
1234
+ """On window resize, reflow the annotation widgets."""
1235
+ super(AnnotationViewer, self).resizeEvent(event)
1236
+ if not hasattr(self, '_resize_timer'):
1237
+ self._resize_timer = QTimer(self)
1238
+ self._resize_timer.setSingleShot(True)
1239
+ self._resize_timer.timeout.connect(self.recalculate_layout)
1240
+ self._resize_timer.start(100)
1241
+
1242
+ def keyPressEvent(self, event):
1243
+ """Handles key presses for deleting selected annotations."""
1244
+ if event.key() in (Qt.Key_Delete, Qt.Key_Backspace) and event.modifiers() == Qt.ControlModifier:
1245
+ if not self.selected_widgets:
1246
+ super().keyPressEvent(event)
1247
+ return
1248
+
1249
+ # Extract the central data items from the selected widgets
1250
+ data_items_to_delete = [widget.data_item for widget in self.selected_widgets]
1251
+
1252
+ # Delegate the actual deletion to the main ExplorerWindow
1253
+ if data_items_to_delete:
1254
+ self.explorer_window.delete_data_items(data_items_to_delete)
1255
+
1256
+ event.accept()
1257
+ else:
1258
+ super().keyPressEvent(event)
1259
+
1260
+ def eventFilter(self, source, event):
1261
+ """Filters events from the scroll area's viewport to handle mouse interactions."""
1262
+ if source is self.scroll_area.viewport():
1263
+ if event.type() == QEvent.MouseButtonPress:
1264
+ return self.viewport_mouse_press(event)
1265
+ elif event.type() == QEvent.MouseMove:
1266
+ return self.viewport_mouse_move(event)
1267
+ elif event.type() == QEvent.MouseButtonRelease:
1268
+ return self.viewport_mouse_release(event)
1269
+ elif event.type() == QEvent.MouseButtonDblClick:
1270
+ return self.viewport_mouse_double_click(event)
1271
+
1272
+ return super(AnnotationViewer, self).eventFilter(source, event)
1273
+
1274
+ def viewport_mouse_press(self, event):
1275
+ """Handle mouse press inside the viewport for selection."""
1276
+ if event.button() == Qt.LeftButton and event.modifiers() == Qt.ControlModifier:
1277
+ # Start rubber band selection
1278
+ self.selection_at_press = set(self.selected_widgets)
1279
+ self.rubber_band_origin = event.pos()
1280
+
1281
+ # Check if the press was on a widget to avoid starting rubber band on a widget click
1282
+ content_pos = self.content_widget.mapFrom(self.scroll_area.viewport(), event.pos())
1283
+ child_at_pos = self.content_widget.childAt(content_pos)
1284
+ self.mouse_pressed_on_widget = isinstance(child_at_pos, AnnotationImageWidget)
1285
+
1286
+ return True # Event handled
1287
+
1288
+ elif event.button() == Qt.LeftButton and not event.modifiers():
1289
+ # Clear selection if clicking on the background
1290
+ content_pos = self.content_widget.mapFrom(self.scroll_area.viewport(), event.pos())
1291
+ if self.content_widget.childAt(content_pos) is None:
1292
+ if self.selected_widgets:
1293
+ changed_ids = [w.data_item.annotation.id for w in self.selected_widgets]
1294
+ self.clear_selection()
1295
+ self.selection_changed.emit(changed_ids)
1296
+ if hasattr(self.explorer_window.annotation_window, 'unselect_annotations'):
1297
+ self.explorer_window.annotation_window.unselect_annotations()
1298
+ return True
1299
+
1300
+ return False # Let the event propagate for default behaviors like scrolling
1301
+
1302
+ def viewport_mouse_double_click(self, event):
1303
+ """Handle double-click in the viewport to clear selection and reset view."""
1304
+ if event.button() == Qt.LeftButton:
1305
+ if self.selected_widgets:
1306
+ changed_ids = [w.data_item.annotation.id for w in self.selected_widgets]
1307
+ self.clear_selection()
1308
+ self.selection_changed.emit(changed_ids)
1309
+ if self.isolated_mode:
1310
+ self.show_all_annotations()
1311
+ self.reset_view_requested.emit()
1312
+ return True
1313
+ return False
1314
+
1315
+ def viewport_mouse_move(self, event):
1316
+ """Handle mouse move in the viewport for dynamic rubber band selection."""
1317
+ if (
1318
+ self.rubber_band_origin is None or
1319
+ event.buttons() != Qt.LeftButton or
1320
+ event.modifiers() != Qt.ControlModifier or
1321
+ self.mouse_pressed_on_widget
1322
+ ):
1323
+ return False
1324
+
1325
+ # Only start selection if drag distance exceeds threshold
1326
+ distance = (event.pos() - self.rubber_band_origin).manhattanLength()
1327
+ if distance < self.drag_threshold:
1328
+ return True
1329
+
1330
+ # Create and show the rubber band if not already present
1331
+ if not self.rubber_band:
1332
+ self.rubber_band = QRubberBand(QRubberBand.Rectangle, self.scroll_area.viewport())
1333
+
1334
+ rect = QRect(self.rubber_band_origin, event.pos()).normalized()
1335
+ self.rubber_band.setGeometry(rect)
1336
+ self.rubber_band.show()
1337
+
1338
+ selection_rect = self.rubber_band.geometry()
1339
+ content_widget = self.content_widget
1340
+ changed_ids = []
1341
+
1342
+ # Iterate over all annotation widgets to update selection state
1343
+ for widget in self.annotation_widgets_by_id.values():
1344
+ # Map widget's geometry from content_widget coordinates to viewport coordinates
1345
+ mapped_top_left = content_widget.mapTo(self.scroll_area.viewport(), widget.geometry().topLeft())
1346
+ widget_rect_in_viewport = QRect(mapped_top_left, widget.geometry().size())
1347
+
1348
+ is_in_band = selection_rect.intersects(widget_rect_in_viewport)
1349
+ should_be_selected = (widget in self.selection_at_press) or is_in_band
1350
+
1351
+ # Select or deselect widgets as needed
1352
+ if should_be_selected and not widget.is_selected():
1353
+ if self.select_widget(widget):
1354
+ changed_ids.append(widget.data_item.annotation.id)
1355
+
1356
+ elif not should_be_selected and widget.is_selected():
1357
+ if self.deselect_widget(widget):
1358
+ changed_ids.append(widget.data_item.annotation.id)
1359
+
1360
+ # Emit signal if any selection state changed
1361
+ if changed_ids:
1362
+ self.selection_changed.emit(changed_ids)
1363
+
1364
+ return True
1365
+
1366
+ def viewport_mouse_release(self, event):
1367
+ """Handle mouse release in the viewport to finalize rubber band selection."""
1368
+ if self.rubber_band_origin is not None and event.button() == Qt.LeftButton:
1369
+ if self.rubber_band and self.rubber_band.isVisible():
1370
+ self.rubber_band.hide()
1371
+ self.rubber_band.deleteLater()
1372
+ self.rubber_band = None
1373
+ self.rubber_band_origin = None
1374
+ return True
1375
+ return False
1376
+
1377
+ def handle_annotation_selection(self, widget, event):
1378
+ """Handle selection of annotation widgets with different modes (single, ctrl, shift)."""
1379
+ # The list for range selection should be based on the sorted data items
1380
+ sorted_data_items = self._get_sorted_data_items()
1381
+
1382
+ # In isolated mode, the list should only contain isolated items
1383
+ if self.isolated_mode:
1384
+ isolated_ids = {w.data_item.annotation.id for w in self.isolated_widgets}
1385
+ sorted_data_items = [item for item in sorted_data_items if item.annotation.id in isolated_ids]
1386
+
1387
+ try:
1388
+ # Find the index of the clicked widget's data item
1389
+ widget_data_item = widget.data_item
1390
+ current_index = sorted_data_items.index(widget_data_item)
1391
+ except ValueError:
1392
+ return
1393
+
1394
+ modifiers = event.modifiers()
1395
+ changed_ids = []
1396
+
1397
+ # Shift or Shift+Ctrl: range selection.
1398
+ if modifiers in (Qt.ShiftModifier, Qt.ShiftModifier | Qt.ControlModifier):
1399
+ last_index = -1
1400
+ if self.last_selected_item_id:
1401
+ try:
1402
+ # Find the data item corresponding to the last selected ID
1403
+ last_item = self.explorer_window.data_item_cache[self.last_selected_item_id]
1404
+ # Find its index in the *current* sorted list
1405
+ last_index = sorted_data_items.index(last_item)
1406
+ except (KeyError, ValueError):
1407
+ # The last selected item is not in the current view or cache, so no anchor
1408
+ last_index = -1
1409
+
1410
+ if last_index != -1:
1411
+ start = min(last_index, current_index)
1412
+ end = max(last_index, current_index)
1413
+
1414
+ # Select all widgets in the range
1415
+ for i in range(start, end + 1):
1416
+ item_to_select = sorted_data_items[i]
1417
+ widget_to_select = self.annotation_widgets_by_id.get(item_to_select.annotation.id)
1418
+ if widget_to_select and self.select_widget(widget_to_select):
1419
+ changed_ids.append(item_to_select.annotation.id)
1420
+ else:
1421
+ # No previous selection, just select the clicked widget
1422
+ if self.select_widget(widget):
1423
+ changed_ids.append(widget.data_item.annotation.id)
1424
+
1425
+ self.last_selected_item_id = widget.data_item.annotation.id
1426
+
1427
+ # Ctrl: toggle selection of the clicked widget
1428
+ elif modifiers == Qt.ControlModifier:
1429
+ # Toggle selection and update the anchor
1430
+ if self.toggle_widget_selection(widget):
1431
+ changed_ids.append(widget.data_item.annotation.id)
1432
+ self.last_selected_item_id = widget.data_item.annotation.id
1433
+
1434
+ # No modifier: single selection
1435
+ else:
1436
+ newly_selected_id = widget.data_item.annotation.id
1437
+
1438
+ # Deselect all others
1439
+ for w in list(self.selected_widgets):
1440
+ if w.data_item.annotation.id != newly_selected_id:
1441
+ if self.deselect_widget(w):
1442
+ changed_ids.append(w.data_item.annotation.id)
1443
+
1444
+ # Select the clicked widget
1445
+ if self.select_widget(widget):
1446
+ changed_ids.append(newly_selected_id)
1447
+ self.last_selected_item_id = widget.data_item.annotation.id
1448
+
1449
+ # If in isolated mode, update which widgets are visible
1450
+ if self.isolated_mode:
1451
+ pass # Do not change the isolated set on internal selection changes
1452
+
1453
+ # Emit signal if any selection state changed
1454
+ if changed_ids:
1455
+ self.selection_changed.emit(changed_ids)
1456
+
1457
+ def toggle_widget_selection(self, widget):
1458
+ """Toggles the selection state of a widget and returns True if changed."""
1459
+ if widget.is_selected():
1460
+ return self.deselect_widget(widget)
1461
+ else:
1462
+ return self.select_widget(widget)
1463
+
1464
+ def select_widget(self, widget):
1465
+ """Selects a widget, updates its data_item, and returns True if state changed."""
1466
+ if not widget.is_selected(): # is_selected() checks the data_item
1467
+ # 1. Controller modifies the state on the data item
1468
+ widget.data_item.set_selected(True)
1469
+ # 2. Controller tells the view to update its appearance
1470
+ widget.update_selection_visuals()
1471
+ self.selected_widgets.append(widget)
1472
+ self._update_toolbar_state()
1473
+ return True
1474
+ return False
1475
+
1476
+ def deselect_widget(self, widget):
1477
+ """Deselects a widget, updates its data_item, and returns True if state changed."""
1478
+ if widget.is_selected():
1479
+ # 1. Controller modifies the state on the data item
1480
+ widget.data_item.set_selected(False)
1481
+ # 2. Controller tells the view to update its appearance
1482
+ widget.update_selection_visuals()
1483
+ if widget in self.selected_widgets:
1484
+ self.selected_widgets.remove(widget)
1485
+ self._update_toolbar_state()
1486
+ return True
1487
+ return False
1488
+
1489
+ def clear_selection(self):
1490
+ """Clear all selected widgets and update toolbar state."""
1491
+ for widget in list(self.selected_widgets):
1492
+ # This will internally call deselect_widget, which is fine
1493
+ self.deselect_widget(widget)
1494
+
1495
+ self.selected_widgets.clear()
1496
+ self._update_toolbar_state()
1497
+
1498
+ def get_selected_annotations(self):
1499
+ """Get the annotations corresponding to selected widgets."""
1500
+ return [widget.annotation for widget in self.selected_widgets]
1501
+
1502
+ def render_selection_from_ids(self, selected_ids):
1503
+ """Update the visual selection of widgets based on a set of IDs from the controller."""
1504
+ self.setUpdatesEnabled(False)
1505
+ try:
1506
+ for ann_id, widget in self.annotation_widgets_by_id.items():
1507
+ is_selected = ann_id in selected_ids
1508
+ # 1. Update the state on the central data item
1509
+ widget.data_item.set_selected(is_selected)
1510
+ # 2. Tell the widget to update its visuals based on the new state
1511
+ widget.update_selection_visuals()
1512
+
1513
+ # Resync internal list of selected widgets from the source of truth
1514
+ self.selected_widgets = [w for w in self.annotation_widgets_by_id.values() if w.is_selected()]
1515
+
1516
+ finally:
1517
+ self.setUpdatesEnabled(True)
1518
+ self._update_toolbar_state()
1519
+
1520
+ def apply_preview_label_to_selected(self, preview_label):
1521
+ """Apply a preview label and emit a signal for the embedding view to update."""
1522
+ if not self.selected_widgets or not preview_label:
1523
+ return
1524
+ changed_ids = []
1525
+ for widget in self.selected_widgets:
1526
+ widget.data_item.set_preview_label(preview_label)
1527
+ widget.update() # Force repaint with new color
1528
+ changed_ids.append(widget.data_item.annotation.id)
1529
+
1530
+ if self.sort_combo.currentText() == "Label":
1531
+ self.recalculate_layout()
1532
+ if changed_ids:
1533
+ self.preview_changed.emit(changed_ids)
1534
+
1535
+ def clear_preview_states(self):
1536
+ """
1537
+ Clears all preview states, including label changes,
1538
+ reverting them to their original state.
1539
+ """
1540
+ something_changed = False
1541
+ for widget in self.annotation_widgets_by_id.values():
1542
+ # Check for and clear preview labels
1543
+ if widget.data_item.has_preview_changes():
1544
+ widget.data_item.clear_preview_label()
1545
+ widget.update() # Repaint to show original color
1546
+ something_changed = True
1547
+
1548
+ if something_changed:
1549
+ # Recalculate positions to update sorting and re-flow the layout
1550
+ if self.sort_combo.currentText() == "Label":
1551
+ self.recalculate_layout()
1552
+
1553
+ def has_preview_changes(self):
1554
+ """Return True if there are preview changes."""
1555
+ return any(w.data_item.has_preview_changes() for w in self.annotation_widgets_by_id.values())
1556
+
1557
+ def get_preview_changes_summary(self):
1558
+ """Get a summary of preview changes."""
1559
+ change_count = sum(1 for w in self.annotation_widgets_by_id.values() if w.data_item.has_preview_changes())
1560
+ return f"{change_count} annotation(s) with preview changes" if change_count else "No preview changes"
1561
+
1562
+ def apply_preview_changes_permanently(self):
1563
+ """Apply preview changes permanently."""
1564
+ applied_annotations = []
1565
+ for widget in self.annotation_widgets_by_id.values():
1566
+ if widget.data_item.apply_preview_permanently():
1567
+ applied_annotations.append(widget.annotation)
1568
+ return applied_annotations