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.
- coralnet_toolbox/Annotations/QtPolygonAnnotation.py +57 -12
- coralnet_toolbox/Annotations/QtRectangleAnnotation.py +44 -14
- coralnet_toolbox/Explorer/QtDataItem.py +52 -22
- coralnet_toolbox/Explorer/QtExplorer.py +277 -1600
- coralnet_toolbox/Explorer/QtSettingsWidgets.py +101 -15
- coralnet_toolbox/Explorer/QtViewers.py +1568 -0
- coralnet_toolbox/Explorer/transformer_models.py +70 -0
- coralnet_toolbox/Explorer/yolo_models.py +112 -0
- coralnet_toolbox/IO/QtExportMaskAnnotations.py +538 -403
- coralnet_toolbox/Icons/system_monitor.png +0 -0
- coralnet_toolbox/MachineLearning/ImportDataset/QtBase.py +239 -147
- coralnet_toolbox/MachineLearning/VideoInference/YOLO3D/run.py +102 -16
- coralnet_toolbox/QtAnnotationWindow.py +16 -10
- coralnet_toolbox/QtEventFilter.py +4 -4
- coralnet_toolbox/QtImageWindow.py +3 -7
- coralnet_toolbox/QtMainWindow.py +104 -64
- coralnet_toolbox/QtProgressBar.py +1 -0
- coralnet_toolbox/QtSystemMonitor.py +370 -0
- coralnet_toolbox/Rasters/RasterTableModel.py +20 -0
- coralnet_toolbox/Results/ConvertResults.py +14 -8
- coralnet_toolbox/Results/ResultsProcessor.py +3 -2
- coralnet_toolbox/SAM/QtDeployGenerator.py +2 -5
- coralnet_toolbox/SAM/QtDeployPredictor.py +11 -3
- coralnet_toolbox/SeeAnything/QtDeployGenerator.py +146 -116
- coralnet_toolbox/SeeAnything/QtDeployPredictor.py +55 -9
- coralnet_toolbox/Tile/QtTileBatchInference.py +4 -4
- coralnet_toolbox/Tools/QtPolygonTool.py +42 -3
- coralnet_toolbox/Tools/QtRectangleTool.py +30 -0
- coralnet_toolbox/Tools/QtSAMTool.py +140 -91
- coralnet_toolbox/Transformers/Models/GroundingDINO.py +72 -0
- coralnet_toolbox/Transformers/Models/OWLViT.py +72 -0
- coralnet_toolbox/Transformers/Models/OmDetTurbo.py +68 -0
- coralnet_toolbox/Transformers/Models/QtBase.py +120 -0
- coralnet_toolbox/{AutoDistill → Transformers}/Models/__init__.py +1 -1
- coralnet_toolbox/{AutoDistill → Transformers}/QtBatchInference.py +15 -15
- coralnet_toolbox/{AutoDistill → Transformers}/QtDeployModel.py +18 -16
- coralnet_toolbox/{AutoDistill → Transformers}/__init__.py +1 -1
- coralnet_toolbox/__init__.py +1 -1
- coralnet_toolbox/utilities.py +21 -15
- {coralnet_toolbox-0.0.74.dist-info → coralnet_toolbox-0.0.76.dist-info}/METADATA +13 -10
- {coralnet_toolbox-0.0.74.dist-info → coralnet_toolbox-0.0.76.dist-info}/RECORD +45 -40
- coralnet_toolbox/AutoDistill/Models/GroundingDINO.py +0 -81
- coralnet_toolbox/AutoDistill/Models/OWLViT.py +0 -76
- coralnet_toolbox/AutoDistill/Models/OmDetTurbo.py +0 -75
- coralnet_toolbox/AutoDistill/Models/QtBase.py +0 -112
- {coralnet_toolbox-0.0.74.dist-info → coralnet_toolbox-0.0.76.dist-info}/WHEEL +0 -0
- {coralnet_toolbox-0.0.74.dist-info → coralnet_toolbox-0.0.76.dist-info}/entry_points.txt +0 -0
- {coralnet_toolbox-0.0.74.dist-info → coralnet_toolbox-0.0.76.dist-info}/licenses/LICENSE.txt +0 -0
- {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
|