coralnet-toolbox 0.0.68__py2.py3-none-any.whl → 0.0.69__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/Explorer/QtDataItem.py +55 -16
- coralnet_toolbox/Explorer/QtExplorer.py +1151 -336
- coralnet_toolbox/Explorer/QtFeatureStore.py +176 -0
- coralnet_toolbox/Explorer/QtSettingsWidgets.py +207 -24
- coralnet_toolbox/QtEventFilter.py +18 -17
- coralnet_toolbox/QtMainWindow.py +59 -7
- coralnet_toolbox/__init__.py +1 -1
- {coralnet_toolbox-0.0.68.dist-info → coralnet_toolbox-0.0.69.dist-info}/METADATA +4 -2
- {coralnet_toolbox-0.0.68.dist-info → coralnet_toolbox-0.0.69.dist-info}/RECORD +13 -12
- {coralnet_toolbox-0.0.68.dist-info → coralnet_toolbox-0.0.69.dist-info}/WHEEL +0 -0
- {coralnet_toolbox-0.0.68.dist-info → coralnet_toolbox-0.0.69.dist-info}/entry_points.txt +0 -0
- {coralnet_toolbox-0.0.68.dist-info → coralnet_toolbox-0.0.69.dist-info}/licenses/LICENSE.txt +0 -0
- {coralnet_toolbox-0.0.68.dist-info → coralnet_toolbox-0.0.69.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,81 +276,72 @@ 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):
|
@@ -230,7 +372,7 @@ class EmbeddingViewer(QWidget):
|
|
230
372
|
else:
|
231
373
|
self.graphics_view.setDragMode(QGraphicsView.NoDrag)
|
232
374
|
QGraphicsView.mousePressEvent(self.graphics_view, event)
|
233
|
-
|
375
|
+
|
234
376
|
def mouseDoubleClickEvent(self, event):
|
235
377
|
"""Handle double-click to clear selection and reset the main view."""
|
236
378
|
if event.button() == Qt.LeftButton:
|
@@ -281,7 +423,7 @@ class EmbeddingViewer(QWidget):
|
|
281
423
|
else:
|
282
424
|
QGraphicsView.mouseReleaseEvent(self.graphics_view, event)
|
283
425
|
self.graphics_view.setDragMode(QGraphicsView.NoDrag)
|
284
|
-
|
426
|
+
|
285
427
|
def wheelEvent(self, event):
|
286
428
|
"""Handle mouse wheel for zooming."""
|
287
429
|
zoom_in_factor = 1.25
|
@@ -308,88 +450,74 @@ class EmbeddingViewer(QWidget):
|
|
308
450
|
self.graphics_view.translate(delta.x(), delta.y())
|
309
451
|
|
310
452
|
def update_embeddings(self, data_items):
|
311
|
-
"""Update the embedding visualization. Creates an EmbeddingPointItem for
|
453
|
+
"""Update the embedding visualization. Creates an EmbeddingPointItem for
|
312
454
|
each AnnotationDataItem and links them."""
|
455
|
+
# Reset isolation state when loading new points
|
456
|
+
if self.isolated_mode:
|
457
|
+
self.show_all_points()
|
458
|
+
|
313
459
|
self.clear_points()
|
314
460
|
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
461
|
point = EmbeddingPointItem(item)
|
318
462
|
self.graphics_scene.addItem(point)
|
319
463
|
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
464
|
|
329
|
-
#
|
330
|
-
|
465
|
+
# Ensure buttons are in the correct initial state
|
466
|
+
self._update_toolbar_state()
|
331
467
|
|
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
468
|
def clear_points(self):
|
345
469
|
"""Clear all embedding points from the scene."""
|
470
|
+
if self.isolated_mode:
|
471
|
+
self.show_all_points()
|
472
|
+
|
346
473
|
for point in self.points_by_id.values():
|
347
474
|
self.graphics_scene.removeItem(point)
|
348
475
|
self.points_by_id.clear()
|
476
|
+
self._update_toolbar_state()
|
349
477
|
|
350
478
|
def on_selection_changed(self):
|
351
479
|
"""
|
352
480
|
Handles selection changes in the scene. Updates the central data model
|
353
481
|
and emits a signal to notify other parts of the application.
|
354
482
|
"""
|
355
|
-
if not self.graphics_scene:
|
483
|
+
if not self.graphics_scene:
|
356
484
|
return
|
357
485
|
try:
|
358
486
|
selected_items = self.graphics_scene.selectedItems()
|
359
487
|
except RuntimeError:
|
360
488
|
return
|
361
|
-
|
489
|
+
|
362
490
|
current_selection_ids = {item.data_item.annotation.id for item in selected_items}
|
363
491
|
|
364
492
|
if current_selection_ids != self.previous_selection_ids:
|
365
|
-
# Update the central model (AnnotationDataItem) for all points
|
366
493
|
for point_id, point in self.points_by_id.items():
|
367
494
|
is_selected = point_id in current_selection_ids
|
368
|
-
# The data_item is the single source of truth
|
369
495
|
point.data_item.set_selected(is_selected)
|
370
496
|
|
371
497
|
self.selection_changed.emit(list(current_selection_ids))
|
372
498
|
self.previous_selection_ids = current_selection_ids
|
373
499
|
|
374
|
-
|
375
|
-
if hasattr(self, 'animation_timer') and self.animation_timer:
|
500
|
+
if hasattr(self, 'animation_timer') and self.animation_timer:
|
376
501
|
self.animation_timer.stop()
|
377
|
-
|
502
|
+
|
378
503
|
for point in self.points_by_id.values():
|
379
504
|
if not point.isSelected():
|
380
505
|
point.setPen(QPen(QColor("black"), POINT_WIDTH))
|
381
506
|
if selected_items and hasattr(self, 'animation_timer') and self.animation_timer:
|
382
507
|
self.animation_timer.start()
|
383
508
|
|
509
|
+
# Update button states based on new selection
|
510
|
+
self._update_toolbar_state()
|
511
|
+
|
384
512
|
def animate_selection(self):
|
385
513
|
"""Animate selected points with a marching ants effect."""
|
386
|
-
if not self.graphics_scene:
|
514
|
+
if not self.graphics_scene:
|
387
515
|
return
|
388
516
|
try:
|
389
517
|
selected_items = self.graphics_scene.selectedItems()
|
390
518
|
except RuntimeError:
|
391
519
|
return
|
392
|
-
|
520
|
+
|
393
521
|
self.animation_offset = (self.animation_offset + 1) % 20
|
394
522
|
for item in selected_items:
|
395
523
|
# Get the color directly from the source of truth
|
@@ -400,23 +528,23 @@ class EmbeddingViewer(QWidget):
|
|
400
528
|
animated_pen.setDashPattern([1, 2])
|
401
529
|
animated_pen.setDashOffset(self.animation_offset)
|
402
530
|
item.setPen(animated_pen)
|
403
|
-
|
531
|
+
|
404
532
|
def render_selection_from_ids(self, selected_ids):
|
405
533
|
"""
|
406
534
|
Updates the visual selection of points based on a set of annotation IDs
|
407
535
|
provided by an external controller.
|
408
536
|
"""
|
409
537
|
blocker = QSignalBlocker(self.graphics_scene)
|
410
|
-
|
538
|
+
|
411
539
|
for ann_id, point in self.points_by_id.items():
|
412
540
|
is_selected = ann_id in selected_ids
|
413
541
|
# 1. Update the state on the central data item
|
414
542
|
point.data_item.set_selected(is_selected)
|
415
543
|
# 2. Update the selection state of the graphics item itself
|
416
544
|
point.setSelected(is_selected)
|
417
|
-
|
545
|
+
|
418
546
|
blocker.unblock()
|
419
|
-
|
547
|
+
|
420
548
|
# Manually trigger on_selection_changed to update animation and emit signals
|
421
549
|
self.on_selection_changed()
|
422
550
|
|
@@ -426,20 +554,21 @@ class EmbeddingViewer(QWidget):
|
|
426
554
|
self.graphics_view.fitInView(self.graphics_scene.itemsBoundingRect(), Qt.KeepAspectRatio)
|
427
555
|
else:
|
428
556
|
self.graphics_view.fitInView(-2500, -2500, 5000, 5000, Qt.KeepAspectRatio)
|
429
|
-
|
557
|
+
|
430
558
|
|
431
559
|
class AnnotationViewer(QScrollArea):
|
432
|
-
"""Scrollable grid widget for displaying annotation image crops with selection,
|
560
|
+
"""Scrollable grid widget for displaying annotation image crops with selection,
|
433
561
|
filtering, and isolation support. Acts as a controller for the widgets."""
|
434
562
|
selection_changed = pyqtSignal(list)
|
435
563
|
preview_changed = pyqtSignal(list)
|
436
564
|
reset_view_requested = pyqtSignal()
|
565
|
+
find_similar_requested = pyqtSignal()
|
437
566
|
|
438
567
|
def __init__(self, parent=None):
|
439
568
|
"""Initialize the AnnotationViewer widget."""
|
440
569
|
super(AnnotationViewer, self).__init__(parent)
|
441
570
|
self.explorer_window = parent
|
442
|
-
|
571
|
+
|
443
572
|
self.annotation_widgets_by_id = {}
|
444
573
|
self.selected_widgets = []
|
445
574
|
self.last_selected_index = -1
|
@@ -453,6 +582,11 @@ class AnnotationViewer(QScrollArea):
|
|
453
582
|
self.original_label_assignments = {}
|
454
583
|
self.isolated_mode = False
|
455
584
|
self.isolated_widgets = set()
|
585
|
+
|
586
|
+
# State for new sorting options
|
587
|
+
self.active_ordered_ids = []
|
588
|
+
self.is_confidence_sort_available = False
|
589
|
+
|
456
590
|
self.setup_ui()
|
457
591
|
|
458
592
|
def setup_ui(self):
|
@@ -485,9 +619,35 @@ class AnnotationViewer(QScrollArea):
|
|
485
619
|
sort_label = QLabel("Sort By:")
|
486
620
|
toolbar_layout.addWidget(sort_label)
|
487
621
|
self.sort_combo = QComboBox()
|
488
|
-
|
622
|
+
# Remove "Similarity" as it's now an implicit action
|
623
|
+
self.sort_combo.addItems(["None", "Label", "Image", "Confidence"])
|
624
|
+
self.sort_combo.insertSeparator(3) # Add separator before "Confidence"
|
489
625
|
self.sort_combo.currentTextChanged.connect(self.on_sort_changed)
|
490
626
|
toolbar_layout.addWidget(self.sort_combo)
|
627
|
+
|
628
|
+
toolbar_layout.addWidget(self._create_separator())
|
629
|
+
|
630
|
+
self.find_similar_button = QToolButton()
|
631
|
+
self.find_similar_button.setText("Find Similar")
|
632
|
+
self.find_similar_button.setToolTip("Find annotations visually similar to the selection.")
|
633
|
+
self.find_similar_button.setPopupMode(QToolButton.MenuButtonPopup)
|
634
|
+
self.find_similar_button.setToolButtonStyle(Qt.ToolButtonTextOnly)
|
635
|
+
self.find_similar_button.setStyleSheet(
|
636
|
+
"QToolButton::menu-indicator { subcontrol-position: right center; subcontrol-origin: padding; left: -4px; }"
|
637
|
+
)
|
638
|
+
|
639
|
+
run_similar_action = QAction("Find Similar", self)
|
640
|
+
run_similar_action.triggered.connect(self.find_similar_requested.emit)
|
641
|
+
self.find_similar_button.setDefaultAction(run_similar_action)
|
642
|
+
|
643
|
+
self.similarity_settings_widget = SimilaritySettingsWidget()
|
644
|
+
settings_menu = QMenu(self)
|
645
|
+
widget_action = QWidgetAction(settings_menu)
|
646
|
+
widget_action.setDefaultWidget(self.similarity_settings_widget)
|
647
|
+
settings_menu.addAction(widget_action)
|
648
|
+
self.find_similar_button.setMenu(settings_menu)
|
649
|
+
toolbar_layout.addWidget(self.find_similar_button)
|
650
|
+
|
491
651
|
toolbar_layout.addStretch()
|
492
652
|
|
493
653
|
size_label = QLabel("Size:")
|
@@ -515,7 +675,51 @@ class AnnotationViewer(QScrollArea):
|
|
515
675
|
|
516
676
|
main_layout.addWidget(content_scroll)
|
517
677
|
self.setWidget(main_container)
|
678
|
+
|
679
|
+
# Set the initial state of the sort options
|
680
|
+
self._update_sort_options_state()
|
518
681
|
self._update_toolbar_state()
|
682
|
+
|
683
|
+
def _create_separator(self):
|
684
|
+
"""Creates a vertical separator for the toolbar."""
|
685
|
+
separator = QLabel("|")
|
686
|
+
separator.setStyleSheet("color: gray; margin: 0 5px;")
|
687
|
+
return separator
|
688
|
+
|
689
|
+
def _update_sort_options_state(self):
|
690
|
+
"""Enable/disable sort options based on available data."""
|
691
|
+
model = self.sort_combo.model()
|
692
|
+
|
693
|
+
# Enable/disable "Confidence" option
|
694
|
+
confidence_item_index = self.sort_combo.findText("Confidence")
|
695
|
+
if confidence_item_index != -1:
|
696
|
+
model.item(confidence_item_index).setEnabled(self.is_confidence_sort_available)
|
697
|
+
|
698
|
+
def handle_annotation_context_menu(self, widget, event):
|
699
|
+
"""Handle context menu requests (e.g., right-click) on an annotation widget."""
|
700
|
+
if event.modifiers() == Qt.ControlModifier:
|
701
|
+
explorer = self.explorer_window
|
702
|
+
image_path = widget.annotation.image_path
|
703
|
+
annotation_to_select = widget.annotation
|
704
|
+
|
705
|
+
if hasattr(explorer, 'annotation_window'):
|
706
|
+
# Check if the image needs to be changed
|
707
|
+
if explorer.annotation_window.current_image_path != image_path:
|
708
|
+
if hasattr(explorer.annotation_window, 'set_image'):
|
709
|
+
explorer.annotation_window.set_image(image_path)
|
710
|
+
|
711
|
+
# Now, select the annotation in the annotation_window
|
712
|
+
if hasattr(explorer.annotation_window, 'select_annotation'):
|
713
|
+
# This method by default unselects other annotations
|
714
|
+
explorer.annotation_window.select_annotation(annotation_to_select)
|
715
|
+
|
716
|
+
# Also clear any existing selection in the explorer window itself
|
717
|
+
explorer.annotation_viewer.clear_selection()
|
718
|
+
explorer.embedding_viewer.render_selection_from_ids(set())
|
719
|
+
explorer.update_label_window_selection()
|
720
|
+
explorer.update_button_states()
|
721
|
+
|
722
|
+
event.accept()
|
519
723
|
|
520
724
|
@pyqtSlot()
|
521
725
|
def isolate_selection(self):
|
@@ -537,23 +741,56 @@ class AnnotationViewer(QScrollArea):
|
|
537
741
|
self._update_toolbar_state()
|
538
742
|
self.explorer_window.main_window.label_window.update_annotation_count()
|
539
743
|
|
744
|
+
def display_and_isolate_ordered_results(self, ordered_ids):
|
745
|
+
"""
|
746
|
+
Isolates the view to a specific set of ordered widgets, ensuring the
|
747
|
+
grid is always updated. This is the new primary method for showing
|
748
|
+
similarity results.
|
749
|
+
"""
|
750
|
+
self.active_ordered_ids = ordered_ids
|
751
|
+
|
752
|
+
# Render the selection based on the new order
|
753
|
+
self.render_selection_from_ids(set(ordered_ids))
|
754
|
+
|
755
|
+
# Now, perform the isolation logic directly to bypass the guard clause
|
756
|
+
self.isolated_widgets = set(self.selected_widgets)
|
757
|
+
self.content_widget.setUpdatesEnabled(False)
|
758
|
+
try:
|
759
|
+
for widget in self.annotation_widgets_by_id.values():
|
760
|
+
# Show widget if it's in our target set, hide otherwise
|
761
|
+
if widget in self.isolated_widgets:
|
762
|
+
widget.show()
|
763
|
+
else:
|
764
|
+
widget.hide()
|
765
|
+
|
766
|
+
self.isolated_mode = True
|
767
|
+
self.recalculate_widget_positions() # Crucial grid update
|
768
|
+
finally:
|
769
|
+
self.content_widget.setUpdatesEnabled(True)
|
770
|
+
|
771
|
+
self._update_toolbar_state()
|
772
|
+
self.explorer_window.main_window.label_window.update_annotation_count()
|
773
|
+
|
540
774
|
@pyqtSlot()
|
541
775
|
def show_all_annotations(self):
|
542
776
|
"""Shows all annotation widgets, exiting the isolated mode."""
|
543
777
|
if not self.isolated_mode:
|
544
778
|
return
|
545
|
-
|
779
|
+
|
546
780
|
self.isolated_mode = False
|
547
781
|
self.isolated_widgets.clear()
|
548
|
-
|
782
|
+
self.active_ordered_ids = [] # Clear similarity sort context
|
783
|
+
|
549
784
|
self.content_widget.setUpdatesEnabled(False)
|
550
785
|
try:
|
786
|
+
# Show all widgets that are managed by the viewer
|
551
787
|
for widget in self.annotation_widgets_by_id.values():
|
552
788
|
widget.show()
|
789
|
+
|
553
790
|
self.recalculate_widget_positions()
|
554
791
|
finally:
|
555
792
|
self.content_widget.setUpdatesEnabled(True)
|
556
|
-
|
793
|
+
|
557
794
|
self._update_toolbar_state()
|
558
795
|
self.explorer_window.main_window.label_window.update_annotation_count()
|
559
796
|
|
@@ -569,31 +806,47 @@ class AnnotationViewer(QScrollArea):
|
|
569
806
|
self.show_all_button.hide()
|
570
807
|
self.isolate_button.setEnabled(selection_exists)
|
571
808
|
|
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
809
|
def on_sort_changed(self, sort_type):
|
579
810
|
"""Handle sort type change."""
|
811
|
+
self.active_ordered_ids = [] # Clear any special ordering
|
580
812
|
self.recalculate_widget_positions()
|
581
813
|
|
814
|
+
def set_confidence_sort_availability(self, is_available):
|
815
|
+
"""Sets the availability of the confidence sort option."""
|
816
|
+
self.is_confidence_sort_available = is_available
|
817
|
+
self._update_sort_options_state()
|
818
|
+
|
582
819
|
def _get_sorted_widgets(self):
|
583
820
|
"""Get widgets sorted according to the current sort setting."""
|
821
|
+
# If a specific order is active (e.g., from similarity search), use it.
|
822
|
+
if self.active_ordered_ids:
|
823
|
+
widget_map = {w.data_item.annotation.id: w for w in self.annotation_widgets_by_id.values()}
|
824
|
+
ordered_widgets = [widget_map[ann_id] for ann_id in self.active_ordered_ids if ann_id in widget_map]
|
825
|
+
return ordered_widgets
|
826
|
+
|
827
|
+
# Otherwise, use the dropdown sort logic
|
584
828
|
sort_type = self.sort_combo.currentText()
|
585
829
|
widgets = list(self.annotation_widgets_by_id.values())
|
830
|
+
|
586
831
|
if sort_type == "Label":
|
587
832
|
widgets.sort(key=lambda w: w.data_item.effective_label.short_label_code)
|
588
833
|
elif sort_type == "Image":
|
589
834
|
widgets.sort(key=lambda w: os.path.basename(w.data_item.annotation.image_path))
|
835
|
+
elif sort_type == "Confidence":
|
836
|
+
# Sort by confidence, descending. Handles cases with no confidence gracefully.
|
837
|
+
widgets.sort(key=lambda w: w.data_item.get_effective_confidence(), reverse=True)
|
838
|
+
|
590
839
|
return widgets
|
591
840
|
|
592
841
|
def _group_widgets_by_sort_key(self, widgets):
|
593
842
|
"""Group widgets by the current sort key."""
|
594
843
|
sort_type = self.sort_combo.currentText()
|
595
|
-
if sort_type == "None":
|
844
|
+
if not self.active_ordered_ids and sort_type == "None":
|
845
|
+
return [("", widgets)]
|
846
|
+
|
847
|
+
if self.active_ordered_ids: # Don't show group headers for similarity results
|
596
848
|
return [("", widgets)]
|
849
|
+
|
597
850
|
groups = []
|
598
851
|
current_group = []
|
599
852
|
current_key = None
|
@@ -603,8 +856,9 @@ class AnnotationViewer(QScrollArea):
|
|
603
856
|
elif sort_type == "Image":
|
604
857
|
key = os.path.basename(widget.data_item.annotation.image_path)
|
605
858
|
else:
|
606
|
-
key = ""
|
607
|
-
|
859
|
+
key = "" # No headers for Confidence or None
|
860
|
+
|
861
|
+
if key and current_key != key:
|
608
862
|
if current_group:
|
609
863
|
groups.append((current_key, current_group))
|
610
864
|
current_group = [widget]
|
@@ -648,16 +902,16 @@ class AnnotationViewer(QScrollArea):
|
|
648
902
|
|
649
903
|
def on_size_changed(self, value):
|
650
904
|
"""Handle slider value change to resize annotation widgets."""
|
651
|
-
if value % 2 != 0:
|
905
|
+
if value % 2 != 0:
|
652
906
|
value -= 1
|
653
|
-
|
907
|
+
|
654
908
|
self.current_widget_size = value
|
655
909
|
self.size_value_label.setText(str(value))
|
656
910
|
self.content_widget.setUpdatesEnabled(False)
|
657
|
-
|
911
|
+
|
658
912
|
for widget in self.annotation_widgets_by_id.values():
|
659
913
|
widget.update_height(value)
|
660
|
-
|
914
|
+
|
661
915
|
self.content_widget.setUpdatesEnabled(True)
|
662
916
|
self.recalculate_widget_positions()
|
663
917
|
|
@@ -692,7 +946,7 @@ class AnnotationViewer(QScrollArea):
|
|
692
946
|
y += header_label.height() + spacing
|
693
947
|
x = spacing
|
694
948
|
max_height_in_row = 0
|
695
|
-
|
949
|
+
|
696
950
|
for widget in group_widgets:
|
697
951
|
widget_size = widget.size()
|
698
952
|
if x > spacing and x + widget_size.width() > available_width:
|
@@ -710,22 +964,22 @@ class AnnotationViewer(QScrollArea):
|
|
710
964
|
"""Update displayed annotations, creating new widgets for them."""
|
711
965
|
if self.isolated_mode:
|
712
966
|
self.show_all_annotations()
|
713
|
-
|
967
|
+
|
714
968
|
for widget in self.annotation_widgets_by_id.values():
|
715
969
|
widget.setParent(None)
|
716
970
|
widget.deleteLater()
|
717
|
-
|
971
|
+
|
718
972
|
self.annotation_widgets_by_id.clear()
|
719
973
|
self.selected_widgets.clear()
|
720
974
|
self.last_selected_index = -1
|
721
|
-
|
975
|
+
|
722
976
|
for data_item in data_items:
|
723
977
|
annotation_widget = AnnotationImageWidget(
|
724
978
|
data_item, self.current_widget_size, self, self.content_widget)
|
725
|
-
|
979
|
+
|
726
980
|
annotation_widget.show()
|
727
981
|
self.annotation_widgets_by_id[data_item.annotation.id] = annotation_widget
|
728
|
-
|
982
|
+
|
729
983
|
self.recalculate_widget_positions()
|
730
984
|
self._update_toolbar_state()
|
731
985
|
|
@@ -737,42 +991,23 @@ class AnnotationViewer(QScrollArea):
|
|
737
991
|
self._resize_timer.setSingleShot(True)
|
738
992
|
self._resize_timer.timeout.connect(self.recalculate_widget_positions)
|
739
993
|
self._resize_timer.start(100)
|
740
|
-
|
994
|
+
|
741
995
|
def keyPressEvent(self, event):
|
742
996
|
"""Handles key presses for deleting selected annotations."""
|
743
|
-
# Check if the pressed key is Delete/Backspace AND the Control key is held down
|
744
997
|
if event.key() in (Qt.Key_Delete, Qt.Key_Backspace) and event.modifiers() == Qt.ControlModifier:
|
745
|
-
# Proceed only if there are selected widgets
|
746
998
|
if not self.selected_widgets:
|
747
999
|
super().keyPressEvent(event)
|
748
1000
|
return
|
749
1001
|
|
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)
|
1002
|
+
# Extract the central data items from the selected widgets
|
1003
|
+
data_items_to_delete = [widget.data_item for widget in self.selected_widgets]
|
760
1004
|
|
761
|
-
#
|
762
|
-
|
763
|
-
|
764
|
-
# Recalculate the layout to fill in the empty space
|
765
|
-
self.recalculate_widget_positions()
|
766
|
-
|
767
|
-
# Emit a signal to notify the ExplorerWindow that the selection is now empty
|
768
|
-
# This will also clear the selection in the EmbeddingViewer
|
769
|
-
if changed_ids:
|
770
|
-
self.selection_changed.emit(changed_ids)
|
1005
|
+
# Delegate the actual deletion to the main ExplorerWindow
|
1006
|
+
if data_items_to_delete:
|
1007
|
+
self.explorer_window.delete_data_items(data_items_to_delete)
|
771
1008
|
|
772
|
-
# Accept the event to prevent it from being processed further
|
773
1009
|
event.accept()
|
774
1010
|
else:
|
775
|
-
# Pass any other key presses to the default handler
|
776
1011
|
super().keyPressEvent(event)
|
777
1012
|
|
778
1013
|
def mousePressEvent(self, event):
|
@@ -782,7 +1017,7 @@ class AnnotationViewer(QScrollArea):
|
|
782
1017
|
# If left click with no modifiers, check if click is outside widgets
|
783
1018
|
is_on_widget = False
|
784
1019
|
child_at_pos = self.childAt(event.pos())
|
785
|
-
|
1020
|
+
|
786
1021
|
if child_at_pos:
|
787
1022
|
widget = child_at_pos
|
788
1023
|
# Traverse up the parent chain to see if click is on an annotation widget
|
@@ -791,14 +1026,14 @@ class AnnotationViewer(QScrollArea):
|
|
791
1026
|
is_on_widget = True
|
792
1027
|
break
|
793
1028
|
widget = widget.parent()
|
794
|
-
|
1029
|
+
|
795
1030
|
# If click is outside widgets and there is a selection, clear it
|
796
1031
|
if not is_on_widget and self.selected_widgets:
|
797
1032
|
changed_ids = [w.data_item.annotation.id for w in self.selected_widgets]
|
798
1033
|
self.clear_selection()
|
799
1034
|
self.selection_changed.emit(changed_ids)
|
800
1035
|
return
|
801
|
-
|
1036
|
+
|
802
1037
|
elif event.modifiers() == Qt.ControlModifier:
|
803
1038
|
# Start rubber band selection with Ctrl+Left click
|
804
1039
|
self.selection_at_press = set(self.selected_widgets)
|
@@ -814,12 +1049,12 @@ class AnnotationViewer(QScrollArea):
|
|
814
1049
|
break
|
815
1050
|
widget = widget.parent()
|
816
1051
|
return
|
817
|
-
|
1052
|
+
|
818
1053
|
elif event.button() == Qt.RightButton:
|
819
1054
|
# Ignore right clicks
|
820
1055
|
event.ignore()
|
821
1056
|
return
|
822
|
-
|
1057
|
+
|
823
1058
|
# Default handler for other cases
|
824
1059
|
super().mousePressEvent(event)
|
825
1060
|
|
@@ -901,13 +1136,13 @@ class AnnotationViewer(QScrollArea):
|
|
901
1136
|
self.rubber_band.hide()
|
902
1137
|
self.rubber_band.deleteLater()
|
903
1138
|
self.rubber_band = None
|
904
|
-
|
1139
|
+
|
905
1140
|
self.selection_at_press = set()
|
906
1141
|
self.rubber_band_origin = None
|
907
1142
|
self.mouse_pressed_on_widget = False
|
908
1143
|
event.accept()
|
909
1144
|
return
|
910
|
-
|
1145
|
+
|
911
1146
|
super().mouseReleaseEvent(event)
|
912
1147
|
|
913
1148
|
def handle_annotation_selection(self, widget, event):
|
@@ -938,14 +1173,14 @@ class AnnotationViewer(QScrollArea):
|
|
938
1173
|
last_selected_widget = w
|
939
1174
|
except ValueError:
|
940
1175
|
continue
|
941
|
-
|
1176
|
+
|
942
1177
|
if last_selected_widget:
|
943
1178
|
last_selected_index_in_current_list = widget_list.index(last_selected_widget)
|
944
1179
|
start = min(last_selected_index_in_current_list, widget_index)
|
945
1180
|
end = max(last_selected_index_in_current_list, widget_index)
|
946
1181
|
else:
|
947
1182
|
start, end = widget_index, widget_index
|
948
|
-
|
1183
|
+
|
949
1184
|
# Select all widgets in the range
|
950
1185
|
for i in range(start, end + 1):
|
951
1186
|
if self.select_widget(widget_list[i]):
|
@@ -969,13 +1204,13 @@ class AnnotationViewer(QScrollArea):
|
|
969
1204
|
# No modifier: single selection
|
970
1205
|
else:
|
971
1206
|
newly_selected_id = widget.data_item.annotation.id
|
972
|
-
|
1207
|
+
|
973
1208
|
# Deselect all others
|
974
1209
|
for w in list(self.selected_widgets):
|
975
1210
|
if w.data_item.annotation.id != newly_selected_id:
|
976
1211
|
if self.deselect_widget(w):
|
977
1212
|
changed_ids.append(w.data_item.annotation.id)
|
978
|
-
|
1213
|
+
|
979
1214
|
# Select the clicked widget
|
980
1215
|
if self.select_widget(widget):
|
981
1216
|
changed_ids.append(newly_selected_id)
|
@@ -991,7 +1226,7 @@ class AnnotationViewer(QScrollArea):
|
|
991
1226
|
|
992
1227
|
def _update_isolation(self):
|
993
1228
|
"""Update the isolated view to show only currently selected widgets."""
|
994
|
-
if not self.isolated_mode:
|
1229
|
+
if not self.isolated_mode:
|
995
1230
|
return
|
996
1231
|
# If in isolated mode, only show selected widgets
|
997
1232
|
if self.selected_widgets:
|
@@ -999,12 +1234,12 @@ class AnnotationViewer(QScrollArea):
|
|
999
1234
|
self.setUpdatesEnabled(False)
|
1000
1235
|
try:
|
1001
1236
|
for widget in self.annotation_widgets_by_id.values():
|
1002
|
-
if widget not in self.isolated_widgets:
|
1237
|
+
if widget not in self.isolated_widgets:
|
1003
1238
|
widget.hide()
|
1004
|
-
else:
|
1239
|
+
else:
|
1005
1240
|
widget.show()
|
1006
1241
|
self.recalculate_widget_positions()
|
1007
|
-
|
1242
|
+
|
1008
1243
|
finally:
|
1009
1244
|
self.setUpdatesEnabled(True)
|
1010
1245
|
|
@@ -1038,7 +1273,7 @@ class AnnotationViewer(QScrollArea):
|
|
1038
1273
|
for widget in list(self.selected_widgets):
|
1039
1274
|
# This will internally call deselect_widget, which is fine
|
1040
1275
|
self.deselect_widget(widget)
|
1041
|
-
|
1276
|
+
|
1042
1277
|
self.selected_widgets.clear()
|
1043
1278
|
self._update_toolbar_state()
|
1044
1279
|
|
@@ -1056,10 +1291,10 @@ class AnnotationViewer(QScrollArea):
|
|
1056
1291
|
widget.data_item.set_selected(is_selected)
|
1057
1292
|
# 2. Tell the widget to update its visuals based on the new state
|
1058
1293
|
widget.update_selection_visuals()
|
1059
|
-
|
1294
|
+
|
1060
1295
|
# Resync internal list of selected widgets from the source of truth
|
1061
1296
|
self.selected_widgets = [w for w in self.annotation_widgets_by_id.values() if w.is_selected()]
|
1062
|
-
|
1297
|
+
|
1063
1298
|
if self.isolated_mode and self.selected_widgets:
|
1064
1299
|
self.isolated_widgets.update(self.selected_widgets)
|
1065
1300
|
for widget in self.annotation_widgets_by_id.values():
|
@@ -1071,23 +1306,23 @@ class AnnotationViewer(QScrollArea):
|
|
1071
1306
|
|
1072
1307
|
def apply_preview_label_to_selected(self, preview_label):
|
1073
1308
|
"""Apply a preview label and emit a signal for the embedding view to update."""
|
1074
|
-
if not self.selected_widgets or not preview_label:
|
1309
|
+
if not self.selected_widgets or not preview_label:
|
1075
1310
|
return
|
1076
1311
|
changed_ids = []
|
1077
1312
|
for widget in self.selected_widgets:
|
1078
1313
|
widget.data_item.set_preview_label(preview_label)
|
1079
1314
|
widget.update() # Force repaint with new color
|
1080
1315
|
changed_ids.append(widget.data_item.annotation.id)
|
1081
|
-
|
1082
|
-
if self.sort_combo.currentText() == "Label":
|
1316
|
+
|
1317
|
+
if self.sort_combo.currentText() == "Label":
|
1083
1318
|
self.recalculate_widget_positions()
|
1084
|
-
if changed_ids:
|
1319
|
+
if changed_ids:
|
1085
1320
|
self.preview_changed.emit(changed_ids)
|
1086
1321
|
|
1087
1322
|
def clear_preview_states(self):
|
1088
1323
|
"""
|
1089
|
-
Clears all preview states, including label changes
|
1090
|
-
|
1324
|
+
Clears all preview states, including label changes,
|
1325
|
+
reverting them to their original state.
|
1091
1326
|
"""
|
1092
1327
|
something_changed = False
|
1093
1328
|
for widget in self.annotation_widgets_by_id.values():
|
@@ -1097,12 +1332,6 @@ class AnnotationViewer(QScrollArea):
|
|
1097
1332
|
widget.update() # Repaint to show original color
|
1098
1333
|
something_changed = True
|
1099
1334
|
|
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
1335
|
if something_changed:
|
1107
1336
|
# Recalculate positions to update sorting and re-flow the layout
|
1108
1337
|
if self.sort_combo.currentText() in ("Label", "Image"):
|
@@ -1141,11 +1370,21 @@ class ExplorerWindow(QMainWindow):
|
|
1141
1370
|
self.annotation_window = main_window.annotation_window
|
1142
1371
|
|
1143
1372
|
self.device = main_window.device
|
1144
|
-
self.model_path = ""
|
1145
1373
|
self.loaded_model = None
|
1374
|
+
|
1375
|
+
self.feature_store = FeatureStore()
|
1376
|
+
|
1377
|
+
# Add a property to store the parameters with defaults
|
1378
|
+
self.mislabel_params = {'k': 20, 'threshold': 0.6}
|
1379
|
+
self.uncertainty_params = {'confidence': 0.6, 'margin': 0.1}
|
1380
|
+
self.similarity_params = {'k': 30}
|
1381
|
+
|
1382
|
+
self.data_item_cache = {} # Cache for AnnotationDataItem objects
|
1383
|
+
|
1146
1384
|
self.current_data_items = []
|
1147
1385
|
self.current_features = None
|
1148
1386
|
self.current_feature_generating_model = ""
|
1387
|
+
self.current_embedding_model_info = None
|
1149
1388
|
self._ui_initialized = False
|
1150
1389
|
|
1151
1390
|
self.setWindowTitle("Explorer")
|
@@ -1195,6 +1434,10 @@ class ExplorerWindow(QMainWindow):
|
|
1195
1434
|
# Call the main cancellation method to revert any pending changes
|
1196
1435
|
self.clear_preview_changes()
|
1197
1436
|
|
1437
|
+
# Clean up the feature store by deleting its files
|
1438
|
+
if hasattr(self, 'feature_store') and self.feature_store:
|
1439
|
+
self.feature_store.delete_storage()
|
1440
|
+
|
1198
1441
|
# Call the dedicated cleanup method
|
1199
1442
|
self._cleanup_resources()
|
1200
1443
|
|
@@ -1225,23 +1468,23 @@ class ExplorerWindow(QMainWindow):
|
|
1225
1468
|
# This ensures that the widgets are only created once per ExplorerWindow instance.
|
1226
1469
|
|
1227
1470
|
# Annotation settings panel (filters by image, type, label)
|
1228
|
-
if self.annotation_settings_widget is None:
|
1471
|
+
if self.annotation_settings_widget is None:
|
1229
1472
|
self.annotation_settings_widget = AnnotationSettingsWidget(self.main_window, self)
|
1230
|
-
|
1473
|
+
|
1231
1474
|
# Model selection panel (choose feature extraction model)
|
1232
|
-
if self.model_settings_widget is None:
|
1475
|
+
if self.model_settings_widget is None:
|
1233
1476
|
self.model_settings_widget = ModelSettingsWidget(self.main_window, self)
|
1234
|
-
|
1477
|
+
|
1235
1478
|
# Embedding settings panel (choose dimensionality reduction method)
|
1236
|
-
if self.embedding_settings_widget is None:
|
1479
|
+
if self.embedding_settings_widget is None:
|
1237
1480
|
self.embedding_settings_widget = EmbeddingSettingsWidget(self.main_window, self)
|
1238
|
-
|
1481
|
+
|
1239
1482
|
# Annotation viewer (shows annotation image crops in a grid)
|
1240
|
-
if self.annotation_viewer is None:
|
1483
|
+
if self.annotation_viewer is None:
|
1241
1484
|
self.annotation_viewer = AnnotationViewer(self)
|
1242
|
-
|
1485
|
+
|
1243
1486
|
# Embedding viewer (shows 2D embedding scatter plot)
|
1244
|
-
if self.embedding_viewer is None:
|
1487
|
+
if self.embedding_viewer is None:
|
1245
1488
|
self.embedding_viewer = EmbeddingViewer(self)
|
1246
1489
|
|
1247
1490
|
top_layout = QHBoxLayout()
|
@@ -1272,7 +1515,11 @@ class ExplorerWindow(QMainWindow):
|
|
1272
1515
|
self.buttons_layout.addWidget(self.exit_button)
|
1273
1516
|
self.buttons_layout.addWidget(self.apply_button)
|
1274
1517
|
self.main_layout.addLayout(self.buttons_layout)
|
1275
|
-
|
1518
|
+
|
1519
|
+
self._initialize_data_item_cache()
|
1520
|
+
self.annotation_settings_widget.set_default_to_current_image()
|
1521
|
+
self.refresh_filters()
|
1522
|
+
|
1276
1523
|
self.annotation_settings_widget.set_default_to_current_image()
|
1277
1524
|
self.refresh_filters()
|
1278
1525
|
|
@@ -1280,7 +1527,7 @@ class ExplorerWindow(QMainWindow):
|
|
1280
1527
|
self.label_window.labelSelected.disconnect(self.on_label_selected_for_preview)
|
1281
1528
|
except TypeError:
|
1282
1529
|
pass
|
1283
|
-
|
1530
|
+
|
1284
1531
|
# Connect signals to slots
|
1285
1532
|
self.label_window.labelSelected.connect(self.on_label_selected_for_preview)
|
1286
1533
|
self.annotation_viewer.selection_changed.connect(self.on_annotation_view_selection_changed)
|
@@ -1288,20 +1535,35 @@ class ExplorerWindow(QMainWindow):
|
|
1288
1535
|
self.annotation_viewer.reset_view_requested.connect(self.on_reset_view_requested)
|
1289
1536
|
self.embedding_viewer.selection_changed.connect(self.on_embedding_view_selection_changed)
|
1290
1537
|
self.embedding_viewer.reset_view_requested.connect(self.on_reset_view_requested)
|
1291
|
-
|
1538
|
+
self.embedding_viewer.find_mislabels_requested.connect(self.find_potential_mislabels)
|
1539
|
+
self.embedding_viewer.mislabel_parameters_changed.connect(self.on_mislabel_params_changed)
|
1540
|
+
self.model_settings_widget.selection_changed.connect(self.on_model_selection_changed)
|
1541
|
+
self.embedding_viewer.find_uncertain_requested.connect(self.find_uncertain_annotations)
|
1542
|
+
self.embedding_viewer.uncertainty_parameters_changed.connect(self.on_uncertainty_params_changed)
|
1543
|
+
self.annotation_viewer.find_similar_requested.connect(self.find_similar_annotations)
|
1544
|
+
self.annotation_viewer.similarity_settings_widget.parameters_changed.connect(self.on_similarity_params_changed)
|
1545
|
+
|
1292
1546
|
@pyqtSlot(list)
|
1293
1547
|
def on_annotation_view_selection_changed(self, changed_ann_ids):
|
1294
1548
|
"""Syncs selection from AnnotationViewer to EmbeddingViewer."""
|
1549
|
+
# Per request, unselect any annotation in the main AnnotationWindow
|
1550
|
+
if hasattr(self, 'annotation_window'):
|
1551
|
+
self.annotation_window.unselect_annotations()
|
1552
|
+
|
1295
1553
|
all_selected_ids = {w.data_item.annotation.id for w in self.annotation_viewer.selected_widgets}
|
1296
1554
|
if self.embedding_viewer.points_by_id:
|
1297
1555
|
self.embedding_viewer.render_selection_from_ids(all_selected_ids)
|
1298
|
-
|
1556
|
+
|
1299
1557
|
# Call the new centralized method
|
1300
1558
|
self.update_label_window_selection()
|
1301
1559
|
|
1302
1560
|
@pyqtSlot(list)
|
1303
1561
|
def on_embedding_view_selection_changed(self, all_selected_ann_ids):
|
1304
1562
|
"""Syncs selection from EmbeddingViewer to AnnotationViewer."""
|
1563
|
+
# Per request, unselect any annotation in the main AnnotationWindow
|
1564
|
+
if hasattr(self, 'annotation_window'):
|
1565
|
+
self.annotation_window.unselect_annotations()
|
1566
|
+
|
1305
1567
|
# Check the state BEFORE the selection is changed
|
1306
1568
|
was_empty_selection = len(self.annotation_viewer.selected_widgets) == 0
|
1307
1569
|
|
@@ -1321,11 +1583,18 @@ class ExplorerWindow(QMainWindow):
|
|
1321
1583
|
|
1322
1584
|
@pyqtSlot(list)
|
1323
1585
|
def on_preview_changed(self, changed_ann_ids):
|
1324
|
-
"""Updates embedding point colors when a preview label is applied."""
|
1586
|
+
"""Updates embedding point colors and tooltips when a preview label is applied."""
|
1325
1587
|
for ann_id in changed_ann_ids:
|
1588
|
+
# Update embedding point color
|
1326
1589
|
point = self.embedding_viewer.points_by_id.get(ann_id)
|
1327
1590
|
if point:
|
1328
1591
|
point.update()
|
1592
|
+
point.update_tooltip() # Refresh tooltip to show new effective label
|
1593
|
+
|
1594
|
+
# Update annotation widget tooltip
|
1595
|
+
widget = self.annotation_viewer.annotation_widgets_by_id.get(ann_id)
|
1596
|
+
if widget:
|
1597
|
+
widget.update_tooltip()
|
1329
1598
|
|
1330
1599
|
@pyqtSlot()
|
1331
1600
|
def on_reset_view_requested(self):
|
@@ -1334,14 +1603,66 @@ class ExplorerWindow(QMainWindow):
|
|
1334
1603
|
self.annotation_viewer.clear_selection()
|
1335
1604
|
self.embedding_viewer.render_selection_from_ids(set())
|
1336
1605
|
|
1337
|
-
# Exit isolation mode if currently active
|
1606
|
+
# Exit isolation mode if currently active in AnnotationViewer
|
1338
1607
|
if self.annotation_viewer.isolated_mode:
|
1339
1608
|
self.annotation_viewer.show_all_annotations()
|
1340
1609
|
|
1341
|
-
self.
|
1610
|
+
if self.embedding_viewer.isolated_mode:
|
1611
|
+
self.embedding_viewer.show_all_points()
|
1612
|
+
|
1613
|
+
# Clear similarity sort context
|
1614
|
+
self.annotation_viewer.active_ordered_ids = []
|
1615
|
+
|
1616
|
+
self.update_label_window_selection()
|
1342
1617
|
self.update_button_states()
|
1343
1618
|
|
1344
1619
|
print("Reset view: cleared selections and exited isolation mode")
|
1620
|
+
|
1621
|
+
@pyqtSlot(dict)
|
1622
|
+
def on_mislabel_params_changed(self, params):
|
1623
|
+
"""Updates the stored parameters for mislabel detection."""
|
1624
|
+
self.mislabel_params = params
|
1625
|
+
print(f"Mislabel detection parameters updated: {self.mislabel_params}")
|
1626
|
+
|
1627
|
+
@pyqtSlot(dict)
|
1628
|
+
def on_uncertainty_params_changed(self, params):
|
1629
|
+
"""Updates the stored parameters for uncertainty analysis."""
|
1630
|
+
self.uncertainty_params = params
|
1631
|
+
print(f"Uncertainty parameters updated: {self.uncertainty_params}")
|
1632
|
+
|
1633
|
+
@pyqtSlot(dict)
|
1634
|
+
def on_similarity_params_changed(self, params):
|
1635
|
+
"""Updates the stored parameters for similarity search."""
|
1636
|
+
self.similarity_params = params
|
1637
|
+
print(f"Similarity search parameters updated: {self.similarity_params}")
|
1638
|
+
|
1639
|
+
@pyqtSlot()
|
1640
|
+
def on_model_selection_changed(self):
|
1641
|
+
"""
|
1642
|
+
Handles changes in the model settings to enable/disable model-dependent features.
|
1643
|
+
"""
|
1644
|
+
if not self._ui_initialized:
|
1645
|
+
return
|
1646
|
+
|
1647
|
+
model_name, feature_mode = self.model_settings_widget.get_selected_model()
|
1648
|
+
is_predict_mode = ".pt" in model_name and feature_mode == "Predictions"
|
1649
|
+
|
1650
|
+
self.embedding_viewer.is_uncertainty_analysis_available = is_predict_mode
|
1651
|
+
self.embedding_viewer._update_toolbar_state()
|
1652
|
+
|
1653
|
+
def _initialize_data_item_cache(self):
|
1654
|
+
"""
|
1655
|
+
Creates a persistent AnnotationDataItem for every annotation,
|
1656
|
+
caching them for the duration of the session.
|
1657
|
+
"""
|
1658
|
+
self.data_item_cache.clear()
|
1659
|
+
if not hasattr(self.main_window.annotation_window, 'annotations_dict'):
|
1660
|
+
return
|
1661
|
+
|
1662
|
+
all_annotations = self.main_window.annotation_window.annotations_dict.values()
|
1663
|
+
for ann in all_annotations:
|
1664
|
+
if ann.id not in self.data_item_cache:
|
1665
|
+
self.data_item_cache[ann.id] = AnnotationDataItem(ann)
|
1345
1666
|
|
1346
1667
|
def update_label_window_selection(self):
|
1347
1668
|
"""
|
@@ -1360,7 +1681,7 @@ class ExplorerWindow(QMainWindow):
|
|
1360
1681
|
|
1361
1682
|
first_effective_label = selected_data_items[0].effective_label
|
1362
1683
|
all_same_current_label = all(
|
1363
|
-
item.effective_label.id == first_effective_label.id
|
1684
|
+
item.effective_label.id == first_effective_label.id
|
1364
1685
|
for item in selected_data_items
|
1365
1686
|
)
|
1366
1687
|
|
@@ -1374,10 +1695,12 @@ class ExplorerWindow(QMainWindow):
|
|
1374
1695
|
self.label_window.update_annotation_count()
|
1375
1696
|
|
1376
1697
|
def get_filtered_data_items(self):
|
1377
|
-
"""
|
1378
|
-
|
1698
|
+
"""
|
1699
|
+
Gets annotations matching all conditions by retrieving their
|
1700
|
+
persistent AnnotationDataItem objects from the cache.
|
1701
|
+
"""
|
1379
1702
|
if not hasattr(self.main_window.annotation_window, 'annotations_dict'):
|
1380
|
-
return
|
1703
|
+
return []
|
1381
1704
|
|
1382
1705
|
selected_images = self.annotation_settings_widget.get_selected_images()
|
1383
1706
|
selected_types = self.annotation_settings_widget.get_selected_annotation_types()
|
@@ -1394,37 +1717,436 @@ class ExplorerWindow(QMainWindow):
|
|
1394
1717
|
]
|
1395
1718
|
|
1396
1719
|
self._ensure_cropped_images(annotations_to_process)
|
1397
|
-
|
1720
|
+
|
1721
|
+
return [self.data_item_cache[ann.id] for ann in annotations_to_process if ann.id in self.data_item_cache]
|
1722
|
+
|
1723
|
+
def find_potential_mislabels(self):
|
1724
|
+
"""
|
1725
|
+
Identifies annotations whose label does not match the majority of its
|
1726
|
+
k-nearest neighbors in the high-dimensional feature space.
|
1727
|
+
"""
|
1728
|
+
# Get parameters from the stored property instead of hardcoding
|
1729
|
+
K = self.mislabel_params.get('k', 5)
|
1730
|
+
agreement_threshold = self.mislabel_params.get('threshold', 0.6)
|
1731
|
+
|
1732
|
+
if not self.embedding_viewer.points_by_id or len(self.embedding_viewer.points_by_id) < K:
|
1733
|
+
QMessageBox.information(self, "Not Enough Data",
|
1734
|
+
f"This feature requires at least {K} points in the embedding viewer.")
|
1735
|
+
return
|
1736
|
+
|
1737
|
+
items_in_view = list(self.embedding_viewer.points_by_id.values())
|
1738
|
+
data_items_in_view = [p.data_item for p in items_in_view]
|
1739
|
+
|
1740
|
+
# Get the model key used for the current embedding
|
1741
|
+
model_info = self.model_settings_widget.get_selected_model()
|
1742
|
+
model_name, feature_mode = model_info if isinstance(model_info, tuple) else (model_info, "default")
|
1743
|
+
sanitized_model_name = os.path.basename(model_name).replace(' ', '_')
|
1744
|
+
# FIX: Also replace the forward slash to handle "N/A"
|
1745
|
+
sanitized_feature_mode = feature_mode.replace(' ', '_').replace('/', '_')
|
1746
|
+
model_key = f"{sanitized_model_name}_{sanitized_feature_mode}"
|
1747
|
+
|
1748
|
+
QApplication.setOverrideCursor(Qt.WaitCursor)
|
1749
|
+
try:
|
1750
|
+
# Get the FAISS index and the mapping from index to annotation ID
|
1751
|
+
index = self.feature_store._get_or_load_index(model_key)
|
1752
|
+
faiss_idx_to_ann_id = self.feature_store.get_faiss_index_to_annotation_id_map(model_key)
|
1753
|
+
if index is None or not faiss_idx_to_ann_id:
|
1754
|
+
QMessageBox.warning(self, "Error", "Could not find a valid feature index for the current model.")
|
1755
|
+
return
|
1756
|
+
|
1757
|
+
# Get the high-dimensional features for the points in the current view
|
1758
|
+
features_dict, _ = self.feature_store.get_features(data_items_in_view, model_key)
|
1759
|
+
if not features_dict:
|
1760
|
+
QMessageBox.warning(self, "Error", "Could not retrieve features for the items in view.")
|
1761
|
+
return
|
1762
|
+
|
1763
|
+
query_ann_ids = list(features_dict.keys())
|
1764
|
+
query_vectors = np.array([features_dict[ann_id] for ann_id in query_ann_ids]).astype('float32')
|
1765
|
+
|
1766
|
+
# Perform k-NN search. We search for K+1 because the point itself will be the first result.
|
1767
|
+
_, I = index.search(query_vectors, K + 1)
|
1768
|
+
|
1769
|
+
mislabeled_ann_ids = []
|
1770
|
+
for i, ann_id in enumerate(query_ann_ids):
|
1771
|
+
current_label = self.data_item_cache[ann_id].effective_label.id
|
1772
|
+
|
1773
|
+
# Get neighbor labels, ignoring the first result (the point itself)
|
1774
|
+
neighbor_faiss_indices = I[i][1:]
|
1775
|
+
|
1776
|
+
neighbor_labels = []
|
1777
|
+
for n_idx in neighbor_faiss_indices:
|
1778
|
+
# THIS IS THE CORRECTED LOGIC
|
1779
|
+
if n_idx in faiss_idx_to_ann_id:
|
1780
|
+
neighbor_ann_id = faiss_idx_to_ann_id[n_idx]
|
1781
|
+
# ADD THIS CHECK to ensure the neighbor hasn't been deleted
|
1782
|
+
if neighbor_ann_id in self.data_item_cache:
|
1783
|
+
neighbor_labels.append(self.data_item_cache[neighbor_ann_id].effective_label.id)
|
1784
|
+
|
1785
|
+
if not neighbor_labels:
|
1786
|
+
continue
|
1787
|
+
|
1788
|
+
# Use the agreement threshold instead of strict majority
|
1789
|
+
num_matching_neighbors = neighbor_labels.count(current_label)
|
1790
|
+
agreement_ratio = num_matching_neighbors / len(neighbor_labels)
|
1791
|
+
|
1792
|
+
if agreement_ratio < agreement_threshold:
|
1793
|
+
mislabeled_ann_ids.append(ann_id)
|
1794
|
+
|
1795
|
+
self.embedding_viewer.render_selection_from_ids(set(mislabeled_ann_ids))
|
1796
|
+
|
1797
|
+
finally:
|
1798
|
+
QApplication.restoreOverrideCursor()
|
1799
|
+
|
1800
|
+
def find_uncertain_annotations(self):
|
1801
|
+
"""
|
1802
|
+
Identifies annotations where the model's prediction is uncertain.
|
1803
|
+
It reuses cached predictions if available, otherwise runs a temporary prediction.
|
1804
|
+
"""
|
1805
|
+
if not self.embedding_viewer.points_by_id:
|
1806
|
+
QMessageBox.information(self, "No Data", "Please generate an embedding first.")
|
1807
|
+
return
|
1808
|
+
|
1809
|
+
if self.current_embedding_model_info is None:
|
1810
|
+
QMessageBox.information(self,
|
1811
|
+
"No Embedding",
|
1812
|
+
"Could not determine the model used for the embedding. Please run it again.")
|
1813
|
+
return
|
1814
|
+
|
1815
|
+
items_in_view = list(self.embedding_viewer.points_by_id.values())
|
1816
|
+
data_items_in_view = [p.data_item for p in items_in_view]
|
1817
|
+
|
1818
|
+
model_name_from_embedding, feature_mode_from_embedding = self.current_embedding_model_info
|
1819
|
+
|
1820
|
+
QApplication.setOverrideCursor(Qt.WaitCursor)
|
1821
|
+
try:
|
1822
|
+
probabilities_dict = {}
|
1823
|
+
|
1824
|
+
# Decide whether to reuse cached features or run a new prediction
|
1825
|
+
if feature_mode_from_embedding == "Predictions":
|
1826
|
+
print("Reusing cached prediction vectors from the FeatureStore.")
|
1827
|
+
sanitized_model_name = os.path.basename(model_name_from_embedding).replace(' ', '_').replace('/', '_')
|
1828
|
+
sanitized_feature_mode = feature_mode_from_embedding.replace(' ', '_').replace('/', '_')
|
1829
|
+
model_key = f"{sanitized_model_name}_{sanitized_feature_mode}"
|
1830
|
+
|
1831
|
+
probabilities_dict, _ = self.feature_store.get_features(data_items_in_view, model_key)
|
1832
|
+
if not probabilities_dict:
|
1833
|
+
QMessageBox.warning(self,
|
1834
|
+
"Cache Error",
|
1835
|
+
"Could not retrieve cached predictions.")
|
1836
|
+
return
|
1837
|
+
else:
|
1838
|
+
print("Embedding not based on 'Predictions' mode. Running a temporary prediction.")
|
1839
|
+
model_info_for_predict = self.model_settings_widget.get_selected_model()
|
1840
|
+
probabilities_dict = self._get_yolo_predictions_for_uncertainty(data_items_in_view,
|
1841
|
+
model_info_for_predict)
|
1842
|
+
|
1843
|
+
if not probabilities_dict:
|
1844
|
+
# The helper function will show its own, more specific errors.
|
1845
|
+
return
|
1846
|
+
|
1847
|
+
uncertain_ids = []
|
1848
|
+
params = self.uncertainty_params
|
1849
|
+
for ann_id, probs in probabilities_dict.items():
|
1850
|
+
if len(probs) < 2:
|
1851
|
+
continue # Cannot calculate margin
|
1852
|
+
|
1853
|
+
sorted_probs = np.sort(probs)[::-1]
|
1854
|
+
top1_conf = sorted_probs[0]
|
1855
|
+
top2_conf = sorted_probs[1]
|
1856
|
+
margin = top1_conf - top2_conf
|
1857
|
+
|
1858
|
+
if top1_conf < params['confidence'] or margin < params['margin']:
|
1859
|
+
uncertain_ids.append(ann_id)
|
1860
|
+
|
1861
|
+
self.embedding_viewer.render_selection_from_ids(set(uncertain_ids))
|
1862
|
+
print(f"Found {len(uncertain_ids)} uncertain annotations.")
|
1863
|
+
|
1864
|
+
finally:
|
1865
|
+
QApplication.restoreOverrideCursor()
|
1866
|
+
|
1867
|
+
@pyqtSlot()
|
1868
|
+
def find_similar_annotations(self):
|
1869
|
+
"""
|
1870
|
+
Finds k-nearest neighbors to the selected annotation(s) and updates
|
1871
|
+
the UI to show the results in an isolated, ordered view. This method
|
1872
|
+
now ensures the grid is always updated and resets the sort-by dropdown.
|
1873
|
+
"""
|
1874
|
+
k = self.similarity_params.get('k', 10)
|
1875
|
+
|
1876
|
+
if not self.annotation_viewer.selected_widgets:
|
1877
|
+
QMessageBox.information(self, "No Selection", "Please select one or more annotations first.")
|
1878
|
+
return
|
1879
|
+
|
1880
|
+
if not self.current_embedding_model_info:
|
1881
|
+
QMessageBox.warning(self, "No Embedding", "Please run an embedding before searching for similar items.")
|
1882
|
+
return
|
1883
|
+
|
1884
|
+
selected_data_items = [widget.data_item for widget in self.annotation_viewer.selected_widgets]
|
1885
|
+
model_name, feature_mode = self.current_embedding_model_info
|
1886
|
+
sanitized_model_name = os.path.basename(model_name).replace(' ', '_')
|
1887
|
+
sanitized_feature_mode = feature_mode.replace(' ', '_').replace('/', '_')
|
1888
|
+
model_key = f"{sanitized_model_name}_{sanitized_feature_mode}"
|
1889
|
+
|
1890
|
+
QApplication.setOverrideCursor(Qt.WaitCursor)
|
1891
|
+
try:
|
1892
|
+
features_dict, _ = self.feature_store.get_features(selected_data_items, model_key)
|
1893
|
+
if not features_dict:
|
1894
|
+
QMessageBox.warning(self,
|
1895
|
+
"Features Not Found",
|
1896
|
+
"Could not retrieve feature vectors for the selected items.")
|
1897
|
+
return
|
1898
|
+
|
1899
|
+
source_vectors = np.array(list(features_dict.values()))
|
1900
|
+
query_vector = np.mean(source_vectors, axis=0, keepdims=True).astype('float32')
|
1901
|
+
|
1902
|
+
index = self.feature_store._get_or_load_index(model_key)
|
1903
|
+
faiss_idx_to_ann_id = self.feature_store.get_faiss_index_to_annotation_id_map(model_key)
|
1904
|
+
if index is None or not faiss_idx_to_ann_id:
|
1905
|
+
QMessageBox.warning(self,
|
1906
|
+
"Index Error",
|
1907
|
+
"Could not find a valid feature index for the current model.")
|
1908
|
+
return
|
1909
|
+
|
1910
|
+
# Find k results, plus more to account for the query items possibly being in the results
|
1911
|
+
num_to_find = k + len(selected_data_items)
|
1912
|
+
if num_to_find > index.ntotal:
|
1913
|
+
num_to_find = index.ntotal
|
1914
|
+
|
1915
|
+
_, I = index.search(query_vector, num_to_find)
|
1916
|
+
|
1917
|
+
source_ids = {item.annotation.id for item in selected_data_items}
|
1918
|
+
similar_ann_ids = []
|
1919
|
+
for faiss_idx in I[0]:
|
1920
|
+
ann_id = faiss_idx_to_ann_id.get(faiss_idx)
|
1921
|
+
if ann_id and ann_id in self.data_item_cache and ann_id not in source_ids:
|
1922
|
+
similar_ann_ids.append(ann_id)
|
1923
|
+
if len(similar_ann_ids) == k:
|
1924
|
+
break
|
1925
|
+
|
1926
|
+
# Create the final ordered list: original selection first, then similar items.
|
1927
|
+
ordered_ids_to_display = list(source_ids) + similar_ann_ids
|
1928
|
+
|
1929
|
+
# --- FIX IMPLEMENTATION ---
|
1930
|
+
# 1. Force sort combo to "None" to avoid user confusion.
|
1931
|
+
self.annotation_viewer.sort_combo.setCurrentText("None")
|
1932
|
+
|
1933
|
+
# 2. Update the embedding viewer selection.
|
1934
|
+
self.embedding_viewer.render_selection_from_ids(set(ordered_ids_to_display))
|
1935
|
+
|
1936
|
+
# 3. Call the new robust method in AnnotationViewer to handle isolation and grid updates.
|
1937
|
+
self.annotation_viewer.display_and_isolate_ordered_results(ordered_ids_to_display)
|
1938
|
+
|
1939
|
+
self.update_button_states()
|
1940
|
+
|
1941
|
+
finally:
|
1942
|
+
QApplication.restoreOverrideCursor()
|
1943
|
+
|
1944
|
+
def _get_yolo_predictions_for_uncertainty(self, data_items, model_info):
|
1945
|
+
"""
|
1946
|
+
Runs a YOLO classification model to get probabilities for uncertainty analysis.
|
1947
|
+
This is a streamlined method that does NOT use the feature store.
|
1948
|
+
"""
|
1949
|
+
model_name, feature_mode = model_info
|
1950
|
+
|
1951
|
+
# Load the model
|
1952
|
+
model, imgsz = self._load_yolo_model(model_name, feature_mode)
|
1953
|
+
if model is None:
|
1954
|
+
QMessageBox.warning(self,
|
1955
|
+
"Model Load Error",
|
1956
|
+
f"Could not load YOLO model '{model_name}'.")
|
1957
|
+
return None
|
1958
|
+
|
1959
|
+
# Prepare images from data items
|
1960
|
+
image_list, valid_data_items = self._prepare_images_from_data_items(data_items)
|
1961
|
+
if not image_list:
|
1962
|
+
return None
|
1963
|
+
|
1964
|
+
try:
|
1965
|
+
# We need probabilities for uncertainty analysis, so we always use predict
|
1966
|
+
results = model.predict(image_list,
|
1967
|
+
stream=False, # Use batch processing for uncertainty
|
1968
|
+
imgsz=imgsz,
|
1969
|
+
half=True,
|
1970
|
+
device=self.device,
|
1971
|
+
verbose=False)
|
1972
|
+
|
1973
|
+
_, probabilities_dict = self._process_model_results(results, valid_data_items, "Predictions")
|
1974
|
+
return probabilities_dict
|
1975
|
+
|
1976
|
+
except TypeError:
|
1977
|
+
QMessageBox.warning(self,
|
1978
|
+
"Invalid Model",
|
1979
|
+
"The selected model is not compatible with uncertainty analysis.")
|
1980
|
+
return None
|
1981
|
+
|
1982
|
+
finally:
|
1983
|
+
if torch.cuda.is_available():
|
1984
|
+
torch.cuda.empty_cache()
|
1398
1985
|
|
1399
1986
|
def _ensure_cropped_images(self, annotations):
|
1400
1987
|
"""Ensures all provided annotations have a cropped image available."""
|
1401
1988
|
annotations_by_image = {}
|
1402
|
-
|
1989
|
+
|
1403
1990
|
for annotation in annotations:
|
1404
1991
|
if not annotation.cropped_image:
|
1405
1992
|
image_path = annotation.image_path
|
1406
1993
|
if image_path not in annotations_by_image:
|
1407
1994
|
annotations_by_image[image_path] = []
|
1408
1995
|
annotations_by_image[image_path].append(annotation)
|
1409
|
-
|
1410
|
-
if not annotations_by_image:
|
1996
|
+
|
1997
|
+
if not annotations_by_image:
|
1411
1998
|
return
|
1412
1999
|
|
1413
2000
|
progress_bar = ProgressBar(self, "Cropping Image Annotations")
|
1414
2001
|
progress_bar.show()
|
1415
2002
|
progress_bar.start_progress(len(annotations_by_image))
|
1416
|
-
|
2003
|
+
|
1417
2004
|
try:
|
1418
2005
|
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,
|
2006
|
+
self.annotation_window.crop_annotations(image_path=image_path,
|
2007
|
+
annotations=image_annotations,
|
2008
|
+
return_annotations=False,
|
1422
2009
|
verbose=False)
|
1423
2010
|
progress_bar.update_progress()
|
1424
2011
|
finally:
|
1425
2012
|
progress_bar.finish_progress()
|
1426
2013
|
progress_bar.stop_progress()
|
1427
2014
|
progress_bar.close()
|
2015
|
+
|
2016
|
+
def _load_yolo_model(self, model_name, feature_mode):
|
2017
|
+
"""
|
2018
|
+
Helper function to load a YOLO model and cache it.
|
2019
|
+
|
2020
|
+
Args:
|
2021
|
+
model_name (str): Path to the YOLO model file
|
2022
|
+
feature_mode (str): Mode for feature extraction ("Embed Features" or "Predictions")
|
2023
|
+
|
2024
|
+
Returns:
|
2025
|
+
tuple: (model, image_size) or (None, None) if loading fails
|
2026
|
+
"""
|
2027
|
+
current_run_key = (model_name, feature_mode)
|
2028
|
+
|
2029
|
+
# Force a reload if the model path OR the feature mode has changed
|
2030
|
+
if current_run_key != self.current_feature_generating_model or self.loaded_model is None:
|
2031
|
+
print(f"Model or mode changed. Reloading {model_name} for '{feature_mode}'.")
|
2032
|
+
try:
|
2033
|
+
model = YOLO(model_name)
|
2034
|
+
# Update the cache key to the new successful combination
|
2035
|
+
self.current_feature_generating_model = current_run_key
|
2036
|
+
self.loaded_model = model
|
2037
|
+
imgsz = getattr(model.model.args, 'imgsz', 128)
|
2038
|
+
|
2039
|
+
# Warm up the model
|
2040
|
+
dummy_image = np.zeros((imgsz, imgsz, 3), dtype=np.uint8)
|
2041
|
+
model.predict(dummy_image, imgsz=imgsz, half=True, device=self.device, verbose=False)
|
2042
|
+
|
2043
|
+
return model, imgsz
|
2044
|
+
|
2045
|
+
except Exception as e:
|
2046
|
+
print(f"ERROR: Could not load YOLO model '{model_name}': {e}")
|
2047
|
+
# On failure, reset the model cache
|
2048
|
+
self.loaded_model = None
|
2049
|
+
self.current_feature_generating_model = None
|
2050
|
+
return None, None
|
2051
|
+
|
2052
|
+
# Model already loaded and cached
|
2053
|
+
return self.loaded_model, getattr(self.loaded_model.model.args, 'imgsz', 128)
|
2054
|
+
|
2055
|
+
def _prepare_images_from_data_items(self, data_items, progress_bar=None):
|
2056
|
+
"""
|
2057
|
+
Prepare images from data items for model prediction.
|
2058
|
+
|
2059
|
+
Args:
|
2060
|
+
data_items (list): List of AnnotationDataItem objects
|
2061
|
+
progress_bar (ProgressBar, optional): Progress bar for UI updates
|
2062
|
+
|
2063
|
+
Returns:
|
2064
|
+
tuple: (image_list, valid_data_items)
|
2065
|
+
"""
|
2066
|
+
if progress_bar:
|
2067
|
+
progress_bar.set_title("Preparing images...")
|
2068
|
+
progress_bar.start_progress(len(data_items))
|
2069
|
+
|
2070
|
+
image_list, valid_data_items = [], []
|
2071
|
+
for item in data_items:
|
2072
|
+
pixmap = item.annotation.get_cropped_image()
|
2073
|
+
if pixmap and not pixmap.isNull():
|
2074
|
+
image_list.append(pixmap_to_numpy(pixmap))
|
2075
|
+
valid_data_items.append(item)
|
2076
|
+
|
2077
|
+
if progress_bar:
|
2078
|
+
progress_bar.update_progress()
|
2079
|
+
|
2080
|
+
return image_list, valid_data_items
|
2081
|
+
|
2082
|
+
def _process_model_results(self, results, valid_data_items, feature_mode, progress_bar=None):
|
2083
|
+
"""
|
2084
|
+
Process model results and update data item tooltips.
|
2085
|
+
|
2086
|
+
Args:
|
2087
|
+
results: Model prediction results
|
2088
|
+
valid_data_items (list): List of valid data items
|
2089
|
+
feature_mode (str): Mode for feature extraction
|
2090
|
+
progress_bar (ProgressBar, optional): Progress bar for UI updates
|
2091
|
+
|
2092
|
+
Returns:
|
2093
|
+
tuple: (features_list, probabilities_dict)
|
2094
|
+
"""
|
2095
|
+
features_list = []
|
2096
|
+
probabilities_dict = {}
|
2097
|
+
|
2098
|
+
# Get class names from the model for better tooltips
|
2099
|
+
model = self.loaded_model.model if hasattr(self.loaded_model, 'model') else None
|
2100
|
+
class_names = model.names if model and hasattr(model, 'names') else {}
|
2101
|
+
|
2102
|
+
for i, result in enumerate(results):
|
2103
|
+
if i >= len(valid_data_items):
|
2104
|
+
break
|
2105
|
+
|
2106
|
+
item = valid_data_items[i]
|
2107
|
+
ann_id = item.annotation.id
|
2108
|
+
|
2109
|
+
if feature_mode == "Embed Features":
|
2110
|
+
embedding = result.cpu().numpy().flatten()
|
2111
|
+
features_list.append(embedding)
|
2112
|
+
|
2113
|
+
elif hasattr(result, 'probs') and result.probs is not None:
|
2114
|
+
probs = result.probs.data.cpu().numpy().squeeze()
|
2115
|
+
features_list.append(probs)
|
2116
|
+
probabilities_dict[ann_id] = probs
|
2117
|
+
|
2118
|
+
# Store the probabilities directly on the data item for confidence sorting
|
2119
|
+
item.prediction_probabilities = probs
|
2120
|
+
|
2121
|
+
# Format and store prediction details for tooltips
|
2122
|
+
if len(probs) > 0:
|
2123
|
+
# Get top 5 predictions
|
2124
|
+
top_indices = probs.argsort()[::-1][:5]
|
2125
|
+
top_probs = probs[top_indices]
|
2126
|
+
|
2127
|
+
formatted_preds = ["<b>Top Predictions:</b>"]
|
2128
|
+
for idx, prob in zip(top_indices, top_probs):
|
2129
|
+
class_name = class_names.get(int(idx), f"Class {idx}")
|
2130
|
+
formatted_preds.append(f"{class_name}: {prob*100:.1f}%")
|
2131
|
+
|
2132
|
+
item.prediction_details = "<br>".join(formatted_preds)
|
2133
|
+
else:
|
2134
|
+
raise TypeError(
|
2135
|
+
"The 'Predictions' feature mode requires a classification model "
|
2136
|
+
"(e.g., 'yolov8n-cls.pt') that returns class probabilities. "
|
2137
|
+
"The selected model did not provide this output. "
|
2138
|
+
"Please use 'Embed Features' mode for this model."
|
2139
|
+
)
|
2140
|
+
|
2141
|
+
if progress_bar:
|
2142
|
+
progress_bar.update_progress()
|
2143
|
+
|
2144
|
+
# After processing is complete, update tooltips
|
2145
|
+
for item in valid_data_items:
|
2146
|
+
if hasattr(item, 'update_tooltip'):
|
2147
|
+
item.update_tooltip()
|
2148
|
+
|
2149
|
+
return features_list, probabilities_dict
|
1428
2150
|
|
1429
2151
|
def _extract_color_features(self, data_items, progress_bar=None, bins=32):
|
1430
2152
|
"""
|
@@ -1507,102 +2229,71 @@ class ExplorerWindow(QMainWindow):
|
|
1507
2229
|
|
1508
2230
|
def _extract_yolo_features(self, data_items, model_info, progress_bar=None):
|
1509
2231
|
"""Extracts features from annotation crops using a YOLO model."""
|
1510
|
-
# Extract model name and feature mode from the provided model_info tuple
|
1511
2232
|
model_name, feature_mode = model_info
|
1512
2233
|
|
1513
|
-
|
1514
|
-
|
1515
|
-
|
1516
|
-
|
1517
|
-
|
1518
|
-
|
1519
|
-
|
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:
|
2234
|
+
# Load the model
|
2235
|
+
model, imgsz = self._load_yolo_model(model_name, feature_mode)
|
2236
|
+
if model is None:
|
2237
|
+
return np.array([]), []
|
2238
|
+
|
2239
|
+
# Prepare images from data items
|
2240
|
+
image_list, valid_data_items = self._prepare_images_from_data_items(data_items, progress_bar)
|
2241
|
+
if not valid_data_items:
|
1542
2242
|
return np.array([]), []
|
1543
2243
|
|
1544
|
-
#
|
1545
|
-
kwargs = {
|
1546
|
-
|
1547
|
-
|
1548
|
-
|
1549
|
-
|
2244
|
+
# Set up prediction parameters
|
2245
|
+
kwargs = {
|
2246
|
+
'stream': True,
|
2247
|
+
'imgsz': imgsz,
|
2248
|
+
'half': True,
|
2249
|
+
'device': self.device,
|
2250
|
+
'verbose': False
|
2251
|
+
}
|
1550
2252
|
|
1551
|
-
#
|
2253
|
+
# Get results based on feature mode
|
1552
2254
|
if feature_mode == "Embed Features":
|
1553
|
-
results_generator =
|
2255
|
+
results_generator = model.embed(image_list, **kwargs)
|
1554
2256
|
else:
|
1555
|
-
results_generator =
|
1556
|
-
|
1557
|
-
if progress_bar:
|
2257
|
+
results_generator = model.predict(image_list, **kwargs)
|
2258
|
+
|
2259
|
+
if progress_bar:
|
1558
2260
|
progress_bar.set_title("Extracting features...")
|
1559
2261
|
progress_bar.start_progress(len(valid_data_items))
|
1560
2262
|
|
1561
|
-
# Prepare a list to hold the extracted features
|
1562
|
-
embeddings_list = []
|
1563
|
-
|
1564
2263
|
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()
|
2264
|
+
features_list, _ = self._process_model_results(results_generator,
|
2265
|
+
valid_data_items,
|
2266
|
+
feature_mode,
|
2267
|
+
progress_bar=progress_bar)
|
2268
|
+
|
2269
|
+
return np.array(features_list), valid_data_items
|
2270
|
+
|
1578
2271
|
finally:
|
1579
2272
|
if torch.cuda.is_available():
|
1580
2273
|
torch.cuda.empty_cache()
|
1581
|
-
|
1582
|
-
return np.array(embeddings_list), valid_data_items
|
1583
2274
|
|
1584
2275
|
def _extract_features(self, data_items, progress_bar=None):
|
1585
2276
|
"""Dispatcher to call the appropriate feature extraction function."""
|
1586
2277
|
# Get the selected model and feature mode from the model settings widget
|
1587
2278
|
model_name, feature_mode = self.model_settings_widget.get_selected_model()
|
1588
|
-
|
1589
|
-
if isinstance(model_name, tuple):
|
2279
|
+
|
2280
|
+
if isinstance(model_name, tuple):
|
1590
2281
|
model_name = model_name[0]
|
1591
|
-
|
1592
|
-
if not model_name:
|
2282
|
+
|
2283
|
+
if not model_name:
|
1593
2284
|
return np.array([]), []
|
1594
|
-
|
2285
|
+
|
1595
2286
|
if model_name == "Color Features":
|
1596
2287
|
return self._extract_color_features(data_items, progress_bar=progress_bar)
|
1597
|
-
|
2288
|
+
|
1598
2289
|
elif ".pt" in model_name:
|
1599
2290
|
return self._extract_yolo_features(data_items, (model_name, feature_mode), progress_bar=progress_bar)
|
1600
|
-
|
2291
|
+
|
1601
2292
|
return np.array([]), []
|
1602
2293
|
|
1603
2294
|
def _run_dimensionality_reduction(self, features, params):
|
1604
2295
|
"""
|
1605
|
-
Runs dimensionality reduction
|
2296
|
+
Runs dimensionality reduction with automatic PCA preprocessing for UMAP and t-SNE.
|
1606
2297
|
|
1607
2298
|
Args:
|
1608
2299
|
features (np.ndarray): Feature matrix of shape (N, D).
|
@@ -1612,7 +2303,9 @@ class ExplorerWindow(QMainWindow):
|
|
1612
2303
|
np.ndarray or None: 2D embedded features of shape (N, 2), or None on failure.
|
1613
2304
|
"""
|
1614
2305
|
technique = params.get('technique', 'UMAP')
|
1615
|
-
|
2306
|
+
# Default number of components to use for PCA preprocessing
|
2307
|
+
pca_components = params.get('pca_components', 50)
|
2308
|
+
|
1616
2309
|
if len(features) <= 2:
|
1617
2310
|
# Not enough samples for dimensionality reduction
|
1618
2311
|
return None
|
@@ -1620,11 +2313,21 @@ class ExplorerWindow(QMainWindow):
|
|
1620
2313
|
try:
|
1621
2314
|
# Standardize features before reduction
|
1622
2315
|
features_scaled = StandardScaler().fit_transform(features)
|
1623
|
-
|
2316
|
+
|
2317
|
+
# Apply PCA preprocessing automatically for UMAP or TSNE
|
2318
|
+
# (only if the feature dimension is larger than the target PCA components)
|
2319
|
+
if technique in ["UMAP", "TSNE"] and features_scaled.shape[1] > pca_components:
|
2320
|
+
# Ensure pca_components doesn't exceed number of samples or features
|
2321
|
+
pca_components = min(pca_components, features_scaled.shape[0] - 1, features_scaled.shape[1])
|
2322
|
+
print(f"Applying PCA preprocessing to {pca_components} components before {technique}")
|
2323
|
+
pca = PCA(n_components=pca_components, random_state=42)
|
2324
|
+
features_scaled = pca.fit_transform(features_scaled)
|
2325
|
+
variance_explained = sum(pca.explained_variance_ratio_) * 100
|
2326
|
+
print(f"Variance explained by PCA: {variance_explained:.1f}%")
|
2327
|
+
|
2328
|
+
# Proceed with the selected dimensionality reduction technique
|
1624
2329
|
if technique == "UMAP":
|
1625
|
-
# UMAP: n_neighbors must be < n_samples
|
1626
2330
|
n_neighbors = min(params.get('n_neighbors', 15), len(features_scaled) - 1)
|
1627
|
-
|
1628
2331
|
reducer = UMAP(
|
1629
2332
|
n_components=2,
|
1630
2333
|
random_state=42,
|
@@ -1633,9 +2336,7 @@ class ExplorerWindow(QMainWindow):
|
|
1633
2336
|
metric=params.get('metric', 'cosine')
|
1634
2337
|
)
|
1635
2338
|
elif technique == "TSNE":
|
1636
|
-
# t-SNE: perplexity must be < n_samples
|
1637
2339
|
perplexity = min(params.get('perplexity', 30), len(features_scaled) - 1)
|
1638
|
-
|
1639
2340
|
reducer = TSNE(
|
1640
2341
|
n_components=2,
|
1641
2342
|
random_state=42,
|
@@ -1647,7 +2348,6 @@ class ExplorerWindow(QMainWindow):
|
|
1647
2348
|
elif technique == "PCA":
|
1648
2349
|
reducer = PCA(n_components=2, random_state=42)
|
1649
2350
|
else:
|
1650
|
-
# Unknown technique
|
1651
2351
|
return None
|
1652
2352
|
|
1653
2353
|
# Fit and transform the features
|
@@ -1669,43 +2369,84 @@ class ExplorerWindow(QMainWindow):
|
|
1669
2369
|
item.embedding_y = (norm_y * scale_factor) - (scale_factor / 2)
|
1670
2370
|
|
1671
2371
|
def run_embedding_pipeline(self):
|
1672
|
-
"""
|
1673
|
-
|
2372
|
+
"""
|
2373
|
+
Orchestrates feature extraction and dimensionality reduction.
|
2374
|
+
If the EmbeddingViewer is in isolate mode, it will use only the visible
|
2375
|
+
(isolated) points as input for the pipeline.
|
2376
|
+
"""
|
2377
|
+
items_to_embed = []
|
2378
|
+
if self.embedding_viewer.isolated_mode:
|
2379
|
+
items_to_embed = [point.data_item for point in self.embedding_viewer.isolated_points]
|
2380
|
+
else:
|
2381
|
+
items_to_embed = self.current_data_items
|
2382
|
+
|
2383
|
+
if not items_to_embed:
|
2384
|
+
print("No items to process for embedding.")
|
1674
2385
|
return
|
1675
|
-
|
2386
|
+
|
1676
2387
|
self.annotation_viewer.clear_selection()
|
1677
|
-
if self.annotation_viewer.isolated_mode:
|
2388
|
+
if self.annotation_viewer.isolated_mode:
|
1678
2389
|
self.annotation_viewer.show_all_annotations()
|
1679
|
-
|
2390
|
+
|
1680
2391
|
self.embedding_viewer.render_selection_from_ids(set())
|
1681
2392
|
self.update_button_states()
|
1682
2393
|
|
1683
|
-
|
2394
|
+
self.current_embedding_model_info = self.model_settings_widget.get_selected_model()
|
2395
|
+
|
1684
2396
|
embedding_params = self.embedding_settings_widget.get_embedding_parameters()
|
1685
|
-
|
1686
|
-
|
1687
|
-
|
2397
|
+
selected_model, selected_feature_mode = self.current_embedding_model_info
|
2398
|
+
|
2399
|
+
# If the model name is a path, use only its base name.
|
2400
|
+
if os.path.sep in selected_model or '/' in selected_model:
|
2401
|
+
sanitized_model_name = os.path.basename(selected_model)
|
2402
|
+
else:
|
2403
|
+
sanitized_model_name = selected_model
|
2404
|
+
|
2405
|
+
# Replace characters that might be problematic in filenames
|
2406
|
+
sanitized_model_name = sanitized_model_name.replace(' ', '_')
|
2407
|
+
# Also replace the forward slash to handle "N/A"
|
2408
|
+
sanitized_feature_mode = selected_feature_mode.replace(' ', '_').replace('/', '_')
|
2409
|
+
|
2410
|
+
model_key = f"{sanitized_model_name}_{sanitized_feature_mode}"
|
1688
2411
|
|
1689
2412
|
QApplication.setOverrideCursor(Qt.WaitCursor)
|
1690
|
-
progress_bar = ProgressBar(self, "
|
2413
|
+
progress_bar = ProgressBar(self, "Processing Annotations")
|
1691
2414
|
progress_bar.show()
|
2415
|
+
|
1692
2416
|
try:
|
1693
|
-
|
1694
|
-
|
1695
|
-
|
1696
|
-
|
1697
|
-
|
1698
|
-
self.
|
1699
|
-
|
1700
|
-
|
1701
|
-
|
1702
|
-
|
2417
|
+
progress_bar.set_busy_mode("Checking feature cache...")
|
2418
|
+
cached_features, items_to_process = self.feature_store.get_features(items_to_embed, model_key)
|
2419
|
+
print(f"Found {len(cached_features)} features in cache. Need to compute {len(items_to_process)}.")
|
2420
|
+
|
2421
|
+
if items_to_process:
|
2422
|
+
newly_extracted_features, valid_items_processed = self._extract_features(items_to_process,
|
2423
|
+
progress_bar=progress_bar)
|
2424
|
+
if len(newly_extracted_features) > 0:
|
2425
|
+
progress_bar.set_busy_mode("Saving new features to cache...")
|
2426
|
+
self.feature_store.add_features(valid_items_processed, newly_extracted_features, model_key)
|
2427
|
+
new_features_dict = {item.annotation.id: vec for item, vec in zip(valid_items_processed,
|
2428
|
+
newly_extracted_features)}
|
2429
|
+
cached_features.update(new_features_dict)
|
2430
|
+
|
2431
|
+
if not cached_features:
|
2432
|
+
print("No features found or computed. Aborting.")
|
1703
2433
|
return
|
1704
2434
|
|
2435
|
+
final_feature_list = []
|
2436
|
+
final_data_items = []
|
2437
|
+
for item in items_to_embed:
|
2438
|
+
if item.annotation.id in cached_features:
|
2439
|
+
final_feature_list.append(cached_features[item.annotation.id])
|
2440
|
+
final_data_items.append(item)
|
2441
|
+
|
2442
|
+
features = np.array(final_feature_list)
|
2443
|
+
self.current_data_items = final_data_items
|
2444
|
+
self.annotation_viewer.update_annotations(self.current_data_items)
|
2445
|
+
|
1705
2446
|
progress_bar.set_busy_mode("Running dimensionality reduction...")
|
1706
2447
|
embedded_features = self._run_dimensionality_reduction(features, embedding_params)
|
1707
|
-
|
1708
|
-
if embedded_features is None:
|
2448
|
+
|
2449
|
+
if embedded_features is None:
|
1709
2450
|
return
|
1710
2451
|
|
1711
2452
|
progress_bar.set_busy_mode("Updating visualization...")
|
@@ -1713,6 +2454,21 @@ class ExplorerWindow(QMainWindow):
|
|
1713
2454
|
self.embedding_viewer.update_embeddings(self.current_data_items)
|
1714
2455
|
self.embedding_viewer.show_embedding()
|
1715
2456
|
self.embedding_viewer.fit_view_to_points()
|
2457
|
+
|
2458
|
+
# Check if confidence scores are available to enable sorting
|
2459
|
+
_, feature_mode = self.current_embedding_model_info
|
2460
|
+
is_predict_mode = feature_mode == "Predictions"
|
2461
|
+
self.annotation_viewer.set_confidence_sort_availability(is_predict_mode)
|
2462
|
+
|
2463
|
+
# If using Predictions mode, update data items with probabilities for confidence sorting
|
2464
|
+
if is_predict_mode:
|
2465
|
+
for item in self.current_data_items:
|
2466
|
+
if item.annotation.id in cached_features:
|
2467
|
+
item.prediction_probabilities = cached_features[item.annotation.id]
|
2468
|
+
|
2469
|
+
# When a new embedding is run, any previous similarity sort becomes irrelevant
|
2470
|
+
self.annotation_viewer.active_ordered_ids = []
|
2471
|
+
|
1716
2472
|
finally:
|
1717
2473
|
QApplication.restoreOverrideCursor()
|
1718
2474
|
progress_bar.finish_progress()
|
@@ -1724,10 +2480,14 @@ class ExplorerWindow(QMainWindow):
|
|
1724
2480
|
QApplication.setOverrideCursor(Qt.WaitCursor)
|
1725
2481
|
try:
|
1726
2482
|
self.current_data_items = self.get_filtered_data_items()
|
1727
|
-
self.current_features = None
|
2483
|
+
self.current_features = None
|
1728
2484
|
self.annotation_viewer.update_annotations(self.current_data_items)
|
1729
2485
|
self.embedding_viewer.clear_points()
|
1730
2486
|
self.embedding_viewer.show_placeholder()
|
2487
|
+
|
2488
|
+
# Reset sort options when filters change
|
2489
|
+
self.annotation_viewer.active_ordered_ids = []
|
2490
|
+
self.annotation_viewer.set_confidence_sort_availability(False)
|
1731
2491
|
finally:
|
1732
2492
|
QApplication.restoreOverrideCursor()
|
1733
2493
|
|
@@ -1737,65 +2497,120 @@ class ExplorerWindow(QMainWindow):
|
|
1737
2497
|
self.annotation_viewer.apply_preview_label_to_selected(label)
|
1738
2498
|
self.update_button_states()
|
1739
2499
|
|
2500
|
+
def delete_data_items(self, data_items_to_delete):
|
2501
|
+
"""
|
2502
|
+
Permanently deletes a list of data items and their associated annotations
|
2503
|
+
and visual components from the explorer and the main application.
|
2504
|
+
"""
|
2505
|
+
if not data_items_to_delete:
|
2506
|
+
return
|
2507
|
+
|
2508
|
+
print(f"Permanently deleting {len(data_items_to_delete)} item(s).")
|
2509
|
+
QApplication.setOverrideCursor(Qt.WaitCursor)
|
2510
|
+
try:
|
2511
|
+
deleted_ann_ids = {item.annotation.id for item in data_items_to_delete}
|
2512
|
+
annotations_to_delete_from_main_app = [item.annotation for item in data_items_to_delete]
|
2513
|
+
|
2514
|
+
# 1. Delete from the main application's data store
|
2515
|
+
self.annotation_window.delete_annotations(annotations_to_delete_from_main_app)
|
2516
|
+
|
2517
|
+
# 2. Remove from Explorer's internal data structures
|
2518
|
+
self.current_data_items = [
|
2519
|
+
item for item in self.current_data_items if item.annotation.id not in deleted_ann_ids
|
2520
|
+
]
|
2521
|
+
for ann_id in deleted_ann_ids:
|
2522
|
+
if ann_id in self.data_item_cache:
|
2523
|
+
del self.data_item_cache[ann_id]
|
2524
|
+
|
2525
|
+
# 3. Remove from AnnotationViewer
|
2526
|
+
blocker = QSignalBlocker(self.annotation_viewer) # Block signals during mass removal
|
2527
|
+
for ann_id in deleted_ann_ids:
|
2528
|
+
if ann_id in self.annotation_viewer.annotation_widgets_by_id:
|
2529
|
+
widget = self.annotation_viewer.annotation_widgets_by_id.pop(ann_id)
|
2530
|
+
if widget in self.annotation_viewer.selected_widgets:
|
2531
|
+
self.annotation_viewer.selected_widgets.remove(widget)
|
2532
|
+
widget.setParent(None)
|
2533
|
+
widget.deleteLater()
|
2534
|
+
blocker.unblock()
|
2535
|
+
self.annotation_viewer.recalculate_widget_positions()
|
2536
|
+
|
2537
|
+
# 4. Remove from EmbeddingViewer
|
2538
|
+
blocker = QSignalBlocker(self.embedding_viewer.graphics_scene)
|
2539
|
+
for ann_id in deleted_ann_ids:
|
2540
|
+
if ann_id in self.embedding_viewer.points_by_id:
|
2541
|
+
point = self.embedding_viewer.points_by_id.pop(ann_id)
|
2542
|
+
self.embedding_viewer.graphics_scene.removeItem(point)
|
2543
|
+
blocker.unblock()
|
2544
|
+
self.embedding_viewer.on_selection_changed() # Trigger update of selection state
|
2545
|
+
|
2546
|
+
# 5. Update UI
|
2547
|
+
self.update_label_window_selection()
|
2548
|
+
self.update_button_states()
|
2549
|
+
|
2550
|
+
# 6. Refresh main window annotations list
|
2551
|
+
affected_images = {ann.image_path for ann in annotations_to_delete_from_main_app}
|
2552
|
+
for image_path in affected_images:
|
2553
|
+
self.image_window.update_image_annotations(image_path)
|
2554
|
+
self.annotation_window.load_annotations()
|
2555
|
+
|
2556
|
+
except Exception as e:
|
2557
|
+
print(f"Error during item deletion: {e}")
|
2558
|
+
finally:
|
2559
|
+
QApplication.restoreOverrideCursor()
|
2560
|
+
|
1740
2561
|
def clear_preview_changes(self):
|
1741
2562
|
"""
|
1742
|
-
Clears all preview changes in
|
1743
|
-
and items marked for deletion.
|
2563
|
+
Clears all preview changes in the annotation viewer and updates tooltips.
|
1744
2564
|
"""
|
1745
2565
|
if hasattr(self, 'annotation_viewer'):
|
1746
2566
|
self.annotation_viewer.clear_preview_states()
|
1747
2567
|
|
1748
|
-
|
1749
|
-
self.
|
2568
|
+
# After reverting, tooltips need to be updated to reflect original labels
|
2569
|
+
for widget in self.annotation_viewer.annotation_widgets_by_id.values():
|
2570
|
+
widget.update_tooltip()
|
2571
|
+
for point in self.embedding_viewer.points_by_id.values():
|
2572
|
+
point.update_tooltip()
|
1750
2573
|
|
1751
2574
|
# After reverting all changes, update the button states
|
1752
2575
|
self.update_button_states()
|
1753
2576
|
print("Cleared all pending changes.")
|
1754
2577
|
|
1755
2578
|
def update_button_states(self):
|
1756
|
-
"""Update the state of Clear Preview and
|
2579
|
+
"""Update the state of Clear Preview, Apply, and Find Similar buttons."""
|
1757
2580
|
has_changes = self.annotation_viewer.has_preview_changes()
|
1758
2581
|
self.clear_preview_button.setEnabled(has_changes)
|
1759
2582
|
self.apply_button.setEnabled(has_changes)
|
2583
|
+
|
2584
|
+
# Update tooltips with a summary of changes
|
1760
2585
|
summary = self.annotation_viewer.get_preview_changes_summary()
|
1761
2586
|
self.clear_preview_button.setToolTip(f"Clear all preview changes - {summary}")
|
1762
2587
|
self.apply_button.setToolTip(f"Apply changes - {summary}")
|
1763
2588
|
|
2589
|
+
# Logic for the "Find Similar" button
|
2590
|
+
selection_exists = bool(self.annotation_viewer.selected_widgets)
|
2591
|
+
embedding_exists = bool(self.embedding_viewer.points_by_id) and self.current_embedding_model_info is not None
|
2592
|
+
self.annotation_viewer.find_similar_button.setEnabled(selection_exists and embedding_exists)
|
2593
|
+
|
1764
2594
|
def apply(self):
|
1765
2595
|
"""
|
1766
|
-
Apply all pending
|
1767
|
-
to the main application's data.
|
2596
|
+
Apply all pending label modifications to the main application's data.
|
1768
2597
|
"""
|
1769
2598
|
QApplication.setOverrideCursor(Qt.WaitCursor)
|
1770
2599
|
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 ---
|
2600
|
+
# --- 1. Process Label Changes ---
|
1783
2601
|
applied_label_changes = []
|
1784
|
-
|
2602
|
+
# Iterate over all current data items
|
2603
|
+
for item in self.current_data_items:
|
1785
2604
|
if item.apply_preview_permanently():
|
1786
2605
|
applied_label_changes.append(item.annotation)
|
1787
2606
|
|
1788
|
-
# ---
|
1789
|
-
if not
|
2607
|
+
# --- 2. Update UI if any changes were made ---
|
2608
|
+
if not applied_label_changes:
|
1790
2609
|
print("No pending changes to apply.")
|
1791
2610
|
return
|
1792
2611
|
|
1793
|
-
# Update the Explorer's internal list of data items
|
1794
|
-
self.current_data_items = items_to_keep
|
1795
|
-
|
1796
2612
|
# Update the main application's data and UI
|
1797
|
-
|
1798
|
-
affected_images = {ann.image_path for ann in all_affected_annotations}
|
2613
|
+
affected_images = {ann.image_path for ann in applied_label_changes}
|
1799
2614
|
for image_path in affected_images:
|
1800
2615
|
self.image_window.update_image_annotations(image_path)
|
1801
2616
|
self.annotation_window.load_annotations()
|
@@ -1808,13 +2623,13 @@ class ExplorerWindow(QMainWindow):
|
|
1808
2623
|
self.update_label_window_selection()
|
1809
2624
|
self.update_button_states()
|
1810
2625
|
|
1811
|
-
print(
|
2626
|
+
print("Applied changes successfully.")
|
1812
2627
|
|
1813
2628
|
except Exception as e:
|
1814
2629
|
print(f"Error applying modifications: {e}")
|
1815
2630
|
finally:
|
1816
2631
|
QApplication.restoreOverrideCursor()
|
1817
|
-
|
2632
|
+
|
1818
2633
|
def _cleanup_resources(self):
|
1819
2634
|
"""Clean up resources."""
|
1820
2635
|
self.loaded_model = None
|