coralnet-toolbox 0.0.68__py2.py3-none-any.whl → 0.0.70__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/QtAnnotation.py +1 -1
- coralnet_toolbox/Explorer/QtDataItem.py +55 -16
- coralnet_toolbox/Explorer/QtExplorer.py +1211 -341
- coralnet_toolbox/Explorer/QtFeatureStore.py +176 -0
- coralnet_toolbox/Explorer/QtSettingsWidgets.py +207 -24
- coralnet_toolbox/QtAnnotationWindow.py +28 -1
- coralnet_toolbox/QtEventFilter.py +18 -17
- coralnet_toolbox/QtMainWindow.py +65 -8
- coralnet_toolbox/__init__.py +1 -1
- {coralnet_toolbox-0.0.68.dist-info → coralnet_toolbox-0.0.70.dist-info}/METADATA +8 -4
- {coralnet_toolbox-0.0.68.dist-info → coralnet_toolbox-0.0.70.dist-info}/RECORD +15 -14
- {coralnet_toolbox-0.0.68.dist-info → coralnet_toolbox-0.0.70.dist-info}/WHEEL +0 -0
- {coralnet_toolbox-0.0.68.dist-info → coralnet_toolbox-0.0.70.dist-info}/entry_points.txt +0 -0
- {coralnet_toolbox-0.0.68.dist-info → coralnet_toolbox-0.0.70.dist-info}/licenses/LICENSE.txt +0 -0
- {coralnet_toolbox-0.0.68.dist-info → coralnet_toolbox-0.0.70.dist-info}/top_level.txt +0 -0
@@ -1,7 +1,9 @@
|
|
1
|
+
import warnings
|
2
|
+
|
1
3
|
import os
|
4
|
+
|
2
5
|
import numpy as np
|
3
6
|
import torch
|
4
|
-
import warnings
|
5
7
|
|
6
8
|
from ultralytics import YOLO
|
7
9
|
|
@@ -10,19 +12,20 @@ from coralnet_toolbox.utilities import pixmap_to_numpy
|
|
10
12
|
|
11
13
|
from PyQt5.QtGui import QIcon, QPen, QColor, QPainter, QBrush, QPainterPath, QMouseEvent
|
12
14
|
from PyQt5.QtCore import Qt, QTimer, QRect, QRectF, QPointF, pyqtSignal, QSignalBlocker, pyqtSlot
|
13
|
-
|
14
15
|
from PyQt5.QtWidgets import (QVBoxLayout, QHBoxLayout, QGraphicsView, QScrollArea,
|
15
|
-
QGraphicsScene, QPushButton, QComboBox, QLabel, QWidget,
|
16
|
-
QMainWindow, QSplitter, QGroupBox,
|
17
|
-
|
18
|
-
|
19
|
-
QGraphicsRectItem, QRubberBand, QStyleOptionGraphicsItem,
|
20
|
-
QTabWidget, QLineEdit, QFileDialog)
|
16
|
+
QGraphicsScene, QPushButton, QComboBox, QLabel, QWidget,
|
17
|
+
QMainWindow, QSplitter, QGroupBox, QSlider, QMessageBox,
|
18
|
+
QApplication, QGraphicsRectItem, QRubberBand, QMenu,
|
19
|
+
QWidgetAction, QToolButton, QAction)
|
21
20
|
|
21
|
+
from coralnet_toolbox.Explorer.QtFeatureStore import FeatureStore
|
22
22
|
from coralnet_toolbox.Explorer.QtDataItem import AnnotationDataItem
|
23
23
|
from coralnet_toolbox.Explorer.QtDataItem import EmbeddingPointItem
|
24
24
|
from coralnet_toolbox.Explorer.QtDataItem import AnnotationImageWidget
|
25
25
|
from coralnet_toolbox.Explorer.QtSettingsWidgets import ModelSettingsWidget
|
26
|
+
from coralnet_toolbox.Explorer.QtSettingsWidgets import SimilaritySettingsWidget
|
27
|
+
from coralnet_toolbox.Explorer.QtSettingsWidgets import UncertaintySettingsWidget
|
28
|
+
from coralnet_toolbox.Explorer.QtSettingsWidgets import MislabelSettingsWidget
|
26
29
|
from coralnet_toolbox.Explorer.QtSettingsWidgets import EmbeddingSettingsWidget
|
27
30
|
from coralnet_toolbox.Explorer.QtSettingsWidgets import AnnotationSettingsWidget
|
28
31
|
|
@@ -32,13 +35,13 @@ try:
|
|
32
35
|
from sklearn.preprocessing import StandardScaler
|
33
36
|
from sklearn.decomposition import PCA
|
34
37
|
from sklearn.manifold import TSNE
|
35
|
-
from umap import UMAP
|
38
|
+
from umap import UMAP
|
36
39
|
except ImportError:
|
37
40
|
print("Warning: sklearn or umap not installed. Some features may be unavailable.")
|
38
41
|
StandardScaler = None
|
39
42
|
PCA = None
|
40
43
|
TSNE = None
|
41
|
-
UMAP = None
|
44
|
+
UMAP = None
|
42
45
|
|
43
46
|
|
44
47
|
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
@@ -56,18 +59,22 @@ POINT_WIDTH = 3
|
|
56
59
|
|
57
60
|
|
58
61
|
class EmbeddingViewer(QWidget):
|
59
|
-
"""Custom QGraphicsView for interactive embedding visualization with
|
62
|
+
"""Custom QGraphicsView for interactive embedding visualization with an isolate mode."""
|
60
63
|
selection_changed = pyqtSignal(list)
|
61
64
|
reset_view_requested = pyqtSignal()
|
65
|
+
find_mislabels_requested = pyqtSignal()
|
66
|
+
mislabel_parameters_changed = pyqtSignal(dict)
|
67
|
+
find_uncertain_requested = pyqtSignal()
|
68
|
+
uncertainty_parameters_changed = pyqtSignal(dict)
|
62
69
|
|
63
70
|
def __init__(self, parent=None):
|
64
71
|
"""Initialize the EmbeddingViewer widget."""
|
65
|
-
self.graphics_scene = QGraphicsScene()
|
66
|
-
self.graphics_scene.setSceneRect(-5000, -5000, 10000, 10000)
|
67
|
-
|
68
72
|
super(EmbeddingViewer, self).__init__(parent)
|
69
73
|
self.explorer_window = parent
|
70
74
|
|
75
|
+
self.graphics_scene = QGraphicsScene()
|
76
|
+
self.graphics_scene.setSceneRect(-5000, -5000, 10000, 10000)
|
77
|
+
|
71
78
|
self.graphics_view = QGraphicsView(self.graphics_scene)
|
72
79
|
self.graphics_view.setRenderHint(QPainter.Antialiasing)
|
73
80
|
self.graphics_view.setDragMode(QGraphicsView.ScrollHandDrag)
|
@@ -81,6 +88,12 @@ class EmbeddingViewer(QWidget):
|
|
81
88
|
self.points_by_id = {}
|
82
89
|
self.previous_selection_ids = set()
|
83
90
|
|
91
|
+
# State for isolate mode
|
92
|
+
self.isolated_mode = False
|
93
|
+
self.isolated_points = set()
|
94
|
+
|
95
|
+
self.is_uncertainty_analysis_available = False
|
96
|
+
|
84
97
|
self.animation_offset = 0
|
85
98
|
self.animation_timer = QTimer()
|
86
99
|
self.animation_timer.timeout.connect(self.animate_selection)
|
@@ -95,26 +108,164 @@ class EmbeddingViewer(QWidget):
|
|
95
108
|
self.graphics_view.wheelEvent = self.wheelEvent
|
96
109
|
|
97
110
|
def setup_ui(self):
|
98
|
-
"""Set up the UI with
|
111
|
+
"""Set up the UI with toolbar layout and graphics view."""
|
99
112
|
layout = QVBoxLayout(self)
|
100
113
|
layout.setContentsMargins(0, 0, 0, 0)
|
114
|
+
|
115
|
+
toolbar_layout = QHBoxLayout()
|
116
|
+
|
117
|
+
# Isolate/Show All buttons
|
118
|
+
self.isolate_button = QPushButton("Isolate Selection")
|
119
|
+
self.isolate_button.setToolTip("Hide all non-selected points")
|
120
|
+
self.isolate_button.clicked.connect(self.isolate_selection)
|
121
|
+
toolbar_layout.addWidget(self.isolate_button)
|
122
|
+
|
123
|
+
self.show_all_button = QPushButton("Show All")
|
124
|
+
self.show_all_button.setToolTip("Show all embedding points")
|
125
|
+
self.show_all_button.clicked.connect(self.show_all_points)
|
126
|
+
toolbar_layout.addWidget(self.show_all_button)
|
127
|
+
|
128
|
+
toolbar_layout.addWidget(self._create_separator())
|
129
|
+
|
130
|
+
# Create a QToolButton to have both a primary action and a dropdown menu
|
131
|
+
self.find_mislabels_button = QToolButton()
|
132
|
+
self.find_mislabels_button.setText("Find Potential Mislabels")
|
133
|
+
self.find_mislabels_button.setPopupMode(QToolButton.MenuButtonPopup) # Key change for split-button style
|
134
|
+
self.find_mislabels_button.setToolButtonStyle(Qt.ToolButtonTextOnly)
|
135
|
+
self.find_mislabels_button.setStyleSheet(
|
136
|
+
"QToolButton::menu-indicator {"
|
137
|
+
" subcontrol-position: right center;"
|
138
|
+
" subcontrol-origin: padding;"
|
139
|
+
" left: -4px;"
|
140
|
+
" }"
|
141
|
+
)
|
142
|
+
|
143
|
+
# The primary action (clicking the button) triggers the analysis
|
144
|
+
run_analysis_action = QAction("Find Potential Mislabels", self)
|
145
|
+
run_analysis_action.triggered.connect(self.find_mislabels_requested.emit)
|
146
|
+
self.find_mislabels_button.setDefaultAction(run_analysis_action)
|
147
|
+
|
148
|
+
# The dropdown menu contains the settings
|
149
|
+
mislabel_settings_widget = MislabelSettingsWidget()
|
150
|
+
settings_menu = QMenu(self)
|
151
|
+
widget_action = QWidgetAction(settings_menu)
|
152
|
+
widget_action.setDefaultWidget(mislabel_settings_widget)
|
153
|
+
settings_menu.addAction(widget_action)
|
154
|
+
self.find_mislabels_button.setMenu(settings_menu)
|
155
|
+
|
156
|
+
# Connect the widget's signal to the viewer's signal
|
157
|
+
mislabel_settings_widget.parameters_changed.connect(self.mislabel_parameters_changed.emit)
|
158
|
+
toolbar_layout.addWidget(self.find_mislabels_button)
|
159
|
+
|
160
|
+
# Create a QToolButton for uncertainty analysis
|
161
|
+
self.find_uncertain_button = QToolButton()
|
162
|
+
self.find_uncertain_button.setText("Review Uncertain")
|
163
|
+
self.find_uncertain_button.setToolTip(
|
164
|
+
"Find annotations where the model is least confident.\n"
|
165
|
+
"Requires a .pt classification model and 'Predictions' mode."
|
166
|
+
)
|
167
|
+
self.find_uncertain_button.setPopupMode(QToolButton.MenuButtonPopup)
|
168
|
+
self.find_uncertain_button.setToolButtonStyle(Qt.ToolButtonTextOnly)
|
169
|
+
self.find_uncertain_button.setStyleSheet(
|
170
|
+
"QToolButton::menu-indicator { "
|
171
|
+
"subcontrol-position: right center; "
|
172
|
+
"subcontrol-origin: padding; "
|
173
|
+
"left: -4px; }"
|
174
|
+
)
|
101
175
|
|
102
|
-
|
103
|
-
self.
|
176
|
+
run_uncertainty_action = QAction("Review Uncertain", self)
|
177
|
+
run_uncertainty_action.triggered.connect(self.find_uncertain_requested.emit)
|
178
|
+
self.find_uncertain_button.setDefaultAction(run_uncertainty_action)
|
179
|
+
|
180
|
+
uncertainty_settings_widget = UncertaintySettingsWidget()
|
181
|
+
uncertainty_menu = QMenu(self)
|
182
|
+
uncertainty_widget_action = QWidgetAction(uncertainty_menu)
|
183
|
+
uncertainty_widget_action.setDefaultWidget(uncertainty_settings_widget)
|
184
|
+
uncertainty_menu.addAction(uncertainty_widget_action)
|
185
|
+
self.find_uncertain_button.setMenu(uncertainty_menu)
|
186
|
+
|
187
|
+
uncertainty_settings_widget.parameters_changed.connect(self.uncertainty_parameters_changed.emit)
|
188
|
+
toolbar_layout.addWidget(self.find_uncertain_button)
|
189
|
+
|
190
|
+
toolbar_layout.addStretch()
|
191
|
+
|
192
|
+
# Home button to reset view
|
193
|
+
self.home_button = QPushButton()
|
194
|
+
self.home_button.setIcon(get_icon("home.png"))
|
104
195
|
self.home_button.setToolTip("Reset view to fit all points")
|
105
196
|
self.home_button.clicked.connect(self.reset_view)
|
106
|
-
|
107
|
-
|
108
|
-
layout.addLayout(
|
197
|
+
toolbar_layout.addWidget(self.home_button)
|
198
|
+
|
199
|
+
layout.addLayout(toolbar_layout)
|
109
200
|
layout.addWidget(self.graphics_view)
|
201
|
+
|
110
202
|
self.placeholder_label = QLabel(
|
111
203
|
"No embedding data available.\nPress 'Apply Embedding' to generate visualization."
|
112
204
|
)
|
113
205
|
self.placeholder_label.setAlignment(Qt.AlignCenter)
|
114
206
|
self.placeholder_label.setStyleSheet("color: gray; font-size: 14px;")
|
115
|
-
|
116
207
|
layout.addWidget(self.placeholder_label)
|
208
|
+
|
117
209
|
self.show_placeholder()
|
210
|
+
self._update_toolbar_state()
|
211
|
+
|
212
|
+
def _create_separator(self):
|
213
|
+
"""Creates a vertical separator for the toolbar."""
|
214
|
+
separator = QLabel("|")
|
215
|
+
separator.setStyleSheet("color: gray; margin: 0 5px;")
|
216
|
+
return separator
|
217
|
+
|
218
|
+
@pyqtSlot()
|
219
|
+
def isolate_selection(self):
|
220
|
+
"""Hides all points that are not currently selected."""
|
221
|
+
selected_items = self.graphics_scene.selectedItems()
|
222
|
+
if not selected_items or self.isolated_mode:
|
223
|
+
return
|
224
|
+
|
225
|
+
self.isolated_points = set(selected_items)
|
226
|
+
self.graphics_view.setUpdatesEnabled(False)
|
227
|
+
try:
|
228
|
+
for point in self.points_by_id.values():
|
229
|
+
if point not in self.isolated_points:
|
230
|
+
point.hide()
|
231
|
+
self.isolated_mode = True
|
232
|
+
finally:
|
233
|
+
self.graphics_view.setUpdatesEnabled(True)
|
234
|
+
|
235
|
+
self._update_toolbar_state()
|
236
|
+
|
237
|
+
@pyqtSlot()
|
238
|
+
def show_all_points(self):
|
239
|
+
"""Shows all embedding points, exiting isolated mode."""
|
240
|
+
if not self.isolated_mode:
|
241
|
+
return
|
242
|
+
|
243
|
+
self.isolated_mode = False
|
244
|
+
self.isolated_points.clear()
|
245
|
+
self.graphics_view.setUpdatesEnabled(False)
|
246
|
+
try:
|
247
|
+
for point in self.points_by_id.values():
|
248
|
+
point.show()
|
249
|
+
finally:
|
250
|
+
self.graphics_view.setUpdatesEnabled(True)
|
251
|
+
|
252
|
+
self._update_toolbar_state()
|
253
|
+
|
254
|
+
def _update_toolbar_state(self):
|
255
|
+
"""Updates toolbar buttons based on selection and isolation mode."""
|
256
|
+
selection_exists = bool(self.graphics_scene.selectedItems())
|
257
|
+
points_exist = bool(self.points_by_id)
|
258
|
+
|
259
|
+
self.find_mislabels_button.setEnabled(points_exist)
|
260
|
+
self.find_uncertain_button.setEnabled(points_exist and self.is_uncertainty_analysis_available)
|
261
|
+
|
262
|
+
if self.isolated_mode:
|
263
|
+
self.isolate_button.hide()
|
264
|
+
self.show_all_button.show()
|
265
|
+
else:
|
266
|
+
self.isolate_button.show()
|
267
|
+
self.show_all_button.hide()
|
268
|
+
self.isolate_button.setEnabled(selection_exists)
|
118
269
|
|
119
270
|
def reset_view(self):
|
120
271
|
"""Reset the view to fit all embedding points."""
|
@@ -125,85 +276,109 @@ class EmbeddingViewer(QWidget):
|
|
125
276
|
self.graphics_view.setVisible(False)
|
126
277
|
self.placeholder_label.setVisible(True)
|
127
278
|
self.home_button.setEnabled(False)
|
279
|
+
self.find_mislabels_button.setEnabled(False)
|
280
|
+
self.find_uncertain_button.setEnabled(False)
|
281
|
+
|
282
|
+
self.isolate_button.show()
|
283
|
+
self.isolate_button.setEnabled(False)
|
284
|
+
self.show_all_button.hide()
|
128
285
|
|
129
286
|
def show_embedding(self):
|
130
287
|
"""Show the graphics view and hide the placeholder message."""
|
131
288
|
self.graphics_view.setVisible(True)
|
132
289
|
self.placeholder_label.setVisible(False)
|
133
290
|
self.home_button.setEnabled(True)
|
291
|
+
self._update_toolbar_state()
|
134
292
|
|
135
293
|
# Delegate graphics view methods
|
136
|
-
def setRenderHint(self, hint):
|
294
|
+
def setRenderHint(self, hint):
|
137
295
|
"""Set render hint for the graphics view."""
|
138
296
|
self.graphics_view.setRenderHint(hint)
|
139
|
-
|
140
|
-
def setDragMode(self, mode):
|
297
|
+
|
298
|
+
def setDragMode(self, mode):
|
141
299
|
"""Set drag mode for the graphics view."""
|
142
300
|
self.graphics_view.setDragMode(mode)
|
143
|
-
|
144
|
-
def setTransformationAnchor(self, anchor):
|
301
|
+
|
302
|
+
def setTransformationAnchor(self, anchor):
|
145
303
|
"""Set transformation anchor for the graphics view."""
|
146
304
|
self.graphics_view.setTransformationAnchor(anchor)
|
147
|
-
|
148
|
-
def setResizeAnchor(self, anchor):
|
305
|
+
|
306
|
+
def setResizeAnchor(self, anchor):
|
149
307
|
"""Set resize anchor for the graphics view."""
|
150
308
|
self.graphics_view.setResizeAnchor(anchor)
|
151
|
-
|
152
|
-
def mapToScene(self, point):
|
309
|
+
|
310
|
+
def mapToScene(self, point):
|
153
311
|
"""Map a point to the scene coordinates."""
|
154
312
|
return self.graphics_view.mapToScene(point)
|
155
|
-
|
156
|
-
def scale(self, sx, sy):
|
313
|
+
|
314
|
+
def scale(self, sx, sy):
|
157
315
|
"""Scale the graphics view."""
|
158
316
|
self.graphics_view.scale(sx, sy)
|
159
|
-
|
160
|
-
def translate(self, dx, dy):
|
317
|
+
|
318
|
+
def translate(self, dx, dy):
|
161
319
|
"""Translate the graphics view."""
|
162
320
|
self.graphics_view.translate(dx, dy)
|
163
|
-
|
164
|
-
def fitInView(self, rect, aspect_ratio):
|
321
|
+
|
322
|
+
def fitInView(self, rect, aspect_ratio):
|
165
323
|
"""Fit the view to a rectangle with aspect ratio."""
|
166
324
|
self.graphics_view.fitInView(rect, aspect_ratio)
|
167
|
-
|
325
|
+
|
168
326
|
def keyPressEvent(self, event):
|
169
327
|
"""Handles key presses for deleting selected points."""
|
170
|
-
# Check if the pressed key is Delete/Backspace AND the Control key is held down
|
171
328
|
if event.key() in (Qt.Key_Delete, Qt.Key_Backspace) and event.modifiers() == Qt.ControlModifier:
|
172
|
-
# Get the currently selected items from the graphics scene
|
173
329
|
selected_items = self.graphics_scene.selectedItems()
|
174
|
-
|
175
330
|
if not selected_items:
|
176
331
|
super().keyPressEvent(event)
|
177
332
|
return
|
178
333
|
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
if isinstance(item, EmbeddingPointItem):
|
184
|
-
# Mark the central data item for deletion
|
185
|
-
item.data_item.mark_for_deletion()
|
186
|
-
|
187
|
-
# Remove the point from our internal lookup
|
188
|
-
ann_id = item.data_item.annotation.id
|
189
|
-
if ann_id in self.points_by_id:
|
190
|
-
del self.points_by_id[ann_id]
|
191
|
-
|
192
|
-
# Remove the point from the visual scene
|
193
|
-
self.graphics_scene.removeItem(item)
|
334
|
+
# Extract the central data items from the selected graphics points
|
335
|
+
data_items_to_delete = [
|
336
|
+
item.data_item for item in selected_items if isinstance(item, EmbeddingPointItem)
|
337
|
+
]
|
194
338
|
|
195
|
-
#
|
196
|
-
|
197
|
-
|
339
|
+
# Delegate the actual deletion to the main ExplorerWindow
|
340
|
+
if data_items_to_delete:
|
341
|
+
self.explorer_window.delete_data_items(data_items_to_delete)
|
198
342
|
|
199
|
-
# Accept the event to prevent it from being processed further
|
200
343
|
event.accept()
|
201
344
|
else:
|
202
|
-
# Pass any other key presses to the default handler
|
203
345
|
super().keyPressEvent(event)
|
204
346
|
|
205
347
|
def mousePressEvent(self, event):
|
206
348
|
"""Handle mouse press for selection (point or rubber band) and panning."""
|
349
|
+
# Ctrl+Right-Click for context menu selection
|
350
|
+
if event.button() == Qt.RightButton and event.modifiers() == Qt.ControlModifier:
|
351
|
+
item_at_pos = self.graphics_view.itemAt(event.pos())
|
352
|
+
if isinstance(item_at_pos, EmbeddingPointItem):
|
353
|
+
# 1. Clear all selections in both viewers
|
354
|
+
self.graphics_scene.clearSelection()
|
355
|
+
item_at_pos.setSelected(True)
|
356
|
+
self.on_selection_changed() # Updates internal state and emits signals
|
357
|
+
|
358
|
+
# 2. Sync annotation viewer selection
|
359
|
+
ann_id = item_at_pos.data_item.annotation.id
|
360
|
+
self.explorer_window.annotation_viewer.render_selection_from_ids({ann_id})
|
361
|
+
|
362
|
+
# 3. Update annotation window (set image, select, center)
|
363
|
+
explorer = self.explorer_window
|
364
|
+
annotation = item_at_pos.data_item.annotation
|
365
|
+
image_path = annotation.image_path
|
366
|
+
|
367
|
+
if hasattr(explorer, 'annotation_window'):
|
368
|
+
if explorer.annotation_window.current_image_path != image_path:
|
369
|
+
if hasattr(explorer.annotation_window, 'set_image'):
|
370
|
+
explorer.annotation_window.set_image(image_path)
|
371
|
+
if hasattr(explorer.annotation_window, 'select_annotation'):
|
372
|
+
explorer.annotation_window.select_annotation(annotation)
|
373
|
+
if hasattr(explorer.annotation_window, 'center_on_annotation'):
|
374
|
+
explorer.annotation_window.center_on_annotation(annotation)
|
375
|
+
|
376
|
+
explorer.update_label_window_selection()
|
377
|
+
explorer.update_button_states()
|
378
|
+
event.accept()
|
379
|
+
return
|
380
|
+
|
381
|
+
# Handle left-click for selection or rubber band
|
207
382
|
if event.button() == Qt.LeftButton and event.modifiers() == Qt.ControlModifier:
|
208
383
|
item_at_pos = self.graphics_view.itemAt(event.pos())
|
209
384
|
if isinstance(item_at_pos, EmbeddingPointItem):
|
@@ -230,7 +405,7 @@ class EmbeddingViewer(QWidget):
|
|
230
405
|
else:
|
231
406
|
self.graphics_view.setDragMode(QGraphicsView.NoDrag)
|
232
407
|
QGraphicsView.mousePressEvent(self.graphics_view, event)
|
233
|
-
|
408
|
+
|
234
409
|
def mouseDoubleClickEvent(self, event):
|
235
410
|
"""Handle double-click to clear selection and reset the main view."""
|
236
411
|
if event.button() == Qt.LeftButton:
|
@@ -281,7 +456,7 @@ class EmbeddingViewer(QWidget):
|
|
281
456
|
else:
|
282
457
|
QGraphicsView.mouseReleaseEvent(self.graphics_view, event)
|
283
458
|
self.graphics_view.setDragMode(QGraphicsView.NoDrag)
|
284
|
-
|
459
|
+
|
285
460
|
def wheelEvent(self, event):
|
286
461
|
"""Handle mouse wheel for zooming."""
|
287
462
|
zoom_in_factor = 1.25
|
@@ -308,88 +483,74 @@ class EmbeddingViewer(QWidget):
|
|
308
483
|
self.graphics_view.translate(delta.x(), delta.y())
|
309
484
|
|
310
485
|
def update_embeddings(self, data_items):
|
311
|
-
"""Update the embedding visualization. Creates an EmbeddingPointItem for
|
486
|
+
"""Update the embedding visualization. Creates an EmbeddingPointItem for
|
312
487
|
each AnnotationDataItem and links them."""
|
488
|
+
# Reset isolation state when loading new points
|
489
|
+
if self.isolated_mode:
|
490
|
+
self.show_all_points()
|
491
|
+
|
313
492
|
self.clear_points()
|
314
493
|
for item in data_items:
|
315
|
-
# Create the point item directly from the data_item.
|
316
|
-
# The item's constructor now handles setting position, flags, etc.
|
317
494
|
point = EmbeddingPointItem(item)
|
318
495
|
self.graphics_scene.addItem(point)
|
319
496
|
self.points_by_id[item.annotation.id] = point
|
320
|
-
|
321
|
-
def refresh_points(self):
|
322
|
-
"""Refreshes the points in the view to match the current state of the master data list."""
|
323
|
-
if not self.explorer_window or not self.explorer_window.current_data_items:
|
324
|
-
return
|
325
|
-
|
326
|
-
# Get the set of IDs for points currently in the scene
|
327
|
-
current_point_ids = set(self.points_by_id.keys())
|
328
497
|
|
329
|
-
#
|
330
|
-
|
498
|
+
# Ensure buttons are in the correct initial state
|
499
|
+
self._update_toolbar_state()
|
331
500
|
|
332
|
-
something_changed = False
|
333
|
-
for item in all_data_items:
|
334
|
-
# If a data item is NOT marked for deletion but is also NOT in the scene, add it back.
|
335
|
-
if not item.is_marked_for_deletion() and item.annotation.id not in current_point_ids:
|
336
|
-
point = EmbeddingPointItem(item)
|
337
|
-
self.graphics_scene.addItem(point)
|
338
|
-
self.points_by_id[item.annotation.id] = point
|
339
|
-
something_changed = True
|
340
|
-
|
341
|
-
if something_changed:
|
342
|
-
print("Refreshed embedding points to show reverted items.")
|
343
|
-
|
344
501
|
def clear_points(self):
|
345
502
|
"""Clear all embedding points from the scene."""
|
503
|
+
if self.isolated_mode:
|
504
|
+
self.show_all_points()
|
505
|
+
|
346
506
|
for point in self.points_by_id.values():
|
347
507
|
self.graphics_scene.removeItem(point)
|
348
508
|
self.points_by_id.clear()
|
509
|
+
self._update_toolbar_state()
|
349
510
|
|
350
511
|
def on_selection_changed(self):
|
351
512
|
"""
|
352
513
|
Handles selection changes in the scene. Updates the central data model
|
353
514
|
and emits a signal to notify other parts of the application.
|
354
515
|
"""
|
355
|
-
if not self.graphics_scene:
|
516
|
+
if not self.graphics_scene:
|
356
517
|
return
|
357
518
|
try:
|
358
519
|
selected_items = self.graphics_scene.selectedItems()
|
359
520
|
except RuntimeError:
|
360
521
|
return
|
361
|
-
|
522
|
+
|
362
523
|
current_selection_ids = {item.data_item.annotation.id for item in selected_items}
|
363
524
|
|
364
525
|
if current_selection_ids != self.previous_selection_ids:
|
365
|
-
# Update the central model (AnnotationDataItem) for all points
|
366
526
|
for point_id, point in self.points_by_id.items():
|
367
527
|
is_selected = point_id in current_selection_ids
|
368
|
-
# The data_item is the single source of truth
|
369
528
|
point.data_item.set_selected(is_selected)
|
370
529
|
|
371
530
|
self.selection_changed.emit(list(current_selection_ids))
|
372
531
|
self.previous_selection_ids = current_selection_ids
|
373
532
|
|
374
|
-
|
375
|
-
if hasattr(self, 'animation_timer') and self.animation_timer:
|
533
|
+
if hasattr(self, 'animation_timer') and self.animation_timer:
|
376
534
|
self.animation_timer.stop()
|
377
|
-
|
535
|
+
|
378
536
|
for point in self.points_by_id.values():
|
379
537
|
if not point.isSelected():
|
380
538
|
point.setPen(QPen(QColor("black"), POINT_WIDTH))
|
381
539
|
if selected_items and hasattr(self, 'animation_timer') and self.animation_timer:
|
382
540
|
self.animation_timer.start()
|
383
541
|
|
542
|
+
# Update button states based on new selection
|
543
|
+
self._update_toolbar_state()
|
544
|
+
|
384
545
|
def animate_selection(self):
|
385
546
|
"""Animate selected points with a marching ants effect."""
|
386
|
-
if not self.graphics_scene:
|
547
|
+
if not self.graphics_scene:
|
387
548
|
return
|
388
549
|
try:
|
389
550
|
selected_items = self.graphics_scene.selectedItems()
|
390
551
|
except RuntimeError:
|
391
552
|
return
|
392
|
-
|
553
|
+
|
393
554
|
self.animation_offset = (self.animation_offset + 1) % 20
|
394
555
|
for item in selected_items:
|
395
556
|
# Get the color directly from the source of truth
|
@@ -400,23 +561,23 @@ class EmbeddingViewer(QWidget):
|
|
400
561
|
animated_pen.setDashPattern([1, 2])
|
401
562
|
animated_pen.setDashOffset(self.animation_offset)
|
402
563
|
item.setPen(animated_pen)
|
403
|
-
|
564
|
+
|
404
565
|
def render_selection_from_ids(self, selected_ids):
|
405
566
|
"""
|
406
567
|
Updates the visual selection of points based on a set of annotation IDs
|
407
568
|
provided by an external controller.
|
408
569
|
"""
|
409
570
|
blocker = QSignalBlocker(self.graphics_scene)
|
410
|
-
|
571
|
+
|
411
572
|
for ann_id, point in self.points_by_id.items():
|
412
573
|
is_selected = ann_id in selected_ids
|
413
574
|
# 1. Update the state on the central data item
|
414
575
|
point.data_item.set_selected(is_selected)
|
415
576
|
# 2. Update the selection state of the graphics item itself
|
416
577
|
point.setSelected(is_selected)
|
417
|
-
|
578
|
+
|
418
579
|
blocker.unblock()
|
419
|
-
|
580
|
+
|
420
581
|
# Manually trigger on_selection_changed to update animation and emit signals
|
421
582
|
self.on_selection_changed()
|
422
583
|
|
@@ -426,20 +587,21 @@ class EmbeddingViewer(QWidget):
|
|
426
587
|
self.graphics_view.fitInView(self.graphics_scene.itemsBoundingRect(), Qt.KeepAspectRatio)
|
427
588
|
else:
|
428
589
|
self.graphics_view.fitInView(-2500, -2500, 5000, 5000, Qt.KeepAspectRatio)
|
429
|
-
|
590
|
+
|
430
591
|
|
431
592
|
class AnnotationViewer(QScrollArea):
|
432
|
-
"""Scrollable grid widget for displaying annotation image crops with selection,
|
593
|
+
"""Scrollable grid widget for displaying annotation image crops with selection,
|
433
594
|
filtering, and isolation support. Acts as a controller for the widgets."""
|
434
595
|
selection_changed = pyqtSignal(list)
|
435
596
|
preview_changed = pyqtSignal(list)
|
436
597
|
reset_view_requested = pyqtSignal()
|
598
|
+
find_similar_requested = pyqtSignal()
|
437
599
|
|
438
600
|
def __init__(self, parent=None):
|
439
601
|
"""Initialize the AnnotationViewer widget."""
|
440
602
|
super(AnnotationViewer, self).__init__(parent)
|
441
603
|
self.explorer_window = parent
|
442
|
-
|
604
|
+
|
443
605
|
self.annotation_widgets_by_id = {}
|
444
606
|
self.selected_widgets = []
|
445
607
|
self.last_selected_index = -1
|
@@ -453,6 +615,11 @@ class AnnotationViewer(QScrollArea):
|
|
453
615
|
self.original_label_assignments = {}
|
454
616
|
self.isolated_mode = False
|
455
617
|
self.isolated_widgets = set()
|
618
|
+
|
619
|
+
# State for new sorting options
|
620
|
+
self.active_ordered_ids = []
|
621
|
+
self.is_confidence_sort_available = False
|
622
|
+
|
456
623
|
self.setup_ui()
|
457
624
|
|
458
625
|
def setup_ui(self):
|
@@ -485,9 +652,35 @@ class AnnotationViewer(QScrollArea):
|
|
485
652
|
sort_label = QLabel("Sort By:")
|
486
653
|
toolbar_layout.addWidget(sort_label)
|
487
654
|
self.sort_combo = QComboBox()
|
488
|
-
|
655
|
+
# Remove "Similarity" as it's now an implicit action
|
656
|
+
self.sort_combo.addItems(["None", "Label", "Image", "Confidence"])
|
657
|
+
self.sort_combo.insertSeparator(3) # Add separator before "Confidence"
|
489
658
|
self.sort_combo.currentTextChanged.connect(self.on_sort_changed)
|
490
659
|
toolbar_layout.addWidget(self.sort_combo)
|
660
|
+
|
661
|
+
toolbar_layout.addWidget(self._create_separator())
|
662
|
+
|
663
|
+
self.find_similar_button = QToolButton()
|
664
|
+
self.find_similar_button.setText("Find Similar")
|
665
|
+
self.find_similar_button.setToolTip("Find annotations visually similar to the selection.")
|
666
|
+
self.find_similar_button.setPopupMode(QToolButton.MenuButtonPopup)
|
667
|
+
self.find_similar_button.setToolButtonStyle(Qt.ToolButtonTextOnly)
|
668
|
+
self.find_similar_button.setStyleSheet(
|
669
|
+
"QToolButton::menu-indicator { subcontrol-position: right center; subcontrol-origin: padding; left: -4px; }"
|
670
|
+
)
|
671
|
+
|
672
|
+
run_similar_action = QAction("Find Similar", self)
|
673
|
+
run_similar_action.triggered.connect(self.find_similar_requested.emit)
|
674
|
+
self.find_similar_button.setDefaultAction(run_similar_action)
|
675
|
+
|
676
|
+
self.similarity_settings_widget = SimilaritySettingsWidget()
|
677
|
+
settings_menu = QMenu(self)
|
678
|
+
widget_action = QWidgetAction(settings_menu)
|
679
|
+
widget_action.setDefaultWidget(self.similarity_settings_widget)
|
680
|
+
settings_menu.addAction(widget_action)
|
681
|
+
self.find_similar_button.setMenu(settings_menu)
|
682
|
+
toolbar_layout.addWidget(self.find_similar_button)
|
683
|
+
|
491
684
|
toolbar_layout.addStretch()
|
492
685
|
|
493
686
|
size_label = QLabel("Size:")
|
@@ -515,7 +708,61 @@ class AnnotationViewer(QScrollArea):
|
|
515
708
|
|
516
709
|
main_layout.addWidget(content_scroll)
|
517
710
|
self.setWidget(main_container)
|
711
|
+
|
712
|
+
# Set the initial state of the sort options
|
713
|
+
self._update_sort_options_state()
|
518
714
|
self._update_toolbar_state()
|
715
|
+
|
716
|
+
def _create_separator(self):
|
717
|
+
"""Creates a vertical separator for the toolbar."""
|
718
|
+
separator = QLabel("|")
|
719
|
+
separator.setStyleSheet("color: gray; margin: 0 5px;")
|
720
|
+
return separator
|
721
|
+
|
722
|
+
def _update_sort_options_state(self):
|
723
|
+
"""Enable/disable sort options based on available data."""
|
724
|
+
model = self.sort_combo.model()
|
725
|
+
|
726
|
+
# Enable/disable "Confidence" option
|
727
|
+
confidence_item_index = self.sort_combo.findText("Confidence")
|
728
|
+
if confidence_item_index != -1:
|
729
|
+
model.item(confidence_item_index).setEnabled(self.is_confidence_sort_available)
|
730
|
+
|
731
|
+
def handle_annotation_context_menu(self, widget, event):
|
732
|
+
"""Handle context menu requests (e.g., right-click) on an annotation widget."""
|
733
|
+
if event.modifiers() == Qt.ControlModifier:
|
734
|
+
explorer = self.explorer_window
|
735
|
+
image_path = widget.annotation.image_path
|
736
|
+
annotation_to_select = widget.annotation
|
737
|
+
|
738
|
+
# ctrl+right click to only select this annotation (single selection):
|
739
|
+
self.clear_selection()
|
740
|
+
self.select_widget(widget)
|
741
|
+
changed_ids = [widget.data_item.annotation.id]
|
742
|
+
|
743
|
+
if changed_ids:
|
744
|
+
self.selection_changed.emit(changed_ids)
|
745
|
+
|
746
|
+
if hasattr(explorer, 'annotation_window'):
|
747
|
+
# Check if the image needs to be changed
|
748
|
+
if explorer.annotation_window.current_image_path != image_path:
|
749
|
+
if hasattr(explorer.annotation_window, 'set_image'):
|
750
|
+
explorer.annotation_window.set_image(image_path)
|
751
|
+
|
752
|
+
# Now, select the annotation in the annotation_window
|
753
|
+
if hasattr(explorer.annotation_window, 'select_annotation'):
|
754
|
+
explorer.annotation_window.select_annotation(annotation_to_select)
|
755
|
+
|
756
|
+
# Center the annotation window view on the selected annotation
|
757
|
+
if hasattr(explorer.annotation_window, 'center_on_annotation'):
|
758
|
+
explorer.annotation_window.center_on_annotation(annotation_to_select)
|
759
|
+
|
760
|
+
# Also clear any existing selection in the explorer window itself
|
761
|
+
explorer.embedding_viewer.render_selection_from_ids({widget.data_item.annotation.id})
|
762
|
+
explorer.update_label_window_selection()
|
763
|
+
explorer.update_button_states()
|
764
|
+
|
765
|
+
event.accept()
|
519
766
|
|
520
767
|
@pyqtSlot()
|
521
768
|
def isolate_selection(self):
|
@@ -537,23 +784,56 @@ class AnnotationViewer(QScrollArea):
|
|
537
784
|
self._update_toolbar_state()
|
538
785
|
self.explorer_window.main_window.label_window.update_annotation_count()
|
539
786
|
|
787
|
+
def display_and_isolate_ordered_results(self, ordered_ids):
|
788
|
+
"""
|
789
|
+
Isolates the view to a specific set of ordered widgets, ensuring the
|
790
|
+
grid is always updated. This is the new primary method for showing
|
791
|
+
similarity results.
|
792
|
+
"""
|
793
|
+
self.active_ordered_ids = ordered_ids
|
794
|
+
|
795
|
+
# Render the selection based on the new order
|
796
|
+
self.render_selection_from_ids(set(ordered_ids))
|
797
|
+
|
798
|
+
# Now, perform the isolation logic directly to bypass the guard clause
|
799
|
+
self.isolated_widgets = set(self.selected_widgets)
|
800
|
+
self.content_widget.setUpdatesEnabled(False)
|
801
|
+
try:
|
802
|
+
for widget in self.annotation_widgets_by_id.values():
|
803
|
+
# Show widget if it's in our target set, hide otherwise
|
804
|
+
if widget in self.isolated_widgets:
|
805
|
+
widget.show()
|
806
|
+
else:
|
807
|
+
widget.hide()
|
808
|
+
|
809
|
+
self.isolated_mode = True
|
810
|
+
self.recalculate_widget_positions() # Crucial grid update
|
811
|
+
finally:
|
812
|
+
self.content_widget.setUpdatesEnabled(True)
|
813
|
+
|
814
|
+
self._update_toolbar_state()
|
815
|
+
self.explorer_window.main_window.label_window.update_annotation_count()
|
816
|
+
|
540
817
|
@pyqtSlot()
|
541
818
|
def show_all_annotations(self):
|
542
819
|
"""Shows all annotation widgets, exiting the isolated mode."""
|
543
820
|
if not self.isolated_mode:
|
544
821
|
return
|
545
|
-
|
822
|
+
|
546
823
|
self.isolated_mode = False
|
547
824
|
self.isolated_widgets.clear()
|
548
|
-
|
825
|
+
self.active_ordered_ids = [] # Clear similarity sort context
|
826
|
+
|
549
827
|
self.content_widget.setUpdatesEnabled(False)
|
550
828
|
try:
|
829
|
+
# Show all widgets that are managed by the viewer
|
551
830
|
for widget in self.annotation_widgets_by_id.values():
|
552
831
|
widget.show()
|
832
|
+
|
553
833
|
self.recalculate_widget_positions()
|
554
834
|
finally:
|
555
835
|
self.content_widget.setUpdatesEnabled(True)
|
556
|
-
|
836
|
+
|
557
837
|
self._update_toolbar_state()
|
558
838
|
self.explorer_window.main_window.label_window.update_annotation_count()
|
559
839
|
|
@@ -569,31 +849,47 @@ class AnnotationViewer(QScrollArea):
|
|
569
849
|
self.show_all_button.hide()
|
570
850
|
self.isolate_button.setEnabled(selection_exists)
|
571
851
|
|
572
|
-
def _create_separator(self):
|
573
|
-
"""Creates a vertical separator for the toolbar."""
|
574
|
-
separator = QLabel("|")
|
575
|
-
separator.setStyleSheet("color: gray; margin: 0 5px;")
|
576
|
-
return separator
|
577
|
-
|
578
852
|
def on_sort_changed(self, sort_type):
|
579
853
|
"""Handle sort type change."""
|
854
|
+
self.active_ordered_ids = [] # Clear any special ordering
|
580
855
|
self.recalculate_widget_positions()
|
581
856
|
|
857
|
+
def set_confidence_sort_availability(self, is_available):
|
858
|
+
"""Sets the availability of the confidence sort option."""
|
859
|
+
self.is_confidence_sort_available = is_available
|
860
|
+
self._update_sort_options_state()
|
861
|
+
|
582
862
|
def _get_sorted_widgets(self):
|
583
863
|
"""Get widgets sorted according to the current sort setting."""
|
864
|
+
# If a specific order is active (e.g., from similarity search), use it.
|
865
|
+
if self.active_ordered_ids:
|
866
|
+
widget_map = {w.data_item.annotation.id: w for w in self.annotation_widgets_by_id.values()}
|
867
|
+
ordered_widgets = [widget_map[ann_id] for ann_id in self.active_ordered_ids if ann_id in widget_map]
|
868
|
+
return ordered_widgets
|
869
|
+
|
870
|
+
# Otherwise, use the dropdown sort logic
|
584
871
|
sort_type = self.sort_combo.currentText()
|
585
872
|
widgets = list(self.annotation_widgets_by_id.values())
|
873
|
+
|
586
874
|
if sort_type == "Label":
|
587
875
|
widgets.sort(key=lambda w: w.data_item.effective_label.short_label_code)
|
588
876
|
elif sort_type == "Image":
|
589
877
|
widgets.sort(key=lambda w: os.path.basename(w.data_item.annotation.image_path))
|
878
|
+
elif sort_type == "Confidence":
|
879
|
+
# Sort by confidence, descending. Handles cases with no confidence gracefully.
|
880
|
+
widgets.sort(key=lambda w: w.data_item.get_effective_confidence(), reverse=True)
|
881
|
+
|
590
882
|
return widgets
|
591
883
|
|
592
884
|
def _group_widgets_by_sort_key(self, widgets):
|
593
885
|
"""Group widgets by the current sort key."""
|
594
886
|
sort_type = self.sort_combo.currentText()
|
595
|
-
if sort_type == "None":
|
887
|
+
if not self.active_ordered_ids and sort_type == "None":
|
888
|
+
return [("", widgets)]
|
889
|
+
|
890
|
+
if self.active_ordered_ids: # Don't show group headers for similarity results
|
596
891
|
return [("", widgets)]
|
892
|
+
|
597
893
|
groups = []
|
598
894
|
current_group = []
|
599
895
|
current_key = None
|
@@ -603,8 +899,9 @@ class AnnotationViewer(QScrollArea):
|
|
603
899
|
elif sort_type == "Image":
|
604
900
|
key = os.path.basename(widget.data_item.annotation.image_path)
|
605
901
|
else:
|
606
|
-
key = ""
|
607
|
-
|
902
|
+
key = "" # No headers for Confidence or None
|
903
|
+
|
904
|
+
if key and current_key != key:
|
608
905
|
if current_group:
|
609
906
|
groups.append((current_key, current_group))
|
610
907
|
current_group = [widget]
|
@@ -648,16 +945,16 @@ class AnnotationViewer(QScrollArea):
|
|
648
945
|
|
649
946
|
def on_size_changed(self, value):
|
650
947
|
"""Handle slider value change to resize annotation widgets."""
|
651
|
-
if value % 2 != 0:
|
948
|
+
if value % 2 != 0:
|
652
949
|
value -= 1
|
653
|
-
|
950
|
+
|
654
951
|
self.current_widget_size = value
|
655
952
|
self.size_value_label.setText(str(value))
|
656
953
|
self.content_widget.setUpdatesEnabled(False)
|
657
|
-
|
954
|
+
|
658
955
|
for widget in self.annotation_widgets_by_id.values():
|
659
956
|
widget.update_height(value)
|
660
|
-
|
957
|
+
|
661
958
|
self.content_widget.setUpdatesEnabled(True)
|
662
959
|
self.recalculate_widget_positions()
|
663
960
|
|
@@ -692,7 +989,7 @@ class AnnotationViewer(QScrollArea):
|
|
692
989
|
y += header_label.height() + spacing
|
693
990
|
x = spacing
|
694
991
|
max_height_in_row = 0
|
695
|
-
|
992
|
+
|
696
993
|
for widget in group_widgets:
|
697
994
|
widget_size = widget.size()
|
698
995
|
if x > spacing and x + widget_size.width() > available_width:
|
@@ -710,24 +1007,26 @@ class AnnotationViewer(QScrollArea):
|
|
710
1007
|
"""Update displayed annotations, creating new widgets for them."""
|
711
1008
|
if self.isolated_mode:
|
712
1009
|
self.show_all_annotations()
|
713
|
-
|
1010
|
+
|
714
1011
|
for widget in self.annotation_widgets_by_id.values():
|
715
1012
|
widget.setParent(None)
|
716
1013
|
widget.deleteLater()
|
717
|
-
|
1014
|
+
|
718
1015
|
self.annotation_widgets_by_id.clear()
|
719
1016
|
self.selected_widgets.clear()
|
720
1017
|
self.last_selected_index = -1
|
721
|
-
|
1018
|
+
|
722
1019
|
for data_item in data_items:
|
723
1020
|
annotation_widget = AnnotationImageWidget(
|
724
1021
|
data_item, self.current_widget_size, self, self.content_widget)
|
725
|
-
|
1022
|
+
|
726
1023
|
annotation_widget.show()
|
727
1024
|
self.annotation_widgets_by_id[data_item.annotation.id] = annotation_widget
|
728
|
-
|
1025
|
+
|
729
1026
|
self.recalculate_widget_positions()
|
730
1027
|
self._update_toolbar_state()
|
1028
|
+
# Update the label window with the new annotation count
|
1029
|
+
self.explorer_window.main_window.label_window.update_annotation_count()
|
731
1030
|
|
732
1031
|
def resizeEvent(self, event):
|
733
1032
|
"""On window resize, reflow the annotation widgets."""
|
@@ -737,42 +1036,23 @@ class AnnotationViewer(QScrollArea):
|
|
737
1036
|
self._resize_timer.setSingleShot(True)
|
738
1037
|
self._resize_timer.timeout.connect(self.recalculate_widget_positions)
|
739
1038
|
self._resize_timer.start(100)
|
740
|
-
|
1039
|
+
|
741
1040
|
def keyPressEvent(self, event):
|
742
1041
|
"""Handles key presses for deleting selected annotations."""
|
743
|
-
# Check if the pressed key is Delete/Backspace AND the Control key is held down
|
744
1042
|
if event.key() in (Qt.Key_Delete, Qt.Key_Backspace) and event.modifiers() == Qt.ControlModifier:
|
745
|
-
# Proceed only if there are selected widgets
|
746
1043
|
if not self.selected_widgets:
|
747
1044
|
super().keyPressEvent(event)
|
748
1045
|
return
|
749
1046
|
|
750
|
-
|
751
|
-
|
752
|
-
# Keep track of which annotations were affected
|
753
|
-
changed_ids = []
|
754
|
-
|
755
|
-
# Mark each selected item for deletion and hide it
|
756
|
-
for widget in self.selected_widgets:
|
757
|
-
widget.data_item.mark_for_deletion()
|
758
|
-
widget.hide()
|
759
|
-
changed_ids.append(widget.data_item.annotation.id)
|
760
|
-
|
761
|
-
# Clear the list of selected widgets
|
762
|
-
self.selected_widgets.clear()
|
763
|
-
|
764
|
-
# Recalculate the layout to fill in the empty space
|
765
|
-
self.recalculate_widget_positions()
|
1047
|
+
# Extract the central data items from the selected widgets
|
1048
|
+
data_items_to_delete = [widget.data_item for widget in self.selected_widgets]
|
766
1049
|
|
767
|
-
#
|
768
|
-
|
769
|
-
|
770
|
-
self.selection_changed.emit(changed_ids)
|
1050
|
+
# Delegate the actual deletion to the main ExplorerWindow
|
1051
|
+
if data_items_to_delete:
|
1052
|
+
self.explorer_window.delete_data_items(data_items_to_delete)
|
771
1053
|
|
772
|
-
# Accept the event to prevent it from being processed further
|
773
1054
|
event.accept()
|
774
1055
|
else:
|
775
|
-
# Pass any other key presses to the default handler
|
776
1056
|
super().keyPressEvent(event)
|
777
1057
|
|
778
1058
|
def mousePressEvent(self, event):
|
@@ -782,7 +1062,7 @@ class AnnotationViewer(QScrollArea):
|
|
782
1062
|
# If left click with no modifiers, check if click is outside widgets
|
783
1063
|
is_on_widget = False
|
784
1064
|
child_at_pos = self.childAt(event.pos())
|
785
|
-
|
1065
|
+
|
786
1066
|
if child_at_pos:
|
787
1067
|
widget = child_at_pos
|
788
1068
|
# Traverse up the parent chain to see if click is on an annotation widget
|
@@ -791,14 +1071,20 @@ class AnnotationViewer(QScrollArea):
|
|
791
1071
|
is_on_widget = True
|
792
1072
|
break
|
793
1073
|
widget = widget.parent()
|
794
|
-
|
795
|
-
# If click is outside widgets
|
796
|
-
if not is_on_widget
|
797
|
-
|
798
|
-
self.
|
799
|
-
|
1074
|
+
|
1075
|
+
# If click is outside widgets, clear annotation_window selection
|
1076
|
+
if not is_on_widget:
|
1077
|
+
# Clear annotation selection in the annotation_window as well
|
1078
|
+
if hasattr(self.explorer_window, 'annotation_window') and self.explorer_window.annotation_window:
|
1079
|
+
if hasattr(self.explorer_window.annotation_window, 'unselect_annotations'):
|
1080
|
+
self.explorer_window.annotation_window.unselect_annotations()
|
1081
|
+
# If there is a selection in the viewer, clear it
|
1082
|
+
if self.selected_widgets:
|
1083
|
+
changed_ids = [w.data_item.annotation.id for w in self.selected_widgets]
|
1084
|
+
self.clear_selection()
|
1085
|
+
self.selection_changed.emit(changed_ids)
|
800
1086
|
return
|
801
|
-
|
1087
|
+
|
802
1088
|
elif event.modifiers() == Qt.ControlModifier:
|
803
1089
|
# Start rubber band selection with Ctrl+Left click
|
804
1090
|
self.selection_at_press = set(self.selected_widgets)
|
@@ -814,12 +1100,12 @@ class AnnotationViewer(QScrollArea):
|
|
814
1100
|
break
|
815
1101
|
widget = widget.parent()
|
816
1102
|
return
|
817
|
-
|
1103
|
+
|
818
1104
|
elif event.button() == Qt.RightButton:
|
819
1105
|
# Ignore right clicks
|
820
1106
|
event.ignore()
|
821
1107
|
return
|
822
|
-
|
1108
|
+
|
823
1109
|
# Default handler for other cases
|
824
1110
|
super().mousePressEvent(event)
|
825
1111
|
|
@@ -901,13 +1187,13 @@ class AnnotationViewer(QScrollArea):
|
|
901
1187
|
self.rubber_band.hide()
|
902
1188
|
self.rubber_band.deleteLater()
|
903
1189
|
self.rubber_band = None
|
904
|
-
|
1190
|
+
|
905
1191
|
self.selection_at_press = set()
|
906
1192
|
self.rubber_band_origin = None
|
907
1193
|
self.mouse_pressed_on_widget = False
|
908
1194
|
event.accept()
|
909
1195
|
return
|
910
|
-
|
1196
|
+
|
911
1197
|
super().mouseReleaseEvent(event)
|
912
1198
|
|
913
1199
|
def handle_annotation_selection(self, widget, event):
|
@@ -938,14 +1224,14 @@ class AnnotationViewer(QScrollArea):
|
|
938
1224
|
last_selected_widget = w
|
939
1225
|
except ValueError:
|
940
1226
|
continue
|
941
|
-
|
1227
|
+
|
942
1228
|
if last_selected_widget:
|
943
1229
|
last_selected_index_in_current_list = widget_list.index(last_selected_widget)
|
944
1230
|
start = min(last_selected_index_in_current_list, widget_index)
|
945
1231
|
end = max(last_selected_index_in_current_list, widget_index)
|
946
1232
|
else:
|
947
1233
|
start, end = widget_index, widget_index
|
948
|
-
|
1234
|
+
|
949
1235
|
# Select all widgets in the range
|
950
1236
|
for i in range(start, end + 1):
|
951
1237
|
if self.select_widget(widget_list[i]):
|
@@ -969,13 +1255,13 @@ class AnnotationViewer(QScrollArea):
|
|
969
1255
|
# No modifier: single selection
|
970
1256
|
else:
|
971
1257
|
newly_selected_id = widget.data_item.annotation.id
|
972
|
-
|
1258
|
+
|
973
1259
|
# Deselect all others
|
974
1260
|
for w in list(self.selected_widgets):
|
975
1261
|
if w.data_item.annotation.id != newly_selected_id:
|
976
1262
|
if self.deselect_widget(w):
|
977
1263
|
changed_ids.append(w.data_item.annotation.id)
|
978
|
-
|
1264
|
+
|
979
1265
|
# Select the clicked widget
|
980
1266
|
if self.select_widget(widget):
|
981
1267
|
changed_ids.append(newly_selected_id)
|
@@ -991,7 +1277,7 @@ class AnnotationViewer(QScrollArea):
|
|
991
1277
|
|
992
1278
|
def _update_isolation(self):
|
993
1279
|
"""Update the isolated view to show only currently selected widgets."""
|
994
|
-
if not self.isolated_mode:
|
1280
|
+
if not self.isolated_mode:
|
995
1281
|
return
|
996
1282
|
# If in isolated mode, only show selected widgets
|
997
1283
|
if self.selected_widgets:
|
@@ -999,12 +1285,12 @@ class AnnotationViewer(QScrollArea):
|
|
999
1285
|
self.setUpdatesEnabled(False)
|
1000
1286
|
try:
|
1001
1287
|
for widget in self.annotation_widgets_by_id.values():
|
1002
|
-
if widget not in self.isolated_widgets:
|
1288
|
+
if widget not in self.isolated_widgets:
|
1003
1289
|
widget.hide()
|
1004
|
-
else:
|
1290
|
+
else:
|
1005
1291
|
widget.show()
|
1006
1292
|
self.recalculate_widget_positions()
|
1007
|
-
|
1293
|
+
|
1008
1294
|
finally:
|
1009
1295
|
self.setUpdatesEnabled(True)
|
1010
1296
|
|
@@ -1038,7 +1324,7 @@ class AnnotationViewer(QScrollArea):
|
|
1038
1324
|
for widget in list(self.selected_widgets):
|
1039
1325
|
# This will internally call deselect_widget, which is fine
|
1040
1326
|
self.deselect_widget(widget)
|
1041
|
-
|
1327
|
+
|
1042
1328
|
self.selected_widgets.clear()
|
1043
1329
|
self._update_toolbar_state()
|
1044
1330
|
|
@@ -1056,10 +1342,10 @@ class AnnotationViewer(QScrollArea):
|
|
1056
1342
|
widget.data_item.set_selected(is_selected)
|
1057
1343
|
# 2. Tell the widget to update its visuals based on the new state
|
1058
1344
|
widget.update_selection_visuals()
|
1059
|
-
|
1345
|
+
|
1060
1346
|
# Resync internal list of selected widgets from the source of truth
|
1061
1347
|
self.selected_widgets = [w for w in self.annotation_widgets_by_id.values() if w.is_selected()]
|
1062
|
-
|
1348
|
+
|
1063
1349
|
if self.isolated_mode and self.selected_widgets:
|
1064
1350
|
self.isolated_widgets.update(self.selected_widgets)
|
1065
1351
|
for widget in self.annotation_widgets_by_id.values():
|
@@ -1071,23 +1357,23 @@ class AnnotationViewer(QScrollArea):
|
|
1071
1357
|
|
1072
1358
|
def apply_preview_label_to_selected(self, preview_label):
|
1073
1359
|
"""Apply a preview label and emit a signal for the embedding view to update."""
|
1074
|
-
if not self.selected_widgets or not preview_label:
|
1360
|
+
if not self.selected_widgets or not preview_label:
|
1075
1361
|
return
|
1076
1362
|
changed_ids = []
|
1077
1363
|
for widget in self.selected_widgets:
|
1078
1364
|
widget.data_item.set_preview_label(preview_label)
|
1079
1365
|
widget.update() # Force repaint with new color
|
1080
1366
|
changed_ids.append(widget.data_item.annotation.id)
|
1081
|
-
|
1082
|
-
if self.sort_combo.currentText() == "Label":
|
1367
|
+
|
1368
|
+
if self.sort_combo.currentText() == "Label":
|
1083
1369
|
self.recalculate_widget_positions()
|
1084
|
-
if changed_ids:
|
1370
|
+
if changed_ids:
|
1085
1371
|
self.preview_changed.emit(changed_ids)
|
1086
1372
|
|
1087
1373
|
def clear_preview_states(self):
|
1088
1374
|
"""
|
1089
|
-
Clears all preview states, including label changes
|
1090
|
-
|
1375
|
+
Clears all preview states, including label changes,
|
1376
|
+
reverting them to their original state.
|
1091
1377
|
"""
|
1092
1378
|
something_changed = False
|
1093
1379
|
for widget in self.annotation_widgets_by_id.values():
|
@@ -1097,12 +1383,6 @@ class AnnotationViewer(QScrollArea):
|
|
1097
1383
|
widget.update() # Repaint to show original color
|
1098
1384
|
something_changed = True
|
1099
1385
|
|
1100
|
-
# Check for and un-mark items for deletion
|
1101
|
-
if widget.data_item.is_marked_for_deletion():
|
1102
|
-
widget.data_item.unmark_for_deletion()
|
1103
|
-
widget.show() # Make the widget visible again
|
1104
|
-
something_changed = True
|
1105
|
-
|
1106
1386
|
if something_changed:
|
1107
1387
|
# Recalculate positions to update sorting and re-flow the layout
|
1108
1388
|
if self.sort_combo.currentText() in ("Label", "Image"):
|
@@ -1141,11 +1421,21 @@ class ExplorerWindow(QMainWindow):
|
|
1141
1421
|
self.annotation_window = main_window.annotation_window
|
1142
1422
|
|
1143
1423
|
self.device = main_window.device
|
1144
|
-
self.model_path = ""
|
1145
1424
|
self.loaded_model = None
|
1425
|
+
|
1426
|
+
self.feature_store = FeatureStore()
|
1427
|
+
|
1428
|
+
# Add a property to store the parameters with defaults
|
1429
|
+
self.mislabel_params = {'k': 20, 'threshold': 0.6}
|
1430
|
+
self.uncertainty_params = {'confidence': 0.6, 'margin': 0.1}
|
1431
|
+
self.similarity_params = {'k': 30}
|
1432
|
+
|
1433
|
+
self.data_item_cache = {} # Cache for AnnotationDataItem objects
|
1434
|
+
|
1146
1435
|
self.current_data_items = []
|
1147
1436
|
self.current_features = None
|
1148
1437
|
self.current_feature_generating_model = ""
|
1438
|
+
self.current_embedding_model_info = None
|
1149
1439
|
self._ui_initialized = False
|
1150
1440
|
|
1151
1441
|
self.setWindowTitle("Explorer")
|
@@ -1195,6 +1485,10 @@ class ExplorerWindow(QMainWindow):
|
|
1195
1485
|
# Call the main cancellation method to revert any pending changes
|
1196
1486
|
self.clear_preview_changes()
|
1197
1487
|
|
1488
|
+
# Clean up the feature store by deleting its files
|
1489
|
+
if hasattr(self, 'feature_store') and self.feature_store:
|
1490
|
+
self.feature_store.delete_storage()
|
1491
|
+
|
1198
1492
|
# Call the dedicated cleanup method
|
1199
1493
|
self._cleanup_resources()
|
1200
1494
|
|
@@ -1225,23 +1519,23 @@ class ExplorerWindow(QMainWindow):
|
|
1225
1519
|
# This ensures that the widgets are only created once per ExplorerWindow instance.
|
1226
1520
|
|
1227
1521
|
# Annotation settings panel (filters by image, type, label)
|
1228
|
-
if self.annotation_settings_widget is None:
|
1522
|
+
if self.annotation_settings_widget is None:
|
1229
1523
|
self.annotation_settings_widget = AnnotationSettingsWidget(self.main_window, self)
|
1230
|
-
|
1524
|
+
|
1231
1525
|
# Model selection panel (choose feature extraction model)
|
1232
|
-
if self.model_settings_widget is None:
|
1526
|
+
if self.model_settings_widget is None:
|
1233
1527
|
self.model_settings_widget = ModelSettingsWidget(self.main_window, self)
|
1234
|
-
|
1528
|
+
|
1235
1529
|
# Embedding settings panel (choose dimensionality reduction method)
|
1236
|
-
if self.embedding_settings_widget is None:
|
1530
|
+
if self.embedding_settings_widget is None:
|
1237
1531
|
self.embedding_settings_widget = EmbeddingSettingsWidget(self.main_window, self)
|
1238
|
-
|
1532
|
+
|
1239
1533
|
# Annotation viewer (shows annotation image crops in a grid)
|
1240
|
-
if self.annotation_viewer is None:
|
1534
|
+
if self.annotation_viewer is None:
|
1241
1535
|
self.annotation_viewer = AnnotationViewer(self)
|
1242
|
-
|
1536
|
+
|
1243
1537
|
# Embedding viewer (shows 2D embedding scatter plot)
|
1244
|
-
if self.embedding_viewer is None:
|
1538
|
+
if self.embedding_viewer is None:
|
1245
1539
|
self.embedding_viewer = EmbeddingViewer(self)
|
1246
1540
|
|
1247
1541
|
top_layout = QHBoxLayout()
|
@@ -1272,7 +1566,11 @@ class ExplorerWindow(QMainWindow):
|
|
1272
1566
|
self.buttons_layout.addWidget(self.exit_button)
|
1273
1567
|
self.buttons_layout.addWidget(self.apply_button)
|
1274
1568
|
self.main_layout.addLayout(self.buttons_layout)
|
1275
|
-
|
1569
|
+
|
1570
|
+
self._initialize_data_item_cache()
|
1571
|
+
self.annotation_settings_widget.set_default_to_current_image()
|
1572
|
+
self.refresh_filters()
|
1573
|
+
|
1276
1574
|
self.annotation_settings_widget.set_default_to_current_image()
|
1277
1575
|
self.refresh_filters()
|
1278
1576
|
|
@@ -1280,7 +1578,7 @@ class ExplorerWindow(QMainWindow):
|
|
1280
1578
|
self.label_window.labelSelected.disconnect(self.on_label_selected_for_preview)
|
1281
1579
|
except TypeError:
|
1282
1580
|
pass
|
1283
|
-
|
1581
|
+
|
1284
1582
|
# Connect signals to slots
|
1285
1583
|
self.label_window.labelSelected.connect(self.on_label_selected_for_preview)
|
1286
1584
|
self.annotation_viewer.selection_changed.connect(self.on_annotation_view_selection_changed)
|
@@ -1288,20 +1586,35 @@ class ExplorerWindow(QMainWindow):
|
|
1288
1586
|
self.annotation_viewer.reset_view_requested.connect(self.on_reset_view_requested)
|
1289
1587
|
self.embedding_viewer.selection_changed.connect(self.on_embedding_view_selection_changed)
|
1290
1588
|
self.embedding_viewer.reset_view_requested.connect(self.on_reset_view_requested)
|
1291
|
-
|
1589
|
+
self.embedding_viewer.find_mislabels_requested.connect(self.find_potential_mislabels)
|
1590
|
+
self.embedding_viewer.mislabel_parameters_changed.connect(self.on_mislabel_params_changed)
|
1591
|
+
self.model_settings_widget.selection_changed.connect(self.on_model_selection_changed)
|
1592
|
+
self.embedding_viewer.find_uncertain_requested.connect(self.find_uncertain_annotations)
|
1593
|
+
self.embedding_viewer.uncertainty_parameters_changed.connect(self.on_uncertainty_params_changed)
|
1594
|
+
self.annotation_viewer.find_similar_requested.connect(self.find_similar_annotations)
|
1595
|
+
self.annotation_viewer.similarity_settings_widget.parameters_changed.connect(self.on_similarity_params_changed)
|
1596
|
+
|
1292
1597
|
@pyqtSlot(list)
|
1293
1598
|
def on_annotation_view_selection_changed(self, changed_ann_ids):
|
1294
1599
|
"""Syncs selection from AnnotationViewer to EmbeddingViewer."""
|
1600
|
+
# Per request, unselect any annotation in the main AnnotationWindow
|
1601
|
+
if hasattr(self, 'annotation_window'):
|
1602
|
+
self.annotation_window.unselect_annotations()
|
1603
|
+
|
1295
1604
|
all_selected_ids = {w.data_item.annotation.id for w in self.annotation_viewer.selected_widgets}
|
1296
1605
|
if self.embedding_viewer.points_by_id:
|
1297
1606
|
self.embedding_viewer.render_selection_from_ids(all_selected_ids)
|
1298
|
-
|
1607
|
+
|
1299
1608
|
# Call the new centralized method
|
1300
1609
|
self.update_label_window_selection()
|
1301
1610
|
|
1302
1611
|
@pyqtSlot(list)
|
1303
1612
|
def on_embedding_view_selection_changed(self, all_selected_ann_ids):
|
1304
1613
|
"""Syncs selection from EmbeddingViewer to AnnotationViewer."""
|
1614
|
+
# Per request, unselect any annotation in the main AnnotationWindow
|
1615
|
+
if hasattr(self, 'annotation_window'):
|
1616
|
+
self.annotation_window.unselect_annotations()
|
1617
|
+
|
1305
1618
|
# Check the state BEFORE the selection is changed
|
1306
1619
|
was_empty_selection = len(self.annotation_viewer.selected_widgets) == 0
|
1307
1620
|
|
@@ -1321,11 +1634,18 @@ class ExplorerWindow(QMainWindow):
|
|
1321
1634
|
|
1322
1635
|
@pyqtSlot(list)
|
1323
1636
|
def on_preview_changed(self, changed_ann_ids):
|
1324
|
-
"""Updates embedding point colors when a preview label is applied."""
|
1637
|
+
"""Updates embedding point colors and tooltips when a preview label is applied."""
|
1325
1638
|
for ann_id in changed_ann_ids:
|
1639
|
+
# Update embedding point color
|
1326
1640
|
point = self.embedding_viewer.points_by_id.get(ann_id)
|
1327
1641
|
if point:
|
1328
1642
|
point.update()
|
1643
|
+
point.update_tooltip() # Refresh tooltip to show new effective label
|
1644
|
+
|
1645
|
+
# Update annotation widget tooltip
|
1646
|
+
widget = self.annotation_viewer.annotation_widgets_by_id.get(ann_id)
|
1647
|
+
if widget:
|
1648
|
+
widget.update_tooltip()
|
1329
1649
|
|
1330
1650
|
@pyqtSlot()
|
1331
1651
|
def on_reset_view_requested(self):
|
@@ -1334,14 +1654,66 @@ class ExplorerWindow(QMainWindow):
|
|
1334
1654
|
self.annotation_viewer.clear_selection()
|
1335
1655
|
self.embedding_viewer.render_selection_from_ids(set())
|
1336
1656
|
|
1337
|
-
# Exit isolation mode if currently active
|
1657
|
+
# Exit isolation mode if currently active in AnnotationViewer
|
1338
1658
|
if self.annotation_viewer.isolated_mode:
|
1339
1659
|
self.annotation_viewer.show_all_annotations()
|
1340
1660
|
|
1341
|
-
self.
|
1661
|
+
if self.embedding_viewer.isolated_mode:
|
1662
|
+
self.embedding_viewer.show_all_points()
|
1663
|
+
|
1664
|
+
# Clear similarity sort context
|
1665
|
+
self.annotation_viewer.active_ordered_ids = []
|
1666
|
+
|
1667
|
+
self.update_label_window_selection()
|
1342
1668
|
self.update_button_states()
|
1343
1669
|
|
1344
1670
|
print("Reset view: cleared selections and exited isolation mode")
|
1671
|
+
|
1672
|
+
@pyqtSlot(dict)
|
1673
|
+
def on_mislabel_params_changed(self, params):
|
1674
|
+
"""Updates the stored parameters for mislabel detection."""
|
1675
|
+
self.mislabel_params = params
|
1676
|
+
print(f"Mislabel detection parameters updated: {self.mislabel_params}")
|
1677
|
+
|
1678
|
+
@pyqtSlot(dict)
|
1679
|
+
def on_uncertainty_params_changed(self, params):
|
1680
|
+
"""Updates the stored parameters for uncertainty analysis."""
|
1681
|
+
self.uncertainty_params = params
|
1682
|
+
print(f"Uncertainty parameters updated: {self.uncertainty_params}")
|
1683
|
+
|
1684
|
+
@pyqtSlot(dict)
|
1685
|
+
def on_similarity_params_changed(self, params):
|
1686
|
+
"""Updates the stored parameters for similarity search."""
|
1687
|
+
self.similarity_params = params
|
1688
|
+
print(f"Similarity search parameters updated: {self.similarity_params}")
|
1689
|
+
|
1690
|
+
@pyqtSlot()
|
1691
|
+
def on_model_selection_changed(self):
|
1692
|
+
"""
|
1693
|
+
Handles changes in the model settings to enable/disable model-dependent features.
|
1694
|
+
"""
|
1695
|
+
if not self._ui_initialized:
|
1696
|
+
return
|
1697
|
+
|
1698
|
+
model_name, feature_mode = self.model_settings_widget.get_selected_model()
|
1699
|
+
is_predict_mode = ".pt" in model_name and feature_mode == "Predictions"
|
1700
|
+
|
1701
|
+
self.embedding_viewer.is_uncertainty_analysis_available = is_predict_mode
|
1702
|
+
self.embedding_viewer._update_toolbar_state()
|
1703
|
+
|
1704
|
+
def _initialize_data_item_cache(self):
|
1705
|
+
"""
|
1706
|
+
Creates a persistent AnnotationDataItem for every annotation,
|
1707
|
+
caching them for the duration of the session.
|
1708
|
+
"""
|
1709
|
+
self.data_item_cache.clear()
|
1710
|
+
if not hasattr(self.main_window.annotation_window, 'annotations_dict'):
|
1711
|
+
return
|
1712
|
+
|
1713
|
+
all_annotations = self.main_window.annotation_window.annotations_dict.values()
|
1714
|
+
for ann in all_annotations:
|
1715
|
+
if ann.id not in self.data_item_cache:
|
1716
|
+
self.data_item_cache[ann.id] = AnnotationDataItem(ann)
|
1345
1717
|
|
1346
1718
|
def update_label_window_selection(self):
|
1347
1719
|
"""
|
@@ -1360,7 +1732,7 @@ class ExplorerWindow(QMainWindow):
|
|
1360
1732
|
|
1361
1733
|
first_effective_label = selected_data_items[0].effective_label
|
1362
1734
|
all_same_current_label = all(
|
1363
|
-
item.effective_label.id == first_effective_label.id
|
1735
|
+
item.effective_label.id == first_effective_label.id
|
1364
1736
|
for item in selected_data_items
|
1365
1737
|
)
|
1366
1738
|
|
@@ -1374,10 +1746,12 @@ class ExplorerWindow(QMainWindow):
|
|
1374
1746
|
self.label_window.update_annotation_count()
|
1375
1747
|
|
1376
1748
|
def get_filtered_data_items(self):
|
1377
|
-
"""
|
1378
|
-
|
1749
|
+
"""
|
1750
|
+
Gets annotations matching all conditions by retrieving their
|
1751
|
+
persistent AnnotationDataItem objects from the cache.
|
1752
|
+
"""
|
1379
1753
|
if not hasattr(self.main_window.annotation_window, 'annotations_dict'):
|
1380
|
-
return
|
1754
|
+
return []
|
1381
1755
|
|
1382
1756
|
selected_images = self.annotation_settings_widget.get_selected_images()
|
1383
1757
|
selected_types = self.annotation_settings_widget.get_selected_annotation_types()
|
@@ -1394,37 +1768,436 @@ class ExplorerWindow(QMainWindow):
|
|
1394
1768
|
]
|
1395
1769
|
|
1396
1770
|
self._ensure_cropped_images(annotations_to_process)
|
1397
|
-
|
1771
|
+
|
1772
|
+
return [self.data_item_cache[ann.id] for ann in annotations_to_process if ann.id in self.data_item_cache]
|
1773
|
+
|
1774
|
+
def find_potential_mislabels(self):
|
1775
|
+
"""
|
1776
|
+
Identifies annotations whose label does not match the majority of its
|
1777
|
+
k-nearest neighbors in the high-dimensional feature space.
|
1778
|
+
"""
|
1779
|
+
# Get parameters from the stored property instead of hardcoding
|
1780
|
+
K = self.mislabel_params.get('k', 5)
|
1781
|
+
agreement_threshold = self.mislabel_params.get('threshold', 0.6)
|
1782
|
+
|
1783
|
+
if not self.embedding_viewer.points_by_id or len(self.embedding_viewer.points_by_id) < K:
|
1784
|
+
QMessageBox.information(self, "Not Enough Data",
|
1785
|
+
f"This feature requires at least {K} points in the embedding viewer.")
|
1786
|
+
return
|
1787
|
+
|
1788
|
+
items_in_view = list(self.embedding_viewer.points_by_id.values())
|
1789
|
+
data_items_in_view = [p.data_item for p in items_in_view]
|
1790
|
+
|
1791
|
+
# Get the model key used for the current embedding
|
1792
|
+
model_info = self.model_settings_widget.get_selected_model()
|
1793
|
+
model_name, feature_mode = model_info if isinstance(model_info, tuple) else (model_info, "default")
|
1794
|
+
sanitized_model_name = os.path.basename(model_name).replace(' ', '_')
|
1795
|
+
# FIX: Also replace the forward slash to handle "N/A"
|
1796
|
+
sanitized_feature_mode = feature_mode.replace(' ', '_').replace('/', '_')
|
1797
|
+
model_key = f"{sanitized_model_name}_{sanitized_feature_mode}"
|
1798
|
+
|
1799
|
+
QApplication.setOverrideCursor(Qt.WaitCursor)
|
1800
|
+
try:
|
1801
|
+
# Get the FAISS index and the mapping from index to annotation ID
|
1802
|
+
index = self.feature_store._get_or_load_index(model_key)
|
1803
|
+
faiss_idx_to_ann_id = self.feature_store.get_faiss_index_to_annotation_id_map(model_key)
|
1804
|
+
if index is None or not faiss_idx_to_ann_id:
|
1805
|
+
QMessageBox.warning(self, "Error", "Could not find a valid feature index for the current model.")
|
1806
|
+
return
|
1807
|
+
|
1808
|
+
# Get the high-dimensional features for the points in the current view
|
1809
|
+
features_dict, _ = self.feature_store.get_features(data_items_in_view, model_key)
|
1810
|
+
if not features_dict:
|
1811
|
+
QMessageBox.warning(self, "Error", "Could not retrieve features for the items in view.")
|
1812
|
+
return
|
1813
|
+
|
1814
|
+
query_ann_ids = list(features_dict.keys())
|
1815
|
+
query_vectors = np.array([features_dict[ann_id] for ann_id in query_ann_ids]).astype('float32')
|
1816
|
+
|
1817
|
+
# Perform k-NN search. We search for K+1 because the point itself will be the first result.
|
1818
|
+
_, I = index.search(query_vectors, K + 1)
|
1819
|
+
|
1820
|
+
mislabeled_ann_ids = []
|
1821
|
+
for i, ann_id in enumerate(query_ann_ids):
|
1822
|
+
current_label = self.data_item_cache[ann_id].effective_label.id
|
1823
|
+
|
1824
|
+
# Get neighbor labels, ignoring the first result (the point itself)
|
1825
|
+
neighbor_faiss_indices = I[i][1:]
|
1826
|
+
|
1827
|
+
neighbor_labels = []
|
1828
|
+
for n_idx in neighbor_faiss_indices:
|
1829
|
+
# THIS IS THE CORRECTED LOGIC
|
1830
|
+
if n_idx in faiss_idx_to_ann_id:
|
1831
|
+
neighbor_ann_id = faiss_idx_to_ann_id[n_idx]
|
1832
|
+
# ADD THIS CHECK to ensure the neighbor hasn't been deleted
|
1833
|
+
if neighbor_ann_id in self.data_item_cache:
|
1834
|
+
neighbor_labels.append(self.data_item_cache[neighbor_ann_id].effective_label.id)
|
1835
|
+
|
1836
|
+
if not neighbor_labels:
|
1837
|
+
continue
|
1838
|
+
|
1839
|
+
# Use the agreement threshold instead of strict majority
|
1840
|
+
num_matching_neighbors = neighbor_labels.count(current_label)
|
1841
|
+
agreement_ratio = num_matching_neighbors / len(neighbor_labels)
|
1842
|
+
|
1843
|
+
if agreement_ratio < agreement_threshold:
|
1844
|
+
mislabeled_ann_ids.append(ann_id)
|
1845
|
+
|
1846
|
+
self.embedding_viewer.render_selection_from_ids(set(mislabeled_ann_ids))
|
1847
|
+
|
1848
|
+
finally:
|
1849
|
+
QApplication.restoreOverrideCursor()
|
1850
|
+
|
1851
|
+
def find_uncertain_annotations(self):
|
1852
|
+
"""
|
1853
|
+
Identifies annotations where the model's prediction is uncertain.
|
1854
|
+
It reuses cached predictions if available, otherwise runs a temporary prediction.
|
1855
|
+
"""
|
1856
|
+
if not self.embedding_viewer.points_by_id:
|
1857
|
+
QMessageBox.information(self, "No Data", "Please generate an embedding first.")
|
1858
|
+
return
|
1859
|
+
|
1860
|
+
if self.current_embedding_model_info is None:
|
1861
|
+
QMessageBox.information(self,
|
1862
|
+
"No Embedding",
|
1863
|
+
"Could not determine the model used for the embedding. Please run it again.")
|
1864
|
+
return
|
1865
|
+
|
1866
|
+
items_in_view = list(self.embedding_viewer.points_by_id.values())
|
1867
|
+
data_items_in_view = [p.data_item for p in items_in_view]
|
1868
|
+
|
1869
|
+
model_name_from_embedding, feature_mode_from_embedding = self.current_embedding_model_info
|
1870
|
+
|
1871
|
+
QApplication.setOverrideCursor(Qt.WaitCursor)
|
1872
|
+
try:
|
1873
|
+
probabilities_dict = {}
|
1874
|
+
|
1875
|
+
# Decide whether to reuse cached features or run a new prediction
|
1876
|
+
if feature_mode_from_embedding == "Predictions":
|
1877
|
+
print("Reusing cached prediction vectors from the FeatureStore.")
|
1878
|
+
sanitized_model_name = os.path.basename(model_name_from_embedding).replace(' ', '_').replace('/', '_')
|
1879
|
+
sanitized_feature_mode = feature_mode_from_embedding.replace(' ', '_').replace('/', '_')
|
1880
|
+
model_key = f"{sanitized_model_name}_{sanitized_feature_mode}"
|
1881
|
+
|
1882
|
+
probabilities_dict, _ = self.feature_store.get_features(data_items_in_view, model_key)
|
1883
|
+
if not probabilities_dict:
|
1884
|
+
QMessageBox.warning(self,
|
1885
|
+
"Cache Error",
|
1886
|
+
"Could not retrieve cached predictions.")
|
1887
|
+
return
|
1888
|
+
else:
|
1889
|
+
print("Embedding not based on 'Predictions' mode. Running a temporary prediction.")
|
1890
|
+
model_info_for_predict = self.model_settings_widget.get_selected_model()
|
1891
|
+
probabilities_dict = self._get_yolo_predictions_for_uncertainty(data_items_in_view,
|
1892
|
+
model_info_for_predict)
|
1893
|
+
|
1894
|
+
if not probabilities_dict:
|
1895
|
+
# The helper function will show its own, more specific errors.
|
1896
|
+
return
|
1897
|
+
|
1898
|
+
uncertain_ids = []
|
1899
|
+
params = self.uncertainty_params
|
1900
|
+
for ann_id, probs in probabilities_dict.items():
|
1901
|
+
if len(probs) < 2:
|
1902
|
+
continue # Cannot calculate margin
|
1903
|
+
|
1904
|
+
sorted_probs = np.sort(probs)[::-1]
|
1905
|
+
top1_conf = sorted_probs[0]
|
1906
|
+
top2_conf = sorted_probs[1]
|
1907
|
+
margin = top1_conf - top2_conf
|
1908
|
+
|
1909
|
+
if top1_conf < params['confidence'] or margin < params['margin']:
|
1910
|
+
uncertain_ids.append(ann_id)
|
1911
|
+
|
1912
|
+
self.embedding_viewer.render_selection_from_ids(set(uncertain_ids))
|
1913
|
+
print(f"Found {len(uncertain_ids)} uncertain annotations.")
|
1914
|
+
|
1915
|
+
finally:
|
1916
|
+
QApplication.restoreOverrideCursor()
|
1917
|
+
|
1918
|
+
@pyqtSlot()
|
1919
|
+
def find_similar_annotations(self):
|
1920
|
+
"""
|
1921
|
+
Finds k-nearest neighbors to the selected annotation(s) and updates
|
1922
|
+
the UI to show the results in an isolated, ordered view. This method
|
1923
|
+
now ensures the grid is always updated and resets the sort-by dropdown.
|
1924
|
+
"""
|
1925
|
+
k = self.similarity_params.get('k', 10)
|
1926
|
+
|
1927
|
+
if not self.annotation_viewer.selected_widgets:
|
1928
|
+
QMessageBox.information(self, "No Selection", "Please select one or more annotations first.")
|
1929
|
+
return
|
1930
|
+
|
1931
|
+
if not self.current_embedding_model_info:
|
1932
|
+
QMessageBox.warning(self, "No Embedding", "Please run an embedding before searching for similar items.")
|
1933
|
+
return
|
1934
|
+
|
1935
|
+
selected_data_items = [widget.data_item for widget in self.annotation_viewer.selected_widgets]
|
1936
|
+
model_name, feature_mode = self.current_embedding_model_info
|
1937
|
+
sanitized_model_name = os.path.basename(model_name).replace(' ', '_')
|
1938
|
+
sanitized_feature_mode = feature_mode.replace(' ', '_').replace('/', '_')
|
1939
|
+
model_key = f"{sanitized_model_name}_{sanitized_feature_mode}"
|
1940
|
+
|
1941
|
+
QApplication.setOverrideCursor(Qt.WaitCursor)
|
1942
|
+
try:
|
1943
|
+
features_dict, _ = self.feature_store.get_features(selected_data_items, model_key)
|
1944
|
+
if not features_dict:
|
1945
|
+
QMessageBox.warning(self,
|
1946
|
+
"Features Not Found",
|
1947
|
+
"Could not retrieve feature vectors for the selected items.")
|
1948
|
+
return
|
1949
|
+
|
1950
|
+
source_vectors = np.array(list(features_dict.values()))
|
1951
|
+
query_vector = np.mean(source_vectors, axis=0, keepdims=True).astype('float32')
|
1952
|
+
|
1953
|
+
index = self.feature_store._get_or_load_index(model_key)
|
1954
|
+
faiss_idx_to_ann_id = self.feature_store.get_faiss_index_to_annotation_id_map(model_key)
|
1955
|
+
if index is None or not faiss_idx_to_ann_id:
|
1956
|
+
QMessageBox.warning(self,
|
1957
|
+
"Index Error",
|
1958
|
+
"Could not find a valid feature index for the current model.")
|
1959
|
+
return
|
1960
|
+
|
1961
|
+
# Find k results, plus more to account for the query items possibly being in the results
|
1962
|
+
num_to_find = k + len(selected_data_items)
|
1963
|
+
if num_to_find > index.ntotal:
|
1964
|
+
num_to_find = index.ntotal
|
1965
|
+
|
1966
|
+
_, I = index.search(query_vector, num_to_find)
|
1967
|
+
|
1968
|
+
source_ids = {item.annotation.id for item in selected_data_items}
|
1969
|
+
similar_ann_ids = []
|
1970
|
+
for faiss_idx in I[0]:
|
1971
|
+
ann_id = faiss_idx_to_ann_id.get(faiss_idx)
|
1972
|
+
if ann_id and ann_id in self.data_item_cache and ann_id not in source_ids:
|
1973
|
+
similar_ann_ids.append(ann_id)
|
1974
|
+
if len(similar_ann_ids) == k:
|
1975
|
+
break
|
1976
|
+
|
1977
|
+
# Create the final ordered list: original selection first, then similar items.
|
1978
|
+
ordered_ids_to_display = list(source_ids) + similar_ann_ids
|
1979
|
+
|
1980
|
+
# --- FIX IMPLEMENTATION ---
|
1981
|
+
# 1. Force sort combo to "None" to avoid user confusion.
|
1982
|
+
self.annotation_viewer.sort_combo.setCurrentText("None")
|
1983
|
+
|
1984
|
+
# 2. Update the embedding viewer selection.
|
1985
|
+
self.embedding_viewer.render_selection_from_ids(set(ordered_ids_to_display))
|
1986
|
+
|
1987
|
+
# 3. Call the new robust method in AnnotationViewer to handle isolation and grid updates.
|
1988
|
+
self.annotation_viewer.display_and_isolate_ordered_results(ordered_ids_to_display)
|
1989
|
+
|
1990
|
+
self.update_button_states()
|
1991
|
+
|
1992
|
+
finally:
|
1993
|
+
QApplication.restoreOverrideCursor()
|
1994
|
+
|
1995
|
+
def _get_yolo_predictions_for_uncertainty(self, data_items, model_info):
|
1996
|
+
"""
|
1997
|
+
Runs a YOLO classification model to get probabilities for uncertainty analysis.
|
1998
|
+
This is a streamlined method that does NOT use the feature store.
|
1999
|
+
"""
|
2000
|
+
model_name, feature_mode = model_info
|
2001
|
+
|
2002
|
+
# Load the model
|
2003
|
+
model, imgsz = self._load_yolo_model(model_name, feature_mode)
|
2004
|
+
if model is None:
|
2005
|
+
QMessageBox.warning(self,
|
2006
|
+
"Model Load Error",
|
2007
|
+
f"Could not load YOLO model '{model_name}'.")
|
2008
|
+
return None
|
2009
|
+
|
2010
|
+
# Prepare images from data items
|
2011
|
+
image_list, valid_data_items = self._prepare_images_from_data_items(data_items)
|
2012
|
+
if not image_list:
|
2013
|
+
return None
|
2014
|
+
|
2015
|
+
try:
|
2016
|
+
# We need probabilities for uncertainty analysis, so we always use predict
|
2017
|
+
results = model.predict(image_list,
|
2018
|
+
stream=False, # Use batch processing for uncertainty
|
2019
|
+
imgsz=imgsz,
|
2020
|
+
half=True,
|
2021
|
+
device=self.device,
|
2022
|
+
verbose=False)
|
2023
|
+
|
2024
|
+
_, probabilities_dict = self._process_model_results(results, valid_data_items, "Predictions")
|
2025
|
+
return probabilities_dict
|
2026
|
+
|
2027
|
+
except TypeError:
|
2028
|
+
QMessageBox.warning(self,
|
2029
|
+
"Invalid Model",
|
2030
|
+
"The selected model is not compatible with uncertainty analysis.")
|
2031
|
+
return None
|
2032
|
+
|
2033
|
+
finally:
|
2034
|
+
if torch.cuda.is_available():
|
2035
|
+
torch.cuda.empty_cache()
|
1398
2036
|
|
1399
2037
|
def _ensure_cropped_images(self, annotations):
|
1400
2038
|
"""Ensures all provided annotations have a cropped image available."""
|
1401
2039
|
annotations_by_image = {}
|
1402
|
-
|
2040
|
+
|
1403
2041
|
for annotation in annotations:
|
1404
2042
|
if not annotation.cropped_image:
|
1405
2043
|
image_path = annotation.image_path
|
1406
2044
|
if image_path not in annotations_by_image:
|
1407
2045
|
annotations_by_image[image_path] = []
|
1408
2046
|
annotations_by_image[image_path].append(annotation)
|
1409
|
-
|
1410
|
-
if not annotations_by_image:
|
2047
|
+
|
2048
|
+
if not annotations_by_image:
|
1411
2049
|
return
|
1412
2050
|
|
1413
2051
|
progress_bar = ProgressBar(self, "Cropping Image Annotations")
|
1414
2052
|
progress_bar.show()
|
1415
2053
|
progress_bar.start_progress(len(annotations_by_image))
|
1416
|
-
|
2054
|
+
|
1417
2055
|
try:
|
1418
2056
|
for image_path, image_annotations in annotations_by_image.items():
|
1419
|
-
self.annotation_window.crop_annotations(image_path=image_path,
|
1420
|
-
annotations=image_annotations,
|
1421
|
-
return_annotations=False,
|
2057
|
+
self.annotation_window.crop_annotations(image_path=image_path,
|
2058
|
+
annotations=image_annotations,
|
2059
|
+
return_annotations=False,
|
1422
2060
|
verbose=False)
|
1423
2061
|
progress_bar.update_progress()
|
1424
2062
|
finally:
|
1425
2063
|
progress_bar.finish_progress()
|
1426
2064
|
progress_bar.stop_progress()
|
1427
2065
|
progress_bar.close()
|
2066
|
+
|
2067
|
+
def _load_yolo_model(self, model_name, feature_mode):
|
2068
|
+
"""
|
2069
|
+
Helper function to load a YOLO model and cache it.
|
2070
|
+
|
2071
|
+
Args:
|
2072
|
+
model_name (str): Path to the YOLO model file
|
2073
|
+
feature_mode (str): Mode for feature extraction ("Embed Features" or "Predictions")
|
2074
|
+
|
2075
|
+
Returns:
|
2076
|
+
tuple: (model, image_size) or (None, None) if loading fails
|
2077
|
+
"""
|
2078
|
+
current_run_key = (model_name, feature_mode)
|
2079
|
+
|
2080
|
+
# Force a reload if the model path OR the feature mode has changed
|
2081
|
+
if current_run_key != self.current_feature_generating_model or self.loaded_model is None:
|
2082
|
+
print(f"Model or mode changed. Reloading {model_name} for '{feature_mode}'.")
|
2083
|
+
try:
|
2084
|
+
model = YOLO(model_name)
|
2085
|
+
# Update the cache key to the new successful combination
|
2086
|
+
self.current_feature_generating_model = current_run_key
|
2087
|
+
self.loaded_model = model
|
2088
|
+
imgsz = getattr(model.model.args, 'imgsz', 128)
|
2089
|
+
|
2090
|
+
# Warm up the model
|
2091
|
+
dummy_image = np.zeros((imgsz, imgsz, 3), dtype=np.uint8)
|
2092
|
+
model.predict(dummy_image, imgsz=imgsz, half=True, device=self.device, verbose=False)
|
2093
|
+
|
2094
|
+
return model, imgsz
|
2095
|
+
|
2096
|
+
except Exception as e:
|
2097
|
+
print(f"ERROR: Could not load YOLO model '{model_name}': {e}")
|
2098
|
+
# On failure, reset the model cache
|
2099
|
+
self.loaded_model = None
|
2100
|
+
self.current_feature_generating_model = None
|
2101
|
+
return None, None
|
2102
|
+
|
2103
|
+
# Model already loaded and cached
|
2104
|
+
return self.loaded_model, getattr(self.loaded_model.model.args, 'imgsz', 128)
|
2105
|
+
|
2106
|
+
def _prepare_images_from_data_items(self, data_items, progress_bar=None):
|
2107
|
+
"""
|
2108
|
+
Prepare images from data items for model prediction.
|
2109
|
+
|
2110
|
+
Args:
|
2111
|
+
data_items (list): List of AnnotationDataItem objects
|
2112
|
+
progress_bar (ProgressBar, optional): Progress bar for UI updates
|
2113
|
+
|
2114
|
+
Returns:
|
2115
|
+
tuple: (image_list, valid_data_items)
|
2116
|
+
"""
|
2117
|
+
if progress_bar:
|
2118
|
+
progress_bar.set_title("Preparing images...")
|
2119
|
+
progress_bar.start_progress(len(data_items))
|
2120
|
+
|
2121
|
+
image_list, valid_data_items = [], []
|
2122
|
+
for item in data_items:
|
2123
|
+
pixmap = item.annotation.get_cropped_image()
|
2124
|
+
if pixmap and not pixmap.isNull():
|
2125
|
+
image_list.append(pixmap_to_numpy(pixmap))
|
2126
|
+
valid_data_items.append(item)
|
2127
|
+
|
2128
|
+
if progress_bar:
|
2129
|
+
progress_bar.update_progress()
|
2130
|
+
|
2131
|
+
return image_list, valid_data_items
|
2132
|
+
|
2133
|
+
def _process_model_results(self, results, valid_data_items, feature_mode, progress_bar=None):
|
2134
|
+
"""
|
2135
|
+
Process model results and update data item tooltips.
|
2136
|
+
|
2137
|
+
Args:
|
2138
|
+
results: Model prediction results
|
2139
|
+
valid_data_items (list): List of valid data items
|
2140
|
+
feature_mode (str): Mode for feature extraction
|
2141
|
+
progress_bar (ProgressBar, optional): Progress bar for UI updates
|
2142
|
+
|
2143
|
+
Returns:
|
2144
|
+
tuple: (features_list, probabilities_dict)
|
2145
|
+
"""
|
2146
|
+
features_list = []
|
2147
|
+
probabilities_dict = {}
|
2148
|
+
|
2149
|
+
# Get class names from the model for better tooltips
|
2150
|
+
model = self.loaded_model.model if hasattr(self.loaded_model, 'model') else None
|
2151
|
+
class_names = model.names if model and hasattr(model, 'names') else {}
|
2152
|
+
|
2153
|
+
for i, result in enumerate(results):
|
2154
|
+
if i >= len(valid_data_items):
|
2155
|
+
break
|
2156
|
+
|
2157
|
+
item = valid_data_items[i]
|
2158
|
+
ann_id = item.annotation.id
|
2159
|
+
|
2160
|
+
if feature_mode == "Embed Features":
|
2161
|
+
embedding = result.cpu().numpy().flatten()
|
2162
|
+
features_list.append(embedding)
|
2163
|
+
|
2164
|
+
elif hasattr(result, 'probs') and result.probs is not None:
|
2165
|
+
probs = result.probs.data.cpu().numpy().squeeze()
|
2166
|
+
features_list.append(probs)
|
2167
|
+
probabilities_dict[ann_id] = probs
|
2168
|
+
|
2169
|
+
# Store the probabilities directly on the data item for confidence sorting
|
2170
|
+
item.prediction_probabilities = probs
|
2171
|
+
|
2172
|
+
# Format and store prediction details for tooltips
|
2173
|
+
if len(probs) > 0:
|
2174
|
+
# Get top 5 predictions
|
2175
|
+
top_indices = probs.argsort()[::-1][:5]
|
2176
|
+
top_probs = probs[top_indices]
|
2177
|
+
|
2178
|
+
formatted_preds = ["<b>Top Predictions:</b>"]
|
2179
|
+
for idx, prob in zip(top_indices, top_probs):
|
2180
|
+
class_name = class_names.get(int(idx), f"Class {idx}")
|
2181
|
+
formatted_preds.append(f"{class_name}: {prob*100:.1f}%")
|
2182
|
+
|
2183
|
+
item.prediction_details = "<br>".join(formatted_preds)
|
2184
|
+
else:
|
2185
|
+
raise TypeError(
|
2186
|
+
"The 'Predictions' feature mode requires a classification model "
|
2187
|
+
"(e.g., 'yolov8n-cls.pt') that returns class probabilities. "
|
2188
|
+
"The selected model did not provide this output. "
|
2189
|
+
"Please use 'Embed Features' mode for this model."
|
2190
|
+
)
|
2191
|
+
|
2192
|
+
if progress_bar:
|
2193
|
+
progress_bar.update_progress()
|
2194
|
+
|
2195
|
+
# After processing is complete, update tooltips
|
2196
|
+
for item in valid_data_items:
|
2197
|
+
if hasattr(item, 'update_tooltip'):
|
2198
|
+
item.update_tooltip()
|
2199
|
+
|
2200
|
+
return features_list, probabilities_dict
|
1428
2201
|
|
1429
2202
|
def _extract_color_features(self, data_items, progress_bar=None, bins=32):
|
1430
2203
|
"""
|
@@ -1507,102 +2280,71 @@ class ExplorerWindow(QMainWindow):
|
|
1507
2280
|
|
1508
2281
|
def _extract_yolo_features(self, data_items, model_info, progress_bar=None):
|
1509
2282
|
"""Extracts features from annotation crops using a YOLO model."""
|
1510
|
-
# Extract model name and feature mode from the provided model_info tuple
|
1511
2283
|
model_name, feature_mode = model_info
|
1512
2284
|
|
1513
|
-
|
1514
|
-
|
1515
|
-
|
1516
|
-
self.model_path = model_name
|
1517
|
-
self.imgsz = getattr(self.loaded_model.model.args, 'imgsz', 128)
|
1518
|
-
dummy_image = np.zeros((self.imgsz, self.imgsz, 3), dtype=np.uint8)
|
1519
|
-
self.loaded_model.predict(dummy_image, imgsz=self.imgsz, half=True, device=self.device, verbose=False)
|
1520
|
-
|
1521
|
-
except Exception as e:
|
1522
|
-
print(f"ERROR: Could not load YOLO model '{model_name}': {e}")
|
1523
|
-
return np.array([]), []
|
1524
|
-
|
1525
|
-
if progress_bar:
|
1526
|
-
progress_bar.set_title("Preparing images...")
|
1527
|
-
progress_bar.start_progress(len(data_items))
|
1528
|
-
|
1529
|
-
image_list, valid_data_items = [], []
|
1530
|
-
for item in data_items:
|
1531
|
-
# Get the cropped image from the annotation
|
1532
|
-
pixmap = item.annotation.get_cropped_image()
|
1533
|
-
|
1534
|
-
if pixmap and not pixmap.isNull():
|
1535
|
-
image_list.append(pixmap_to_numpy(pixmap))
|
1536
|
-
valid_data_items.append(item)
|
1537
|
-
|
1538
|
-
if progress_bar:
|
1539
|
-
progress_bar.update_progress()
|
1540
|
-
|
1541
|
-
if not valid_data_items:
|
2285
|
+
# Load the model
|
2286
|
+
model, imgsz = self._load_yolo_model(model_name, feature_mode)
|
2287
|
+
if model is None:
|
1542
2288
|
return np.array([]), []
|
1543
2289
|
|
1544
|
-
#
|
1545
|
-
|
1546
|
-
|
1547
|
-
|
1548
|
-
|
1549
|
-
|
2290
|
+
# Prepare images from data items
|
2291
|
+
image_list, valid_data_items = self._prepare_images_from_data_items(data_items, progress_bar)
|
2292
|
+
if not valid_data_items:
|
2293
|
+
return np.array([]), []
|
2294
|
+
|
2295
|
+
# Set up prediction parameters
|
2296
|
+
kwargs = {
|
2297
|
+
'stream': True,
|
2298
|
+
'imgsz': imgsz,
|
2299
|
+
'half': True,
|
2300
|
+
'device': self.device,
|
2301
|
+
'verbose': False
|
2302
|
+
}
|
1550
2303
|
|
1551
|
-
#
|
2304
|
+
# Get results based on feature mode
|
1552
2305
|
if feature_mode == "Embed Features":
|
1553
|
-
results_generator =
|
2306
|
+
results_generator = model.embed(image_list, **kwargs)
|
1554
2307
|
else:
|
1555
|
-
results_generator =
|
1556
|
-
|
1557
|
-
if progress_bar:
|
2308
|
+
results_generator = model.predict(image_list, **kwargs)
|
2309
|
+
|
2310
|
+
if progress_bar:
|
1558
2311
|
progress_bar.set_title("Extracting features...")
|
1559
2312
|
progress_bar.start_progress(len(valid_data_items))
|
1560
2313
|
|
1561
|
-
# Prepare a list to hold the extracted features
|
1562
|
-
embeddings_list = []
|
1563
|
-
|
1564
2314
|
try:
|
1565
|
-
|
1566
|
-
|
1567
|
-
|
1568
|
-
|
1569
|
-
|
1570
|
-
|
1571
|
-
|
1572
|
-
|
1573
|
-
else:
|
1574
|
-
raise TypeError("Model did not return expected output")
|
1575
|
-
|
1576
|
-
if progress_bar:
|
1577
|
-
progress_bar.update_progress()
|
2315
|
+
features_list, _ = self._process_model_results(results_generator,
|
2316
|
+
valid_data_items,
|
2317
|
+
feature_mode,
|
2318
|
+
progress_bar=progress_bar)
|
2319
|
+
|
2320
|
+
return np.array(features_list), valid_data_items
|
2321
|
+
|
1578
2322
|
finally:
|
1579
2323
|
if torch.cuda.is_available():
|
1580
2324
|
torch.cuda.empty_cache()
|
1581
|
-
|
1582
|
-
return np.array(embeddings_list), valid_data_items
|
1583
2325
|
|
1584
2326
|
def _extract_features(self, data_items, progress_bar=None):
|
1585
2327
|
"""Dispatcher to call the appropriate feature extraction function."""
|
1586
2328
|
# Get the selected model and feature mode from the model settings widget
|
1587
2329
|
model_name, feature_mode = self.model_settings_widget.get_selected_model()
|
1588
|
-
|
1589
|
-
if isinstance(model_name, tuple):
|
2330
|
+
|
2331
|
+
if isinstance(model_name, tuple):
|
1590
2332
|
model_name = model_name[0]
|
1591
|
-
|
1592
|
-
if not model_name:
|
2333
|
+
|
2334
|
+
if not model_name:
|
1593
2335
|
return np.array([]), []
|
1594
|
-
|
2336
|
+
|
1595
2337
|
if model_name == "Color Features":
|
1596
2338
|
return self._extract_color_features(data_items, progress_bar=progress_bar)
|
1597
|
-
|
2339
|
+
|
1598
2340
|
elif ".pt" in model_name:
|
1599
2341
|
return self._extract_yolo_features(data_items, (model_name, feature_mode), progress_bar=progress_bar)
|
1600
|
-
|
2342
|
+
|
1601
2343
|
return np.array([]), []
|
1602
2344
|
|
1603
2345
|
def _run_dimensionality_reduction(self, features, params):
|
1604
2346
|
"""
|
1605
|
-
Runs dimensionality reduction
|
2347
|
+
Runs dimensionality reduction with automatic PCA preprocessing for UMAP and t-SNE.
|
1606
2348
|
|
1607
2349
|
Args:
|
1608
2350
|
features (np.ndarray): Feature matrix of shape (N, D).
|
@@ -1612,7 +2354,9 @@ class ExplorerWindow(QMainWindow):
|
|
1612
2354
|
np.ndarray or None: 2D embedded features of shape (N, 2), or None on failure.
|
1613
2355
|
"""
|
1614
2356
|
technique = params.get('technique', 'UMAP')
|
1615
|
-
|
2357
|
+
# Default number of components to use for PCA preprocessing
|
2358
|
+
pca_components = params.get('pca_components', 50)
|
2359
|
+
|
1616
2360
|
if len(features) <= 2:
|
1617
2361
|
# Not enough samples for dimensionality reduction
|
1618
2362
|
return None
|
@@ -1620,11 +2364,21 @@ class ExplorerWindow(QMainWindow):
|
|
1620
2364
|
try:
|
1621
2365
|
# Standardize features before reduction
|
1622
2366
|
features_scaled = StandardScaler().fit_transform(features)
|
1623
|
-
|
2367
|
+
|
2368
|
+
# Apply PCA preprocessing automatically for UMAP or TSNE
|
2369
|
+
# (only if the feature dimension is larger than the target PCA components)
|
2370
|
+
if technique in ["UMAP", "TSNE"] and features_scaled.shape[1] > pca_components:
|
2371
|
+
# Ensure pca_components doesn't exceed number of samples or features
|
2372
|
+
pca_components = min(pca_components, features_scaled.shape[0] - 1, features_scaled.shape[1])
|
2373
|
+
print(f"Applying PCA preprocessing to {pca_components} components before {technique}")
|
2374
|
+
pca = PCA(n_components=pca_components, random_state=42)
|
2375
|
+
features_scaled = pca.fit_transform(features_scaled)
|
2376
|
+
variance_explained = sum(pca.explained_variance_ratio_) * 100
|
2377
|
+
print(f"Variance explained by PCA: {variance_explained:.1f}%")
|
2378
|
+
|
2379
|
+
# Proceed with the selected dimensionality reduction technique
|
1624
2380
|
if technique == "UMAP":
|
1625
|
-
# UMAP: n_neighbors must be < n_samples
|
1626
2381
|
n_neighbors = min(params.get('n_neighbors', 15), len(features_scaled) - 1)
|
1627
|
-
|
1628
2382
|
reducer = UMAP(
|
1629
2383
|
n_components=2,
|
1630
2384
|
random_state=42,
|
@@ -1633,9 +2387,7 @@ class ExplorerWindow(QMainWindow):
|
|
1633
2387
|
metric=params.get('metric', 'cosine')
|
1634
2388
|
)
|
1635
2389
|
elif technique == "TSNE":
|
1636
|
-
# t-SNE: perplexity must be < n_samples
|
1637
2390
|
perplexity = min(params.get('perplexity', 30), len(features_scaled) - 1)
|
1638
|
-
|
1639
2391
|
reducer = TSNE(
|
1640
2392
|
n_components=2,
|
1641
2393
|
random_state=42,
|
@@ -1647,7 +2399,6 @@ class ExplorerWindow(QMainWindow):
|
|
1647
2399
|
elif technique == "PCA":
|
1648
2400
|
reducer = PCA(n_components=2, random_state=42)
|
1649
2401
|
else:
|
1650
|
-
# Unknown technique
|
1651
2402
|
return None
|
1652
2403
|
|
1653
2404
|
# Fit and transform the features
|
@@ -1669,43 +2420,84 @@ class ExplorerWindow(QMainWindow):
|
|
1669
2420
|
item.embedding_y = (norm_y * scale_factor) - (scale_factor / 2)
|
1670
2421
|
|
1671
2422
|
def run_embedding_pipeline(self):
|
1672
|
-
"""
|
1673
|
-
|
2423
|
+
"""
|
2424
|
+
Orchestrates feature extraction and dimensionality reduction.
|
2425
|
+
If the EmbeddingViewer is in isolate mode, it will use only the visible
|
2426
|
+
(isolated) points as input for the pipeline.
|
2427
|
+
"""
|
2428
|
+
items_to_embed = []
|
2429
|
+
if self.embedding_viewer.isolated_mode:
|
2430
|
+
items_to_embed = [point.data_item for point in self.embedding_viewer.isolated_points]
|
2431
|
+
else:
|
2432
|
+
items_to_embed = self.current_data_items
|
2433
|
+
|
2434
|
+
if not items_to_embed:
|
2435
|
+
print("No items to process for embedding.")
|
1674
2436
|
return
|
1675
|
-
|
2437
|
+
|
1676
2438
|
self.annotation_viewer.clear_selection()
|
1677
|
-
if self.annotation_viewer.isolated_mode:
|
2439
|
+
if self.annotation_viewer.isolated_mode:
|
1678
2440
|
self.annotation_viewer.show_all_annotations()
|
1679
|
-
|
2441
|
+
|
1680
2442
|
self.embedding_viewer.render_selection_from_ids(set())
|
1681
2443
|
self.update_button_states()
|
1682
2444
|
|
1683
|
-
|
2445
|
+
self.current_embedding_model_info = self.model_settings_widget.get_selected_model()
|
2446
|
+
|
1684
2447
|
embedding_params = self.embedding_settings_widget.get_embedding_parameters()
|
1685
|
-
|
1686
|
-
|
1687
|
-
|
2448
|
+
selected_model, selected_feature_mode = self.current_embedding_model_info
|
2449
|
+
|
2450
|
+
# If the model name is a path, use only its base name.
|
2451
|
+
if os.path.sep in selected_model or '/' in selected_model:
|
2452
|
+
sanitized_model_name = os.path.basename(selected_model)
|
2453
|
+
else:
|
2454
|
+
sanitized_model_name = selected_model
|
2455
|
+
|
2456
|
+
# Replace characters that might be problematic in filenames
|
2457
|
+
sanitized_model_name = sanitized_model_name.replace(' ', '_')
|
2458
|
+
# Also replace the forward slash to handle "N/A"
|
2459
|
+
sanitized_feature_mode = selected_feature_mode.replace(' ', '_').replace('/', '_')
|
2460
|
+
|
2461
|
+
model_key = f"{sanitized_model_name}_{sanitized_feature_mode}"
|
1688
2462
|
|
1689
2463
|
QApplication.setOverrideCursor(Qt.WaitCursor)
|
1690
|
-
progress_bar = ProgressBar(self, "
|
2464
|
+
progress_bar = ProgressBar(self, "Processing Annotations")
|
1691
2465
|
progress_bar.show()
|
2466
|
+
|
1692
2467
|
try:
|
1693
|
-
|
1694
|
-
|
1695
|
-
|
1696
|
-
|
1697
|
-
|
1698
|
-
self.
|
1699
|
-
|
1700
|
-
|
1701
|
-
|
1702
|
-
|
2468
|
+
progress_bar.set_busy_mode("Checking feature cache...")
|
2469
|
+
cached_features, items_to_process = self.feature_store.get_features(items_to_embed, model_key)
|
2470
|
+
print(f"Found {len(cached_features)} features in cache. Need to compute {len(items_to_process)}.")
|
2471
|
+
|
2472
|
+
if items_to_process:
|
2473
|
+
newly_extracted_features, valid_items_processed = self._extract_features(items_to_process,
|
2474
|
+
progress_bar=progress_bar)
|
2475
|
+
if len(newly_extracted_features) > 0:
|
2476
|
+
progress_bar.set_busy_mode("Saving new features to cache...")
|
2477
|
+
self.feature_store.add_features(valid_items_processed, newly_extracted_features, model_key)
|
2478
|
+
new_features_dict = {item.annotation.id: vec for item, vec in zip(valid_items_processed,
|
2479
|
+
newly_extracted_features)}
|
2480
|
+
cached_features.update(new_features_dict)
|
2481
|
+
|
2482
|
+
if not cached_features:
|
2483
|
+
print("No features found or computed. Aborting.")
|
1703
2484
|
return
|
1704
2485
|
|
2486
|
+
final_feature_list = []
|
2487
|
+
final_data_items = []
|
2488
|
+
for item in items_to_embed:
|
2489
|
+
if item.annotation.id in cached_features:
|
2490
|
+
final_feature_list.append(cached_features[item.annotation.id])
|
2491
|
+
final_data_items.append(item)
|
2492
|
+
|
2493
|
+
features = np.array(final_feature_list)
|
2494
|
+
self.current_data_items = final_data_items
|
2495
|
+
self.annotation_viewer.update_annotations(self.current_data_items)
|
2496
|
+
|
1705
2497
|
progress_bar.set_busy_mode("Running dimensionality reduction...")
|
1706
2498
|
embedded_features = self._run_dimensionality_reduction(features, embedding_params)
|
1707
|
-
|
1708
|
-
if embedded_features is None:
|
2499
|
+
|
2500
|
+
if embedded_features is None:
|
1709
2501
|
return
|
1710
2502
|
|
1711
2503
|
progress_bar.set_busy_mode("Updating visualization...")
|
@@ -1713,6 +2505,21 @@ class ExplorerWindow(QMainWindow):
|
|
1713
2505
|
self.embedding_viewer.update_embeddings(self.current_data_items)
|
1714
2506
|
self.embedding_viewer.show_embedding()
|
1715
2507
|
self.embedding_viewer.fit_view_to_points()
|
2508
|
+
|
2509
|
+
# Check if confidence scores are available to enable sorting
|
2510
|
+
_, feature_mode = self.current_embedding_model_info
|
2511
|
+
is_predict_mode = feature_mode == "Predictions"
|
2512
|
+
self.annotation_viewer.set_confidence_sort_availability(is_predict_mode)
|
2513
|
+
|
2514
|
+
# If using Predictions mode, update data items with probabilities for confidence sorting
|
2515
|
+
if is_predict_mode:
|
2516
|
+
for item in self.current_data_items:
|
2517
|
+
if item.annotation.id in cached_features:
|
2518
|
+
item.prediction_probabilities = cached_features[item.annotation.id]
|
2519
|
+
|
2520
|
+
# When a new embedding is run, any previous similarity sort becomes irrelevant
|
2521
|
+
self.annotation_viewer.active_ordered_ids = []
|
2522
|
+
|
1716
2523
|
finally:
|
1717
2524
|
QApplication.restoreOverrideCursor()
|
1718
2525
|
progress_bar.finish_progress()
|
@@ -1724,10 +2531,18 @@ class ExplorerWindow(QMainWindow):
|
|
1724
2531
|
QApplication.setOverrideCursor(Qt.WaitCursor)
|
1725
2532
|
try:
|
1726
2533
|
self.current_data_items = self.get_filtered_data_items()
|
1727
|
-
self.current_features = None
|
2534
|
+
self.current_features = None
|
1728
2535
|
self.annotation_viewer.update_annotations(self.current_data_items)
|
1729
2536
|
self.embedding_viewer.clear_points()
|
1730
2537
|
self.embedding_viewer.show_placeholder()
|
2538
|
+
|
2539
|
+
# Reset sort options when filters change
|
2540
|
+
self.annotation_viewer.active_ordered_ids = []
|
2541
|
+
self.annotation_viewer.set_confidence_sort_availability(False)
|
2542
|
+
|
2543
|
+
# Update the annotation count in the label window
|
2544
|
+
self.label_window.update_annotation_count()
|
2545
|
+
|
1731
2546
|
finally:
|
1732
2547
|
QApplication.restoreOverrideCursor()
|
1733
2548
|
|
@@ -1737,65 +2552,120 @@ class ExplorerWindow(QMainWindow):
|
|
1737
2552
|
self.annotation_viewer.apply_preview_label_to_selected(label)
|
1738
2553
|
self.update_button_states()
|
1739
2554
|
|
2555
|
+
def delete_data_items(self, data_items_to_delete):
|
2556
|
+
"""
|
2557
|
+
Permanently deletes a list of data items and their associated annotations
|
2558
|
+
and visual components from the explorer and the main application.
|
2559
|
+
"""
|
2560
|
+
if not data_items_to_delete:
|
2561
|
+
return
|
2562
|
+
|
2563
|
+
print(f"Permanently deleting {len(data_items_to_delete)} item(s).")
|
2564
|
+
QApplication.setOverrideCursor(Qt.WaitCursor)
|
2565
|
+
try:
|
2566
|
+
deleted_ann_ids = {item.annotation.id for item in data_items_to_delete}
|
2567
|
+
annotations_to_delete_from_main_app = [item.annotation for item in data_items_to_delete]
|
2568
|
+
|
2569
|
+
# 1. Delete from the main application's data store
|
2570
|
+
self.annotation_window.delete_annotations(annotations_to_delete_from_main_app)
|
2571
|
+
|
2572
|
+
# 2. Remove from Explorer's internal data structures
|
2573
|
+
self.current_data_items = [
|
2574
|
+
item for item in self.current_data_items if item.annotation.id not in deleted_ann_ids
|
2575
|
+
]
|
2576
|
+
for ann_id in deleted_ann_ids:
|
2577
|
+
if ann_id in self.data_item_cache:
|
2578
|
+
del self.data_item_cache[ann_id]
|
2579
|
+
|
2580
|
+
# 3. Remove from AnnotationViewer
|
2581
|
+
blocker = QSignalBlocker(self.annotation_viewer) # Block signals during mass removal
|
2582
|
+
for ann_id in deleted_ann_ids:
|
2583
|
+
if ann_id in self.annotation_viewer.annotation_widgets_by_id:
|
2584
|
+
widget = self.annotation_viewer.annotation_widgets_by_id.pop(ann_id)
|
2585
|
+
if widget in self.annotation_viewer.selected_widgets:
|
2586
|
+
self.annotation_viewer.selected_widgets.remove(widget)
|
2587
|
+
widget.setParent(None)
|
2588
|
+
widget.deleteLater()
|
2589
|
+
blocker.unblock()
|
2590
|
+
self.annotation_viewer.recalculate_widget_positions()
|
2591
|
+
|
2592
|
+
# 4. Remove from EmbeddingViewer
|
2593
|
+
blocker = QSignalBlocker(self.embedding_viewer.graphics_scene)
|
2594
|
+
for ann_id in deleted_ann_ids:
|
2595
|
+
if ann_id in self.embedding_viewer.points_by_id:
|
2596
|
+
point = self.embedding_viewer.points_by_id.pop(ann_id)
|
2597
|
+
self.embedding_viewer.graphics_scene.removeItem(point)
|
2598
|
+
blocker.unblock()
|
2599
|
+
self.embedding_viewer.on_selection_changed() # Trigger update of selection state
|
2600
|
+
|
2601
|
+
# 5. Update UI
|
2602
|
+
self.update_label_window_selection()
|
2603
|
+
self.update_button_states()
|
2604
|
+
|
2605
|
+
# 6. Refresh main window annotations list
|
2606
|
+
affected_images = {ann.image_path for ann in annotations_to_delete_from_main_app}
|
2607
|
+
for image_path in affected_images:
|
2608
|
+
self.image_window.update_image_annotations(image_path)
|
2609
|
+
self.annotation_window.load_annotations()
|
2610
|
+
|
2611
|
+
except Exception as e:
|
2612
|
+
print(f"Error during item deletion: {e}")
|
2613
|
+
finally:
|
2614
|
+
QApplication.restoreOverrideCursor()
|
2615
|
+
|
1740
2616
|
def clear_preview_changes(self):
|
1741
2617
|
"""
|
1742
|
-
Clears all preview changes in
|
1743
|
-
and items marked for deletion.
|
2618
|
+
Clears all preview changes in the annotation viewer and updates tooltips.
|
1744
2619
|
"""
|
1745
2620
|
if hasattr(self, 'annotation_viewer'):
|
1746
2621
|
self.annotation_viewer.clear_preview_states()
|
1747
2622
|
|
1748
|
-
|
1749
|
-
self.
|
2623
|
+
# After reverting, tooltips need to be updated to reflect original labels
|
2624
|
+
for widget in self.annotation_viewer.annotation_widgets_by_id.values():
|
2625
|
+
widget.update_tooltip()
|
2626
|
+
for point in self.embedding_viewer.points_by_id.values():
|
2627
|
+
point.update_tooltip()
|
1750
2628
|
|
1751
2629
|
# After reverting all changes, update the button states
|
1752
2630
|
self.update_button_states()
|
1753
2631
|
print("Cleared all pending changes.")
|
1754
2632
|
|
1755
2633
|
def update_button_states(self):
|
1756
|
-
"""Update the state of Clear Preview and
|
2634
|
+
"""Update the state of Clear Preview, Apply, and Find Similar buttons."""
|
1757
2635
|
has_changes = self.annotation_viewer.has_preview_changes()
|
1758
2636
|
self.clear_preview_button.setEnabled(has_changes)
|
1759
2637
|
self.apply_button.setEnabled(has_changes)
|
2638
|
+
|
2639
|
+
# Update tooltips with a summary of changes
|
1760
2640
|
summary = self.annotation_viewer.get_preview_changes_summary()
|
1761
2641
|
self.clear_preview_button.setToolTip(f"Clear all preview changes - {summary}")
|
1762
2642
|
self.apply_button.setToolTip(f"Apply changes - {summary}")
|
1763
2643
|
|
2644
|
+
# Logic for the "Find Similar" button
|
2645
|
+
selection_exists = bool(self.annotation_viewer.selected_widgets)
|
2646
|
+
embedding_exists = bool(self.embedding_viewer.points_by_id) and self.current_embedding_model_info is not None
|
2647
|
+
self.annotation_viewer.find_similar_button.setEnabled(selection_exists and embedding_exists)
|
2648
|
+
|
1764
2649
|
def apply(self):
|
1765
2650
|
"""
|
1766
|
-
Apply all pending
|
1767
|
-
to the main application's data.
|
2651
|
+
Apply all pending label modifications to the main application's data.
|
1768
2652
|
"""
|
1769
2653
|
QApplication.setOverrideCursor(Qt.WaitCursor)
|
1770
2654
|
try:
|
1771
|
-
#
|
1772
|
-
items_to_delete = [item for item in self.current_data_items if item.is_marked_for_deletion()]
|
1773
|
-
items_to_keep = [item for item in self.current_data_items if not item.is_marked_for_deletion()]
|
1774
|
-
|
1775
|
-
# --- 1. Process Deletions ---
|
1776
|
-
deleted_annotations = []
|
1777
|
-
if items_to_delete:
|
1778
|
-
deleted_annotations = [item.annotation for item in items_to_delete]
|
1779
|
-
print(f"Permanently deleting {len(deleted_annotations)} annotation(s).")
|
1780
|
-
self.annotation_window.delete_annotations(deleted_annotations)
|
1781
|
-
|
1782
|
-
# --- 2. Process Label Changes on remaining items ---
|
2655
|
+
# --- 1. Process Label Changes ---
|
1783
2656
|
applied_label_changes = []
|
1784
|
-
|
2657
|
+
# Iterate over all current data items
|
2658
|
+
for item in self.current_data_items:
|
1785
2659
|
if item.apply_preview_permanently():
|
1786
2660
|
applied_label_changes.append(item.annotation)
|
1787
2661
|
|
1788
|
-
# ---
|
1789
|
-
if not
|
2662
|
+
# --- 2. Update UI if any changes were made ---
|
2663
|
+
if not applied_label_changes:
|
1790
2664
|
print("No pending changes to apply.")
|
1791
2665
|
return
|
1792
2666
|
|
1793
|
-
# Update the Explorer's internal list of data items
|
1794
|
-
self.current_data_items = items_to_keep
|
1795
|
-
|
1796
2667
|
# Update the main application's data and UI
|
1797
|
-
|
1798
|
-
affected_images = {ann.image_path for ann in all_affected_annotations}
|
2668
|
+
affected_images = {ann.image_path for ann in applied_label_changes}
|
1799
2669
|
for image_path in affected_images:
|
1800
2670
|
self.image_window.update_image_annotations(image_path)
|
1801
2671
|
self.annotation_window.load_annotations()
|
@@ -1808,13 +2678,13 @@ class ExplorerWindow(QMainWindow):
|
|
1808
2678
|
self.update_label_window_selection()
|
1809
2679
|
self.update_button_states()
|
1810
2680
|
|
1811
|
-
print(
|
2681
|
+
print("Applied changes successfully.")
|
1812
2682
|
|
1813
2683
|
except Exception as e:
|
1814
2684
|
print(f"Error applying modifications: {e}")
|
1815
2685
|
finally:
|
1816
2686
|
QApplication.restoreOverrideCursor()
|
1817
|
-
|
2687
|
+
|
1818
2688
|
def _cleanup_resources(self):
|
1819
2689
|
"""Clean up resources."""
|
1820
2690
|
self.loaded_model = None
|