coralnet-toolbox 0.0.68__py2.py3-none-any.whl → 0.0.70__py2.py3-none-any.whl

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