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

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