coralnet-toolbox 0.0.71__py2.py3-none-any.whl → 0.0.73__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.
Files changed (39) hide show
  1. coralnet_toolbox/Annotations/QtRectangleAnnotation.py +31 -2
  2. coralnet_toolbox/AutoDistill/QtDeployModel.py +23 -12
  3. coralnet_toolbox/Explorer/QtDataItem.py +53 -21
  4. coralnet_toolbox/Explorer/QtExplorer.py +581 -276
  5. coralnet_toolbox/Explorer/QtFeatureStore.py +15 -0
  6. coralnet_toolbox/Explorer/QtSettingsWidgets.py +49 -7
  7. coralnet_toolbox/MachineLearning/DeployModel/QtDetect.py +22 -11
  8. coralnet_toolbox/MachineLearning/DeployModel/QtSegment.py +22 -10
  9. coralnet_toolbox/MachineLearning/ExportDataset/QtBase.py +61 -24
  10. coralnet_toolbox/MachineLearning/ExportDataset/QtClassify.py +5 -1
  11. coralnet_toolbox/MachineLearning/ExportDataset/QtDetect.py +19 -6
  12. coralnet_toolbox/MachineLearning/ExportDataset/QtSegment.py +21 -8
  13. coralnet_toolbox/QtAnnotationWindow.py +52 -16
  14. coralnet_toolbox/QtEventFilter.py +8 -2
  15. coralnet_toolbox/QtImageWindow.py +17 -18
  16. coralnet_toolbox/QtLabelWindow.py +1 -1
  17. coralnet_toolbox/QtMainWindow.py +203 -8
  18. coralnet_toolbox/Rasters/QtRaster.py +59 -7
  19. coralnet_toolbox/Rasters/RasterTableModel.py +34 -6
  20. coralnet_toolbox/SAM/QtBatchInference.py +0 -2
  21. coralnet_toolbox/SAM/QtDeployGenerator.py +22 -11
  22. coralnet_toolbox/SeeAnything/QtBatchInference.py +19 -221
  23. coralnet_toolbox/SeeAnything/QtDeployGenerator.py +1016 -0
  24. coralnet_toolbox/SeeAnything/QtDeployPredictor.py +69 -53
  25. coralnet_toolbox/SeeAnything/QtTrainModel.py +115 -45
  26. coralnet_toolbox/SeeAnything/__init__.py +2 -0
  27. coralnet_toolbox/Tools/QtResizeSubTool.py +6 -1
  28. coralnet_toolbox/Tools/QtSAMTool.py +150 -7
  29. coralnet_toolbox/Tools/QtSeeAnythingTool.py +220 -55
  30. coralnet_toolbox/Tools/QtSelectSubTool.py +6 -4
  31. coralnet_toolbox/Tools/QtSelectTool.py +48 -6
  32. coralnet_toolbox/Tools/QtWorkAreaTool.py +25 -13
  33. coralnet_toolbox/__init__.py +1 -1
  34. {coralnet_toolbox-0.0.71.dist-info → coralnet_toolbox-0.0.73.dist-info}/METADATA +1 -1
  35. {coralnet_toolbox-0.0.71.dist-info → coralnet_toolbox-0.0.73.dist-info}/RECORD +39 -38
  36. {coralnet_toolbox-0.0.71.dist-info → coralnet_toolbox-0.0.73.dist-info}/WHEEL +0 -0
  37. {coralnet_toolbox-0.0.71.dist-info → coralnet_toolbox-0.0.73.dist-info}/entry_points.txt +0 -0
  38. {coralnet_toolbox-0.0.71.dist-info → coralnet_toolbox-0.0.73.dist-info}/licenses/LICENSE.txt +0 -0
  39. {coralnet_toolbox-0.0.71.dist-info → coralnet_toolbox-0.0.73.dist-info}/top_level.txt +0 -0
@@ -11,12 +11,12 @@ from coralnet_toolbox.Icons import get_icon
11
11
  from coralnet_toolbox.utilities import pixmap_to_numpy
12
12
 
13
13
  from PyQt5.QtGui import QIcon, QPen, QColor, QPainter, QBrush, QPainterPath, QMouseEvent
14
- from PyQt5.QtCore import Qt, QTimer, QRect, QRectF, QPointF, pyqtSignal, QSignalBlocker, pyqtSlot
14
+ from PyQt5.QtCore import Qt, QTimer, QRect, QRectF, QPointF, pyqtSignal, QSignalBlocker, pyqtSlot, QEvent
15
15
  from PyQt5.QtWidgets import (QVBoxLayout, QHBoxLayout, QGraphicsView, QScrollArea,
16
16
  QGraphicsScene, QPushButton, QComboBox, QLabel, QWidget,
17
17
  QMainWindow, QSplitter, QGroupBox, QSlider, QMessageBox,
18
18
  QApplication, QGraphicsRectItem, QRubberBand, QMenu,
19
- QWidgetAction, QToolButton, QAction)
19
+ QWidgetAction, QToolButton, QAction, QDoubleSpinBox)
20
20
 
21
21
  from coralnet_toolbox.Explorer.QtFeatureStore import FeatureStore
22
22
  from coralnet_toolbox.Explorer.QtDataItem import AnnotationDataItem
@@ -28,6 +28,9 @@ from coralnet_toolbox.Explorer.QtSettingsWidgets import UncertaintySettingsWidge
28
28
  from coralnet_toolbox.Explorer.QtSettingsWidgets import MislabelSettingsWidget
29
29
  from coralnet_toolbox.Explorer.QtSettingsWidgets import EmbeddingSettingsWidget
30
30
  from coralnet_toolbox.Explorer.QtSettingsWidgets import AnnotationSettingsWidget
31
+ from coralnet_toolbox.Explorer.QtSettingsWidgets import DuplicateSettingsWidget
32
+
33
+ from coralnet_toolbox.Annotations.QtRectangleAnnotation import RectangleAnnotation
31
34
 
32
35
  from coralnet_toolbox.QtProgressBar import ProgressBar
33
36
 
@@ -66,6 +69,8 @@ class EmbeddingViewer(QWidget):
66
69
  mislabel_parameters_changed = pyqtSignal(dict)
67
70
  find_uncertain_requested = pyqtSignal()
68
71
  uncertainty_parameters_changed = pyqtSignal(dict)
72
+ find_duplicates_requested = pyqtSignal()
73
+ duplicate_parameters_changed = pyqtSignal(dict)
69
74
 
70
75
  def __init__(self, parent=None):
71
76
  """Initialize the EmbeddingViewer widget."""
@@ -99,6 +104,11 @@ class EmbeddingViewer(QWidget):
99
104
  self.animation_timer.timeout.connect(self.animate_selection)
100
105
  self.animation_timer.setInterval(100)
101
106
 
107
+ # New timer for virtualization
108
+ self.view_update_timer = QTimer(self)
109
+ self.view_update_timer.setSingleShot(True)
110
+ self.view_update_timer.timeout.connect(self._update_visible_points)
111
+
102
112
  self.graphics_scene.selectionChanged.connect(self.on_selection_changed)
103
113
  self.setup_ui()
104
114
  self.graphics_view.mousePressEvent = self.mousePressEvent
@@ -186,8 +196,37 @@ class EmbeddingViewer(QWidget):
186
196
 
187
197
  uncertainty_settings_widget.parameters_changed.connect(self.uncertainty_parameters_changed.emit)
188
198
  toolbar_layout.addWidget(self.find_uncertain_button)
199
+
200
+ # Create a QToolButton for duplicate detection
201
+ self.find_duplicates_button = QToolButton()
202
+ self.find_duplicates_button.setText("Find Duplicates")
203
+ self.find_duplicates_button.setToolTip(
204
+ "Find annotations that are likely duplicates based on feature similarity."
205
+ )
206
+ self.find_duplicates_button.setPopupMode(QToolButton.MenuButtonPopup)
207
+ self.find_duplicates_button.setToolButtonStyle(Qt.ToolButtonTextOnly)
208
+ self.find_duplicates_button.setStyleSheet(
209
+ "QToolButton::menu-indicator { "
210
+ "subcontrol-position: right center; "
211
+ "subcontrol-origin: padding; "
212
+ "left: -4px; }"
213
+ )
214
+
215
+ run_duplicates_action = QAction("Find Duplicates", self)
216
+ run_duplicates_action.triggered.connect(self.find_duplicates_requested.emit)
217
+ self.find_duplicates_button.setDefaultAction(run_duplicates_action)
218
+
219
+ duplicate_settings_widget = DuplicateSettingsWidget()
220
+ duplicate_menu = QMenu(self)
221
+ duplicate_widget_action = QWidgetAction(duplicate_menu)
222
+ duplicate_widget_action.setDefaultWidget(duplicate_settings_widget)
223
+ duplicate_menu.addAction(duplicate_widget_action)
224
+ self.find_duplicates_button.setMenu(duplicate_menu)
225
+
226
+ duplicate_settings_widget.parameters_changed.connect(self.duplicate_parameters_changed.emit)
227
+ toolbar_layout.addWidget(self.find_duplicates_button)
189
228
 
190
- # Add a strech and separator
229
+ # Add a stretch and separator
191
230
  toolbar_layout.addStretch()
192
231
  toolbar_layout.addWidget(self._create_separator())
193
232
 
@@ -224,6 +263,26 @@ class EmbeddingViewer(QWidget):
224
263
  separator.setStyleSheet("color: gray; margin: 0 5px;")
225
264
  return separator
226
265
 
266
+ def _schedule_view_update(self):
267
+ """Schedules a delayed update of visible points to avoid performance issues."""
268
+ self.view_update_timer.start(50) # 50ms delay
269
+
270
+ def _update_visible_points(self):
271
+ """Sets visibility for points based on whether they are in the viewport."""
272
+ if self.isolated_mode or not self.points_by_id:
273
+ return
274
+
275
+ # Get the visible rectangle in scene coordinates
276
+ visible_rect = self.graphics_view.mapToScene(self.graphics_view.viewport().rect()).boundingRect()
277
+
278
+ # Add a buffer to make scrolling smoother by loading points before they enter the view
279
+ buffer_x = visible_rect.width() * 0.2
280
+ buffer_y = visible_rect.height() * 0.2
281
+ buffered_visible_rect = visible_rect.adjusted(-buffer_x, -buffer_y, buffer_x, buffer_y)
282
+
283
+ for point in self.points_by_id.values():
284
+ point.setVisible(buffered_visible_rect.contains(point.pos()) or point.isSelected())
285
+
227
286
  @pyqtSlot()
228
287
  def isolate_selection(self):
229
288
  """Hides all points that are not currently selected."""
@@ -235,8 +294,7 @@ class EmbeddingViewer(QWidget):
235
294
  self.graphics_view.setUpdatesEnabled(False)
236
295
  try:
237
296
  for point in self.points_by_id.values():
238
- if point not in self.isolated_points:
239
- point.hide()
297
+ point.setVisible(point in self.isolated_points)
240
298
  self.isolated_mode = True
241
299
  finally:
242
300
  self.graphics_view.setUpdatesEnabled(True)
@@ -253,8 +311,8 @@ class EmbeddingViewer(QWidget):
253
311
  self.isolated_points.clear()
254
312
  self.graphics_view.setUpdatesEnabled(False)
255
313
  try:
256
- for point in self.points_by_id.values():
257
- point.show()
314
+ # Instead of showing all, let the virtualization logic take over
315
+ self._update_visible_points()
258
316
  finally:
259
317
  self.graphics_view.setUpdatesEnabled(True)
260
318
 
@@ -267,6 +325,7 @@ class EmbeddingViewer(QWidget):
267
325
 
268
326
  self.find_mislabels_button.setEnabled(points_exist)
269
327
  self.find_uncertain_button.setEnabled(points_exist and self.is_uncertainty_analysis_available)
328
+ self.find_duplicates_button.setEnabled(points_exist)
270
329
  self.center_on_selection_button.setEnabled(points_exist and selection_exists)
271
330
 
272
331
  if self.isolated_mode:
@@ -322,6 +381,7 @@ class EmbeddingViewer(QWidget):
322
381
  self.center_on_selection_button.setEnabled(False) # Disable center button
323
382
  self.find_mislabels_button.setEnabled(False)
324
383
  self.find_uncertain_button.setEnabled(False)
384
+ self.find_duplicates_button.setEnabled(False)
325
385
 
326
386
  self.isolate_button.show()
327
387
  self.isolate_button.setEnabled(False)
@@ -483,6 +543,7 @@ class EmbeddingViewer(QWidget):
483
543
  # Forward right-drag as left-drag for panning
484
544
  left_event = QMouseEvent(event.type(), event.localPos(), Qt.LeftButton, Qt.LeftButton, event.modifiers())
485
545
  QGraphicsView.mouseMoveEvent(self.graphics_view, left_event)
546
+ self._schedule_view_update()
486
547
  else:
487
548
  # Default mouse move handling
488
549
  QGraphicsView.mouseMoveEvent(self.graphics_view, event)
@@ -496,6 +557,7 @@ class EmbeddingViewer(QWidget):
496
557
  elif event.button() == Qt.RightButton:
497
558
  left_event = QMouseEvent(event.type(), event.localPos(), Qt.LeftButton, Qt.LeftButton, event.modifiers())
498
559
  QGraphicsView.mouseReleaseEvent(self.graphics_view, left_event)
560
+ self._schedule_view_update()
499
561
  self.graphics_view.setDragMode(QGraphicsView.NoDrag)
500
562
  else:
501
563
  QGraphicsView.mouseReleaseEvent(self.graphics_view, event)
@@ -525,6 +587,7 @@ class EmbeddingViewer(QWidget):
525
587
  # Translate view to keep mouse position stable
526
588
  delta = new_pos - old_pos
527
589
  self.graphics_view.translate(delta.x(), delta.y())
590
+ self._schedule_view_update()
528
591
 
529
592
  def update_embeddings(self, data_items):
530
593
  """Update the embedding visualization. Creates an EmbeddingPointItem for
@@ -541,6 +604,8 @@ class EmbeddingViewer(QWidget):
541
604
 
542
605
  # Ensure buttons are in the correct initial state
543
606
  self._update_toolbar_state()
607
+ # Set initial visibility
608
+ self._update_visible_points()
544
609
 
545
610
  def clear_points(self):
546
611
  """Clear all embedding points from the scene."""
@@ -585,6 +650,9 @@ class EmbeddingViewer(QWidget):
585
650
 
586
651
  # Update button states based on new selection
587
652
  self._update_toolbar_state()
653
+
654
+ # A selection change can affect visibility (e.g., deselecting an off-screen point)
655
+ self._schedule_view_update()
588
656
 
589
657
  def animate_selection(self):
590
658
  """Animate selected points with a marching ants effect."""
@@ -624,6 +692,9 @@ class EmbeddingViewer(QWidget):
624
692
 
625
693
  # Manually trigger on_selection_changed to update animation and emit signals
626
694
  self.on_selection_changed()
695
+
696
+ # After selection, update visibility to ensure newly selected points are shown
697
+ self._update_visible_points()
627
698
 
628
699
  def fit_view_to_points(self):
629
700
  """Fit the view to show all embedding points."""
@@ -631,11 +702,13 @@ class EmbeddingViewer(QWidget):
631
702
  self.graphics_view.fitInView(self.graphics_scene.itemsBoundingRect(), Qt.KeepAspectRatio)
632
703
  else:
633
704
  self.graphics_view.fitInView(-2500, -2500, 5000, 5000, Qt.KeepAspectRatio)
634
-
635
-
636
- class AnnotationViewer(QScrollArea):
637
- """Scrollable grid widget for displaying annotation image crops with selection,
638
- filtering, and isolation support. Acts as a controller for the widgets."""
705
+
706
+
707
+ class AnnotationViewer(QWidget):
708
+ """
709
+ Widget containing a toolbar and a scrollable grid for displaying annotation image crops.
710
+ Implements virtualization to only render visible widgets.
711
+ """
639
712
  selection_changed = pyqtSignal(list)
640
713
  preview_changed = pyqtSignal(list)
641
714
  reset_view_requested = pyqtSignal()
@@ -648,7 +721,7 @@ class AnnotationViewer(QScrollArea):
648
721
 
649
722
  self.annotation_widgets_by_id = {}
650
723
  self.selected_widgets = []
651
- self.last_selected_index = -1
724
+ self.last_selected_item_id = None # Use a persistent ID for the selection anchor
652
725
  self.current_widget_size = 96
653
726
  self.selection_at_press = set()
654
727
  self.rubber_band = None
@@ -660,23 +733,32 @@ class AnnotationViewer(QScrollArea):
660
733
  self.isolated_mode = False
661
734
  self.isolated_widgets = set()
662
735
 
663
- # State for new sorting options
736
+ # State for sorting options
664
737
  self.active_ordered_ids = []
665
738
  self.is_confidence_sort_available = False
666
739
 
740
+ # New attributes for virtualization
741
+ self.all_data_items = []
742
+ self.widget_positions = {} # ann_id -> QRect
743
+ self.update_timer = QTimer(self)
744
+ self.update_timer.setSingleShot(True)
745
+ self.update_timer.timeout.connect(self._update_visible_widgets)
746
+
667
747
  self.setup_ui()
668
748
 
749
+ # Connect scrollbar value changed to schedule an update for virtualization
750
+ self.scroll_area.verticalScrollBar().valueChanged.connect(self._schedule_update)
751
+ # Install an event filter on the viewport to handle mouse events for rubber band selection
752
+ self.scroll_area.viewport().installEventFilter(self)
753
+
669
754
  def setup_ui(self):
670
755
  """Set up the UI with a toolbar and a scrollable content area."""
671
- self.setWidgetResizable(True)
672
- self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
673
- self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
674
-
675
- main_container = QWidget()
676
- main_layout = QVBoxLayout(main_container)
756
+ # This widget is the main container with its own layout
757
+ main_layout = QVBoxLayout(self)
677
758
  main_layout.setContentsMargins(0, 0, 0, 0)
678
759
  main_layout.setSpacing(4)
679
760
 
761
+ # Create and add the toolbar to the main layout
680
762
  toolbar_widget = QWidget()
681
763
  toolbar_layout = QHBoxLayout(toolbar_widget)
682
764
  toolbar_layout.setContentsMargins(4, 2, 4, 2)
@@ -742,16 +824,16 @@ class AnnotationViewer(QScrollArea):
742
824
  self.size_value_label.setMinimumWidth(30)
743
825
  toolbar_layout.addWidget(self.size_value_label)
744
826
  main_layout.addWidget(toolbar_widget)
745
-
827
+
828
+ # Create the scroll area which will contain the content
829
+ self.scroll_area = QScrollArea()
830
+ self.scroll_area.setWidgetResizable(True)
831
+ self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
832
+ self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
833
+
746
834
  self.content_widget = QWidget()
747
- content_scroll = QScrollArea()
748
- content_scroll.setWidgetResizable(True)
749
- content_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
750
- content_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
751
- content_scroll.setWidget(self.content_widget)
752
-
753
- main_layout.addWidget(content_scroll)
754
- self.setWidget(main_container)
835
+ self.scroll_area.setWidget(self.content_widget)
836
+ main_layout.addWidget(self.scroll_area)
755
837
 
756
838
  # Set the initial state of the sort options
757
839
  self._update_sort_options_state()
@@ -780,7 +862,7 @@ class AnnotationViewer(QScrollArea):
780
862
  annotation_to_select = widget.annotation
781
863
 
782
864
  # ctrl+right click to only select this annotation (single selection):
783
- self.clear_selection()
865
+ self.clear_selection()
784
866
  self.select_widget(widget)
785
867
  changed_ids = [widget.data_item.annotation.id]
786
868
 
@@ -793,13 +875,25 @@ class AnnotationViewer(QScrollArea):
793
875
  if hasattr(explorer.annotation_window, 'set_image'):
794
876
  explorer.annotation_window.set_image(image_path)
795
877
 
796
- # Now, select the annotation in the annotation_window
878
+ # Now, select the annotation in the annotation_window (activates animation)
797
879
  if hasattr(explorer.annotation_window, 'select_annotation'):
798
- explorer.annotation_window.select_annotation(annotation_to_select)
880
+ explorer.annotation_window.select_annotation(annotation_to_select, quiet_mode=True)
799
881
 
800
882
  # Center the annotation window view on the selected annotation
801
883
  if hasattr(explorer.annotation_window, 'center_on_annotation'):
802
884
  explorer.annotation_window.center_on_annotation(annotation_to_select)
885
+
886
+ # Show resize handles for Rectangle annotations
887
+ if isinstance(annotation_to_select, RectangleAnnotation):
888
+ explorer.annotation_window.set_selected_tool('select') # Accidentally unselects in AnnotationWindow
889
+ explorer.annotation_window.select_annotation(annotation_to_select, quiet_mode=True)
890
+ select_tool = explorer.annotation_window.tools.get('select')
891
+
892
+ if select_tool:
893
+ # Engage the selection lock.
894
+ select_tool.selection_locked = True
895
+ # Show the resize handles for the now-selected annotation.
896
+ select_tool._show_resize_handles()
803
897
 
804
898
  # Also clear any existing selection in the explorer window itself
805
899
  explorer.embedding_viewer.render_selection_from_ids({widget.data_item.annotation.id})
@@ -811,7 +905,7 @@ class AnnotationViewer(QScrollArea):
811
905
  @pyqtSlot()
812
906
  def isolate_selection(self):
813
907
  """Hides all annotation widgets that are not currently selected."""
814
- if not self.selected_widgets or self.isolated_mode:
908
+ if not self.selected_widgets:
815
909
  return
816
910
 
817
911
  self.isolated_widgets = set(self.selected_widgets)
@@ -821,13 +915,36 @@ class AnnotationViewer(QScrollArea):
821
915
  if widget not in self.isolated_widgets:
822
916
  widget.hide()
823
917
  self.isolated_mode = True
824
- self.recalculate_widget_positions()
918
+ self.recalculate_layout()
825
919
  finally:
826
920
  self.content_widget.setUpdatesEnabled(True)
827
921
 
828
922
  self._update_toolbar_state()
829
923
  self.explorer_window.main_window.label_window.update_annotation_count()
830
924
 
925
+ def isolate_and_select_from_ids(self, ids_to_isolate):
926
+ """
927
+ Enters isolated mode showing only widgets for the given IDs, and also
928
+ selects them. This is the primary entry point from external viewers.
929
+ The isolated set is 'sticky' and will not change on subsequent internal
930
+ selection changes.
931
+ """
932
+ # Get the widget objects from the IDs
933
+ widgets_to_isolate = {
934
+ self.annotation_widgets_by_id[ann_id]
935
+ for ann_id in ids_to_isolate
936
+ if ann_id in self.annotation_widgets_by_id
937
+ }
938
+
939
+ if not widgets_to_isolate:
940
+ return
941
+
942
+ self.isolated_widgets = widgets_to_isolate
943
+ self.isolated_mode = True
944
+
945
+ self.render_selection_from_ids(ids_to_isolate)
946
+ self.recalculate_layout()
947
+
831
948
  def display_and_isolate_ordered_results(self, ordered_ids):
832
949
  """
833
950
  Isolates the view to a specific set of ordered widgets, ensuring the
@@ -851,7 +968,7 @@ class AnnotationViewer(QScrollArea):
851
968
  widget.hide()
852
969
 
853
970
  self.isolated_mode = True
854
- self.recalculate_widget_positions() # Crucial grid update
971
+ self.recalculate_layout() # Crucial grid update
855
972
  finally:
856
973
  self.content_widget.setUpdatesEnabled(True)
857
974
 
@@ -874,7 +991,7 @@ class AnnotationViewer(QScrollArea):
874
991
  for widget in self.annotation_widgets_by_id.values():
875
992
  widget.show()
876
993
 
877
- self.recalculate_widget_positions()
994
+ self.recalculate_layout()
878
995
  finally:
879
996
  self.content_widget.setUpdatesEnabled(True)
880
997
 
@@ -896,62 +1013,71 @@ class AnnotationViewer(QScrollArea):
896
1013
  def on_sort_changed(self, sort_type):
897
1014
  """Handle sort type change."""
898
1015
  self.active_ordered_ids = [] # Clear any special ordering
899
- self.recalculate_widget_positions()
1016
+ self.recalculate_layout()
900
1017
 
901
1018
  def set_confidence_sort_availability(self, is_available):
902
1019
  """Sets the availability of the confidence sort option."""
903
1020
  self.is_confidence_sort_available = is_available
904
1021
  self._update_sort_options_state()
905
1022
 
906
- def _get_sorted_widgets(self):
907
- """Get widgets sorted according to the current sort setting."""
1023
+ def _get_sorted_data_items(self):
1024
+ """Get data items sorted according to the current sort setting."""
908
1025
  # If a specific order is active (e.g., from similarity search), use it.
909
1026
  if self.active_ordered_ids:
910
- widget_map = {w.data_item.annotation.id: w for w in self.annotation_widgets_by_id.values()}
911
- ordered_widgets = [widget_map[ann_id] for ann_id in self.active_ordered_ids if ann_id in widget_map]
912
- return ordered_widgets
1027
+ item_map = {i.annotation.id: i for i in self.all_data_items}
1028
+ ordered_items = [item_map[ann_id] for ann_id in self.active_ordered_ids if ann_id in item_map]
1029
+ return ordered_items
913
1030
 
914
1031
  # Otherwise, use the dropdown sort logic
915
1032
  sort_type = self.sort_combo.currentText()
916
- widgets = list(self.annotation_widgets_by_id.values())
1033
+ items = list(self.all_data_items)
917
1034
 
918
1035
  if sort_type == "Label":
919
- widgets.sort(key=lambda w: w.data_item.effective_label.short_label_code)
1036
+ items.sort(key=lambda i: i.effective_label.short_label_code)
920
1037
  elif sort_type == "Image":
921
- widgets.sort(key=lambda w: os.path.basename(w.data_item.annotation.image_path))
1038
+ items.sort(key=lambda i: os.path.basename(i.annotation.image_path))
922
1039
  elif sort_type == "Confidence":
923
1040
  # Sort by confidence, descending. Handles cases with no confidence gracefully.
924
- widgets.sort(key=lambda w: w.data_item.get_effective_confidence(), reverse=True)
925
-
926
- return widgets
1041
+ items.sort(key=lambda i: i.get_effective_confidence(), reverse=True)
927
1042
 
928
- def _group_widgets_by_sort_key(self, widgets):
929
- """Group widgets by the current sort key."""
1043
+ return items
1044
+
1045
+ def _get_sorted_widgets(self):
1046
+ """
1047
+ Get widgets sorted according to the current sort setting.
1048
+ This is kept for compatibility with selection logic.
1049
+ """
1050
+ sorted_data_items = self._get_sorted_data_items()
1051
+ return [self.annotation_widgets_by_id[item.annotation.id]
1052
+ for item in sorted_data_items if item.annotation.id in self.annotation_widgets_by_id]
1053
+
1054
+ def _group_data_items_by_sort_key(self, data_items):
1055
+ """Group data items by the current sort key."""
930
1056
  sort_type = self.sort_combo.currentText()
931
1057
  if not self.active_ordered_ids and sort_type == "None":
932
- return [("", widgets)]
933
-
934
- if self.active_ordered_ids: # Don't show group headers for similarity results
935
- return [("", widgets)]
1058
+ return [("", data_items)]
1059
+
1060
+ if self.active_ordered_ids: # Don't show group headers for similarity results
1061
+ return [("", data_items)]
936
1062
 
937
1063
  groups = []
938
1064
  current_group = []
939
1065
  current_key = None
940
- for widget in widgets:
1066
+ for item in data_items:
941
1067
  if sort_type == "Label":
942
- key = widget.data_item.effective_label.short_label_code
1068
+ key = item.effective_label.short_label_code
943
1069
  elif sort_type == "Image":
944
- key = os.path.basename(widget.data_item.annotation.image_path)
1070
+ key = os.path.basename(item.annotation.image_path)
945
1071
  else:
946
1072
  key = "" # No headers for Confidence or None
947
1073
 
948
1074
  if key and current_key != key:
949
1075
  if current_group:
950
1076
  groups.append((current_key, current_group))
951
- current_group = [widget]
1077
+ current_group = [item]
952
1078
  current_key = key
953
1079
  else:
954
- current_group.append(widget)
1080
+ current_group.append(item)
955
1081
  if current_group:
956
1082
  groups.append((current_key, current_group))
957
1083
  return groups
@@ -982,7 +1108,7 @@ class AnnotationViewer(QScrollArea):
982
1108
  " }"
983
1109
  )
984
1110
  header.setFixedHeight(30)
985
- header.setMinimumWidth(self.viewport().width() - 20)
1111
+ header.setMinimumWidth(self.scroll_area.viewport().width() - 20)
986
1112
  header.show()
987
1113
  self._group_headers.append(header)
988
1114
  return header
@@ -994,35 +1120,79 @@ class AnnotationViewer(QScrollArea):
994
1120
 
995
1121
  self.current_widget_size = value
996
1122
  self.size_value_label.setText(str(value))
997
- self.content_widget.setUpdatesEnabled(False)
1123
+ self.recalculate_layout()
998
1124
 
999
- for widget in self.annotation_widgets_by_id.values():
1000
- widget.update_height(value)
1125
+ def _schedule_update(self):
1126
+ """Schedules a delayed update of visible widgets to avoid performance issues during rapid scrolling."""
1127
+ self.update_timer.start(50) # 50ms delay
1128
+
1129
+ def _update_visible_widgets(self):
1130
+ """Shows and loads widgets that are in the viewport, and hides/unloads others."""
1131
+ if not self.widget_positions:
1132
+ return
1133
+
1134
+ self.content_widget.setUpdatesEnabled(False)
1135
+
1136
+ # Determine the visible rectangle in the content widget's coordinates
1137
+ scroll_y = self.scroll_area.verticalScrollBar().value()
1138
+ visible_content_rect = QRect(0,
1139
+ scroll_y,
1140
+ self.scroll_area.viewport().width(),
1141
+ self.scroll_area.viewport().height())
1142
+
1143
+ # Add a buffer to load images slightly before they become visible
1144
+ buffer = self.scroll_area.viewport().height() // 2
1145
+ visible_content_rect.adjust(0, -buffer, 0, buffer)
1146
+
1147
+ visible_ids = set()
1148
+ for ann_id, rect in self.widget_positions.items():
1149
+ if rect.intersects(visible_content_rect):
1150
+ visible_ids.add(ann_id)
1151
+
1152
+ # Update widgets based on visibility
1153
+ for ann_id, widget in self.annotation_widgets_by_id.items():
1154
+ if ann_id in visible_ids:
1155
+ # This widget should be visible
1156
+ widget.setGeometry(self.widget_positions[ann_id])
1157
+ widget.load_image() # Lazy-loads the image
1158
+ widget.show()
1159
+ else:
1160
+ # This widget is not visible
1161
+ if widget.isVisible():
1162
+ widget.hide()
1163
+ widget.unload_image() # Free up memory
1001
1164
 
1002
1165
  self.content_widget.setUpdatesEnabled(True)
1003
- self.recalculate_widget_positions()
1004
1166
 
1005
- def recalculate_widget_positions(self):
1006
- """Manually positions widgets in a flow layout with sorting and group headers."""
1007
- if not self.annotation_widgets_by_id:
1167
+ def recalculate_layout(self):
1168
+ """Calculates the positions for all widgets and the total size of the content area."""
1169
+ if not self.all_data_items:
1008
1170
  self.content_widget.setMinimumSize(1, 1)
1009
1171
  return
1010
1172
 
1011
1173
  self._clear_separator_labels()
1012
- visible_widgets = [w for w in self._get_sorted_widgets() if not w.isHidden()]
1013
- if not visible_widgets:
1174
+ sorted_data_items = self._get_sorted_data_items()
1175
+
1176
+ # If in isolated mode, only consider the isolated widgets for layout
1177
+ if self.isolated_mode:
1178
+ isolated_ids = {w.data_item.annotation.id for w in self.isolated_widgets}
1179
+ sorted_data_items = [item for item in sorted_data_items if item.annotation.id in isolated_ids]
1180
+
1181
+ if not sorted_data_items:
1014
1182
  self.content_widget.setMinimumSize(1, 1)
1015
1183
  return
1016
1184
 
1017
1185
  # Create groups based on the current sort key
1018
- groups = self._group_widgets_by_sort_key(visible_widgets)
1186
+ groups = self._group_data_items_by_sort_key(sorted_data_items)
1019
1187
  spacing = max(5, int(self.current_widget_size * 0.08))
1020
- available_width = self.viewport().width()
1188
+ available_width = self.scroll_area.viewport().width()
1021
1189
  x, y = spacing, spacing
1022
1190
  max_height_in_row = 0
1023
1191
 
1024
- # Calculate the maximum height of the widgets in each row
1025
- for group_name, group_widgets in groups:
1192
+ self.widget_positions.clear()
1193
+
1194
+ # Calculate positions
1195
+ for group_name, group_data_items in groups:
1026
1196
  if group_name and self.sort_combo.currentText() != "None":
1027
1197
  if x > spacing:
1028
1198
  x = spacing
@@ -1034,51 +1204,65 @@ class AnnotationViewer(QScrollArea):
1034
1204
  x = spacing
1035
1205
  max_height_in_row = 0
1036
1206
 
1037
- for widget in group_widgets:
1207
+ for data_item in group_data_items:
1208
+ ann_id = data_item.annotation.id
1209
+ # Get or create widget to determine its size
1210
+ if ann_id in self.annotation_widgets_by_id:
1211
+ widget = self.annotation_widgets_by_id[ann_id]
1212
+ widget.update_height(self.current_widget_size) # Ensure size is up-to-date
1213
+ else:
1214
+ widget = AnnotationImageWidget(data_item, self.current_widget_size, self, self.content_widget)
1215
+ self.annotation_widgets_by_id[ann_id] = widget
1216
+ widget.hide() # Hide by default
1217
+
1038
1218
  widget_size = widget.size()
1039
1219
  if x > spacing and x + widget_size.width() > available_width:
1040
1220
  x = spacing
1041
1221
  y += max_height_in_row + spacing
1042
1222
  max_height_in_row = 0
1043
- widget.move(x, y)
1223
+
1224
+ self.widget_positions[ann_id] = QRect(x, y, widget_size.width(), widget_size.height())
1225
+
1044
1226
  x += widget_size.width() + spacing
1045
1227
  max_height_in_row = max(max_height_in_row, widget_size.height())
1046
1228
 
1047
1229
  total_height = y + max_height_in_row + spacing
1048
1230
  self.content_widget.setMinimumSize(available_width, total_height)
1049
1231
 
1232
+ # After calculating layout, update what's visible
1233
+ self._update_visible_widgets()
1234
+
1050
1235
  def update_annotations(self, data_items):
1051
1236
  """Update displayed annotations, creating new widgets for them."""
1052
1237
  if self.isolated_mode:
1053
1238
  self.show_all_annotations()
1054
1239
 
1055
- for widget in self.annotation_widgets_by_id.values():
1056
- widget.setParent(None)
1057
- widget.deleteLater()
1058
-
1059
- self.annotation_widgets_by_id.clear()
1240
+ # Clear out widgets for data items that are no longer in the new set
1241
+ all_ann_ids = {item.annotation.id for item in data_items}
1242
+ for ann_id, widget in list(self.annotation_widgets_by_id.items()):
1243
+ if ann_id not in all_ann_ids:
1244
+ if widget in self.selected_widgets:
1245
+ self.selected_widgets.remove(widget)
1246
+ widget.setParent(None)
1247
+ widget.deleteLater()
1248
+ del self.annotation_widgets_by_id[ann_id]
1249
+
1250
+ self.all_data_items = data_items
1060
1251
  self.selected_widgets.clear()
1061
- self.last_selected_index = -1
1062
-
1063
- for data_item in data_items:
1064
- annotation_widget = AnnotationImageWidget(
1065
- data_item, self.current_widget_size, self, self.content_widget)
1066
-
1067
- annotation_widget.show()
1068
- self.annotation_widgets_by_id[data_item.annotation.id] = annotation_widget
1252
+ self.last_selected_item_id = None
1069
1253
 
1070
- self.recalculate_widget_positions()
1254
+ self.recalculate_layout()
1071
1255
  self._update_toolbar_state()
1072
1256
  # Update the label window with the new annotation count
1073
1257
  self.explorer_window.main_window.label_window.update_annotation_count()
1074
1258
 
1075
1259
  def resizeEvent(self, event):
1076
1260
  """On window resize, reflow the annotation widgets."""
1077
- super().resizeEvent(event)
1261
+ super(AnnotationViewer, self).resizeEvent(event)
1078
1262
  if not hasattr(self, '_resize_timer'):
1079
1263
  self._resize_timer = QTimer(self)
1080
1264
  self._resize_timer.setSingleShot(True)
1081
- self._resize_timer.timeout.connect(self.recalculate_widget_positions)
1265
+ self._resize_timer.timeout.connect(self.recalculate_layout)
1082
1266
  self._resize_timer.start(100)
1083
1267
 
1084
1268
  def keyPressEvent(self, event):
@@ -1099,64 +1283,51 @@ class AnnotationViewer(QScrollArea):
1099
1283
  else:
1100
1284
  super().keyPressEvent(event)
1101
1285
 
1102
- def mousePressEvent(self, event):
1103
- """Handle mouse press for starting rubber band selection OR clearing selection."""
1104
- if event.button() == Qt.LeftButton:
1105
- if not event.modifiers():
1106
- # If left click with no modifiers, check if click is outside widgets
1107
- is_on_widget = False
1108
- child_at_pos = self.childAt(event.pos())
1109
-
1110
- if child_at_pos:
1111
- widget = child_at_pos
1112
- # Traverse up the parent chain to see if click is on an annotation widget
1113
- while widget and widget != self:
1114
- if hasattr(widget, 'annotation_viewer') and widget.annotation_viewer == self:
1115
- is_on_widget = True
1116
- break
1117
- widget = widget.parent()
1118
-
1119
- # If click is outside widgets, clear annotation_window selection
1120
- if not is_on_widget:
1121
- # Clear annotation selection in the annotation_window as well
1122
- if hasattr(self.explorer_window, 'annotation_window') and self.explorer_window.annotation_window:
1123
- if hasattr(self.explorer_window.annotation_window, 'unselect_annotations'):
1124
- self.explorer_window.annotation_window.unselect_annotations()
1125
- # If there is a selection in the viewer, clear it
1126
- if self.selected_widgets:
1127
- changed_ids = [w.data_item.annotation.id for w in self.selected_widgets]
1128
- self.clear_selection()
1129
- self.selection_changed.emit(changed_ids)
1130
- return
1131
-
1132
- elif event.modifiers() == Qt.ControlModifier:
1133
- # Start rubber band selection with Ctrl+Left click
1134
- self.selection_at_press = set(self.selected_widgets)
1135
- self.rubber_band_origin = event.pos()
1136
- self.mouse_pressed_on_widget = False
1137
- child_widget = self.childAt(event.pos())
1138
- if child_widget:
1139
- widget = child_widget
1140
- # Check if click is on a widget to avoid starting rubber band
1141
- while widget and widget != self:
1142
- if hasattr(widget, 'annotation_viewer') and widget.annotation_viewer == self:
1143
- self.mouse_pressed_on_widget = True
1144
- break
1145
- widget = widget.parent()
1146
- return
1147
-
1148
- elif event.button() == Qt.RightButton:
1149
- # Ignore right clicks
1150
- event.ignore()
1151
- return
1152
-
1153
- # Default handler for other cases
1154
- super().mousePressEvent(event)
1155
-
1156
- def mouseDoubleClickEvent(self, event):
1157
- """Handle double-click to clear selection and exit isolation mode."""
1286
+ def eventFilter(self, source, event):
1287
+ """Filters events from the scroll area's viewport to handle mouse interactions."""
1288
+ if source is self.scroll_area.viewport():
1289
+ if event.type() == QEvent.MouseButtonPress:
1290
+ return self.viewport_mouse_press(event)
1291
+ elif event.type() == QEvent.MouseMove:
1292
+ return self.viewport_mouse_move(event)
1293
+ elif event.type() == QEvent.MouseButtonRelease:
1294
+ return self.viewport_mouse_release(event)
1295
+ elif event.type() == QEvent.MouseButtonDblClick:
1296
+ return self.viewport_mouse_double_click(event)
1297
+
1298
+ return super(AnnotationViewer, self).eventFilter(source, event)
1299
+
1300
+ def viewport_mouse_press(self, event):
1301
+ """Handle mouse press inside the viewport for selection."""
1302
+ if event.button() == Qt.LeftButton and event.modifiers() == Qt.ControlModifier:
1303
+ # Start rubber band selection
1304
+ self.selection_at_press = set(self.selected_widgets)
1305
+ self.rubber_band_origin = event.pos()
1306
+
1307
+ # Check if the press was on a widget to avoid starting rubber band on a widget click
1308
+ content_pos = self.content_widget.mapFrom(self.scroll_area.viewport(), event.pos())
1309
+ child_at_pos = self.content_widget.childAt(content_pos)
1310
+ self.mouse_pressed_on_widget = isinstance(child_at_pos, AnnotationImageWidget)
1311
+
1312
+ return True # Event handled
1313
+
1314
+ elif event.button() == Qt.LeftButton and not event.modifiers():
1315
+ # Clear selection if clicking on the background
1316
+ content_pos = self.content_widget.mapFrom(self.scroll_area.viewport(), event.pos())
1317
+ if self.content_widget.childAt(content_pos) is None:
1318
+ if self.selected_widgets:
1319
+ changed_ids = [w.data_item.annotation.id for w in self.selected_widgets]
1320
+ self.clear_selection()
1321
+ self.selection_changed.emit(changed_ids)
1322
+ if hasattr(self.explorer_window.annotation_window, 'unselect_annotations'):
1323
+ self.explorer_window.annotation_window.unselect_annotations()
1324
+ return True
1325
+
1326
+ return False # Let the event propagate for default behaviors like scrolling
1327
+
1328
+ def viewport_mouse_double_click(self, event):
1329
+ """Handle double-click in the viewport to clear selection and reset view."""
1158
1330
  if event.button() == Qt.LeftButton:
1159
- changed_ids = []
1160
1331
  if self.selected_widgets:
1161
1332
  changed_ids = [w.data_item.annotation.id for w in self.selected_widgets]
1162
1333
  self.clear_selection()
@@ -1164,50 +1335,42 @@ class AnnotationViewer(QScrollArea):
1164
1335
  if self.isolated_mode:
1165
1336
  self.show_all_annotations()
1166
1337
  self.reset_view_requested.emit()
1167
- event.accept()
1168
- else:
1169
- super().mouseDoubleClickEvent(event)
1338
+ return True
1339
+ return False
1170
1340
 
1171
- def mouseMoveEvent(self, event):
1172
- """Handle mouse move for DYNAMIC rubber band selection."""
1173
- # Only proceed if Ctrl+Left mouse drag is active and not on a widget
1341
+ def viewport_mouse_move(self, event):
1342
+ """Handle mouse move in the viewport for dynamic rubber band selection."""
1174
1343
  if (
1175
1344
  self.rubber_band_origin is None or
1176
1345
  event.buttons() != Qt.LeftButton or
1177
- event.modifiers() != Qt.ControlModifier
1346
+ event.modifiers() != Qt.ControlModifier or
1347
+ self.mouse_pressed_on_widget
1178
1348
  ):
1179
- super().mouseMoveEvent(event)
1180
- return
1181
-
1182
- if self.mouse_pressed_on_widget:
1183
- # If drag started on a widget, do not start rubber band
1184
- super().mouseMoveEvent(event)
1185
- return
1349
+ return False
1186
1350
 
1187
1351
  # Only start selection if drag distance exceeds threshold
1188
1352
  distance = (event.pos() - self.rubber_band_origin).manhattanLength()
1189
1353
  if distance < self.drag_threshold:
1190
- return
1354
+ return True
1191
1355
 
1192
1356
  # Create and show the rubber band if not already present
1193
1357
  if not self.rubber_band:
1194
- self.rubber_band = QRubberBand(QRubberBand.Rectangle, self.viewport())
1358
+ self.rubber_band = QRubberBand(QRubberBand.Rectangle, self.scroll_area.viewport())
1195
1359
 
1196
1360
  rect = QRect(self.rubber_band_origin, event.pos()).normalized()
1197
1361
  self.rubber_band.setGeometry(rect)
1198
1362
  self.rubber_band.show()
1363
+
1199
1364
  selection_rect = self.rubber_band.geometry()
1200
1365
  content_widget = self.content_widget
1201
1366
  changed_ids = []
1202
1367
 
1203
1368
  # Iterate over all annotation widgets to update selection state
1204
1369
  for widget in self.annotation_widgets_by_id.values():
1205
- widget_rect_in_content = widget.geometry()
1206
- # Map widget's rect to viewport coordinates
1207
- widget_rect_in_viewport = QRect(
1208
- content_widget.mapTo(self.viewport(), widget_rect_in_content.topLeft()),
1209
- widget_rect_in_content.size()
1210
- )
1370
+ # Map widget's geometry from content_widget coordinates to viewport coordinates
1371
+ mapped_top_left = content_widget.mapTo(self.scroll_area.viewport(), widget.geometry().topLeft())
1372
+ widget_rect_in_viewport = QRect(mapped_top_left, widget.geometry().size())
1373
+
1211
1374
  is_in_band = selection_rect.intersects(widget_rect_in_viewport)
1212
1375
  should_be_selected = (widget in self.selection_at_press) or is_in_band
1213
1376
 
@@ -1224,77 +1387,75 @@ class AnnotationViewer(QScrollArea):
1224
1387
  if changed_ids:
1225
1388
  self.selection_changed.emit(changed_ids)
1226
1389
 
1227
- def mouseReleaseEvent(self, event):
1228
- """Handle mouse release to complete rubber band selection."""
1390
+ return True
1391
+
1392
+ def viewport_mouse_release(self, event):
1393
+ """Handle mouse release in the viewport to finalize rubber band selection."""
1229
1394
  if self.rubber_band_origin is not None and event.button() == Qt.LeftButton:
1230
1395
  if self.rubber_band and self.rubber_band.isVisible():
1231
1396
  self.rubber_band.hide()
1232
1397
  self.rubber_band.deleteLater()
1233
1398
  self.rubber_band = None
1234
-
1235
- self.selection_at_press = set()
1236
1399
  self.rubber_band_origin = None
1237
- self.mouse_pressed_on_widget = False
1238
- event.accept()
1239
- return
1240
-
1241
- super().mouseReleaseEvent(event)
1400
+ return True
1401
+ return False
1242
1402
 
1243
1403
  def handle_annotation_selection(self, widget, event):
1244
1404
  """Handle selection of annotation widgets with different modes (single, ctrl, shift)."""
1245
- widget_list = [w for w in self._get_sorted_widgets() if not w.isHidden()]
1405
+ # The list for range selection should be based on the sorted data items
1406
+ sorted_data_items = self._get_sorted_data_items()
1407
+
1408
+ # In isolated mode, the list should only contain isolated items
1409
+ if self.isolated_mode:
1410
+ isolated_ids = {w.data_item.annotation.id for w in self.isolated_widgets}
1411
+ sorted_data_items = [item for item in sorted_data_items if item.annotation.id in isolated_ids]
1246
1412
 
1247
1413
  try:
1248
- widget_index = widget_list.index(widget)
1414
+ # Find the index of the clicked widget's data item
1415
+ widget_data_item = widget.data_item
1416
+ current_index = sorted_data_items.index(widget_data_item)
1249
1417
  except ValueError:
1250
1418
  return
1251
1419
 
1252
1420
  modifiers = event.modifiers()
1253
1421
  changed_ids = []
1254
1422
 
1255
- # Shift or Shift+Ctrl: range selection
1256
- if modifiers == Qt.ShiftModifier or modifiers == (Qt.ShiftModifier | Qt.ControlModifier):
1257
- if self.last_selected_index != -1:
1258
- # Find the last selected widget in the current list
1259
- last_selected_widget = None
1260
- for w in self.selected_widgets:
1261
- if w in widget_list:
1262
- try:
1263
- last_index_in_current_list = widget_list.index(w)
1264
- if (
1265
- last_selected_widget is None
1266
- or last_index_in_current_list > widget_list.index(last_selected_widget)
1267
- ):
1268
- last_selected_widget = w
1269
- except ValueError:
1270
- continue
1271
-
1272
- if last_selected_widget:
1273
- last_selected_index_in_current_list = widget_list.index(last_selected_widget)
1274
- start = min(last_selected_index_in_current_list, widget_index)
1275
- end = max(last_selected_index_in_current_list, widget_index)
1276
- else:
1277
- start, end = widget_index, widget_index
1423
+ # Shift or Shift+Ctrl: range selection.
1424
+ if modifiers in (Qt.ShiftModifier, Qt.ShiftModifier | Qt.ControlModifier):
1425
+ last_index = -1
1426
+ if self.last_selected_item_id:
1427
+ try:
1428
+ # Find the data item corresponding to the last selected ID
1429
+ last_item = self.explorer_window.data_item_cache[self.last_selected_item_id]
1430
+ # Find its index in the *current* sorted list
1431
+ last_index = sorted_data_items.index(last_item)
1432
+ except (KeyError, ValueError):
1433
+ # The last selected item is not in the current view or cache, so no anchor
1434
+ last_index = -1
1435
+
1436
+ if last_index != -1:
1437
+ start = min(last_index, current_index)
1438
+ end = max(last_index, current_index)
1278
1439
 
1279
1440
  # Select all widgets in the range
1280
1441
  for i in range(start, end + 1):
1281
- if self.select_widget(widget_list[i]):
1282
- changed_ids.append(widget_list[i].data_item.annotation.id)
1442
+ item_to_select = sorted_data_items[i]
1443
+ widget_to_select = self.annotation_widgets_by_id.get(item_to_select.annotation.id)
1444
+ if widget_to_select and self.select_widget(widget_to_select):
1445
+ changed_ids.append(item_to_select.annotation.id)
1283
1446
  else:
1284
1447
  # No previous selection, just select the clicked widget
1285
1448
  if self.select_widget(widget):
1286
1449
  changed_ids.append(widget.data_item.annotation.id)
1287
- self.last_selected_index = widget_index
1450
+
1451
+ self.last_selected_item_id = widget.data_item.annotation.id
1288
1452
 
1289
1453
  # Ctrl: toggle selection of the clicked widget
1290
1454
  elif modifiers == Qt.ControlModifier:
1291
- if widget.is_selected():
1292
- if self.deselect_widget(widget):
1293
- changed_ids.append(widget.data_item.annotation.id)
1294
- else:
1295
- if self.select_widget(widget):
1296
- changed_ids.append(widget.data_item.annotation.id)
1297
- self.last_selected_index = widget_index
1455
+ # Toggle selection and update the anchor
1456
+ if self.toggle_widget_selection(widget):
1457
+ changed_ids.append(widget.data_item.annotation.id)
1458
+ self.last_selected_item_id = widget.data_item.annotation.id
1298
1459
 
1299
1460
  # No modifier: single selection
1300
1461
  else:
@@ -1309,34 +1470,22 @@ class AnnotationViewer(QScrollArea):
1309
1470
  # Select the clicked widget
1310
1471
  if self.select_widget(widget):
1311
1472
  changed_ids.append(newly_selected_id)
1312
- self.last_selected_index = widget_index
1473
+ self.last_selected_item_id = widget.data_item.annotation.id
1313
1474
 
1314
1475
  # If in isolated mode, update which widgets are visible
1315
1476
  if self.isolated_mode:
1316
- self._update_isolation()
1477
+ pass # Do not change the isolated set on internal selection changes
1317
1478
 
1318
1479
  # Emit signal if any selection state changed
1319
1480
  if changed_ids:
1320
1481
  self.selection_changed.emit(changed_ids)
1321
1482
 
1322
- def _update_isolation(self):
1323
- """Update the isolated view to show only currently selected widgets."""
1324
- if not self.isolated_mode:
1325
- return
1326
- # If in isolated mode, only show selected widgets
1327
- if self.selected_widgets:
1328
- self.isolated_widgets.update(self.selected_widgets)
1329
- self.setUpdatesEnabled(False)
1330
- try:
1331
- for widget in self.annotation_widgets_by_id.values():
1332
- if widget not in self.isolated_widgets:
1333
- widget.hide()
1334
- else:
1335
- widget.show()
1336
- self.recalculate_widget_positions()
1337
-
1338
- finally:
1339
- self.setUpdatesEnabled(True)
1483
+ def toggle_widget_selection(self, widget):
1484
+ """Toggles the selection state of a widget and returns True if changed."""
1485
+ if widget.is_selected():
1486
+ return self.deselect_widget(widget)
1487
+ else:
1488
+ return self.select_widget(widget)
1340
1489
 
1341
1490
  def select_widget(self, widget):
1342
1491
  """Selects a widget, updates its data_item, and returns True if state changed."""
@@ -1390,11 +1539,6 @@ class AnnotationViewer(QScrollArea):
1390
1539
  # Resync internal list of selected widgets from the source of truth
1391
1540
  self.selected_widgets = [w for w in self.annotation_widgets_by_id.values() if w.is_selected()]
1392
1541
 
1393
- if self.isolated_mode and self.selected_widgets:
1394
- self.isolated_widgets.update(self.selected_widgets)
1395
- for widget in self.annotation_widgets_by_id.values():
1396
- widget.setHidden(widget not in self.isolated_widgets)
1397
- self.recalculate_widget_positions()
1398
1542
  finally:
1399
1543
  self.setUpdatesEnabled(True)
1400
1544
  self._update_toolbar_state()
@@ -1410,7 +1554,7 @@ class AnnotationViewer(QScrollArea):
1410
1554
  changed_ids.append(widget.data_item.annotation.id)
1411
1555
 
1412
1556
  if self.sort_combo.currentText() == "Label":
1413
- self.recalculate_widget_positions()
1557
+ self.recalculate_layout()
1414
1558
  if changed_ids:
1415
1559
  self.preview_changed.emit(changed_ids)
1416
1560
 
@@ -1429,8 +1573,8 @@ class AnnotationViewer(QScrollArea):
1429
1573
 
1430
1574
  if something_changed:
1431
1575
  # Recalculate positions to update sorting and re-flow the layout
1432
- if self.sort_combo.currentText() in ("Label", "Image"):
1433
- self.recalculate_widget_positions()
1576
+ if self.sort_combo.currentText() == "Label":
1577
+ self.recalculate_layout()
1434
1578
 
1435
1579
  def has_preview_changes(self):
1436
1580
  """Return True if there are preview changes."""
@@ -1474,6 +1618,7 @@ class ExplorerWindow(QMainWindow):
1474
1618
  self.mislabel_params = {'k': 20, 'threshold': 0.6}
1475
1619
  self.uncertainty_params = {'confidence': 0.6, 'margin': 0.1}
1476
1620
  self.similarity_params = {'k': 30}
1621
+ self.duplicate_params = {'threshold': 0.05}
1477
1622
 
1478
1623
  self.data_item_cache = {} # Cache for AnnotationDataItem objects
1479
1624
 
@@ -1527,7 +1672,7 @@ class ExplorerWindow(QMainWindow):
1527
1672
  if hasattr(self.embedding_viewer, 'animation_timer') and self.embedding_viewer.animation_timer:
1528
1673
  self.embedding_viewer.animation_timer.stop()
1529
1674
 
1530
- # Call the main cancellation method to revert any pending changes
1675
+ # Call the main cancellation method to revert any pending changes and clear selections.
1531
1676
  self.clear_preview_changes()
1532
1677
 
1533
1678
  # Clean up the feature store by deleting its files
@@ -1615,9 +1760,6 @@ class ExplorerWindow(QMainWindow):
1615
1760
  self._initialize_data_item_cache()
1616
1761
  self.annotation_settings_widget.set_default_to_current_image()
1617
1762
  self.refresh_filters()
1618
-
1619
- self.annotation_settings_widget.set_default_to_current_image()
1620
- self.refresh_filters()
1621
1763
 
1622
1764
  try:
1623
1765
  self.label_window.labelSelected.disconnect(self.on_label_selected_for_preview)
@@ -1625,6 +1767,7 @@ class ExplorerWindow(QMainWindow):
1625
1767
  pass
1626
1768
 
1627
1769
  # Connect signals to slots
1770
+ self.annotation_window.annotationModified.connect(self.on_annotation_modified)
1628
1771
  self.label_window.labelSelected.connect(self.on_label_selected_for_preview)
1629
1772
  self.annotation_viewer.selection_changed.connect(self.on_annotation_view_selection_changed)
1630
1773
  self.annotation_viewer.preview_changed.connect(self.on_preview_changed)
@@ -1636,45 +1779,75 @@ class ExplorerWindow(QMainWindow):
1636
1779
  self.model_settings_widget.selection_changed.connect(self.on_model_selection_changed)
1637
1780
  self.embedding_viewer.find_uncertain_requested.connect(self.find_uncertain_annotations)
1638
1781
  self.embedding_viewer.uncertainty_parameters_changed.connect(self.on_uncertainty_params_changed)
1782
+ self.embedding_viewer.find_duplicates_requested.connect(self.find_duplicate_annotations)
1783
+ self.embedding_viewer.duplicate_parameters_changed.connect(self.on_duplicate_params_changed)
1639
1784
  self.annotation_viewer.find_similar_requested.connect(self.find_similar_annotations)
1640
1785
  self.annotation_viewer.similarity_settings_widget.parameters_changed.connect(self.on_similarity_params_changed)
1641
1786
 
1787
+ def _clear_selections(self):
1788
+ """Clears selections in both viewers and stops animations."""
1789
+ if not self._ui_initialized:
1790
+ return
1791
+
1792
+ # Clear selection in the annotation viewer, which also stops widget animations.
1793
+ if self.annotation_viewer:
1794
+ self.annotation_viewer.clear_selection()
1795
+
1796
+ # Clear selection in the embedding viewer. This deselects all points
1797
+ # and stops the animation timer via its on_selection_changed handler.
1798
+ if self.embedding_viewer:
1799
+ self.embedding_viewer.render_selection_from_ids(set())
1800
+
1801
+ # Update other UI elements that depend on selection state.
1802
+ self.update_label_window_selection()
1803
+ self.update_button_states()
1804
+
1805
+ # Process events
1806
+ QApplication.processEvents()
1807
+ print("Cleared all active selections.")
1808
+
1642
1809
  @pyqtSlot(list)
1643
1810
  def on_annotation_view_selection_changed(self, changed_ann_ids):
1644
- """Syncs selection from AnnotationViewer to EmbeddingViewer."""
1645
- # Per request, unselect any annotation in the main AnnotationWindow
1811
+ """Syncs selection from AnnotationViewer to other components and manages UI state."""
1812
+ # Unselect any annotation in the main AnnotationWindow for a clean slate
1646
1813
  if hasattr(self, 'annotation_window'):
1647
1814
  self.annotation_window.unselect_annotations()
1648
1815
 
1649
1816
  all_selected_ids = {w.data_item.annotation.id for w in self.annotation_viewer.selected_widgets}
1817
+
1818
+ # Sync selection to the embedding viewer
1650
1819
  if self.embedding_viewer.points_by_id:
1820
+ blocker = QSignalBlocker(self.embedding_viewer)
1651
1821
  self.embedding_viewer.render_selection_from_ids(all_selected_ids)
1652
1822
 
1653
- # Call the new centralized method
1823
+ # Get the select tool to manage its state
1824
+ select_tool = self.annotation_window.tools.get('select')
1825
+ if select_tool:
1826
+ # If the selection from the explorer is not a single item (i.e., it's empty
1827
+ # or a multi-selection), hide the handles and release the lock.
1828
+ if len(all_selected_ids) != 1:
1829
+ select_tool._hide_resize_handles()
1830
+ select_tool.selection_locked = False
1831
+ # Ensure that the select tool is not active
1832
+ self.annotation_window.set_selected_tool(None)
1833
+
1834
+ # Update the label window based on the new selection
1654
1835
  self.update_label_window_selection()
1655
1836
 
1656
1837
  @pyqtSlot(list)
1657
1838
  def on_embedding_view_selection_changed(self, all_selected_ann_ids):
1658
- """Syncs selection from EmbeddingViewer to AnnotationViewer."""
1659
- # Per request, unselect any annotation in the main AnnotationWindow
1660
- if hasattr(self, 'annotation_window'):
1661
- self.annotation_window.unselect_annotations()
1662
-
1663
- # Check the state BEFORE the selection is changed
1664
- was_empty_selection = len(self.annotation_viewer.selected_widgets) == 0
1665
-
1666
- # Now, update the selection in the annotation viewer
1667
- self.annotation_viewer.render_selection_from_ids(set(all_selected_ann_ids))
1668
-
1669
- # The rest of the logic now works correctly
1670
- is_new_selection = len(all_selected_ann_ids) > 0
1671
- if (
1672
- was_empty_selection and
1673
- is_new_selection and
1674
- not self.annotation_viewer.isolated_mode
1675
- ):
1676
- self.annotation_viewer.isolate_selection()
1839
+ """Syncs selection from EmbeddingViewer to AnnotationViewer and isolates."""
1840
+ selected_ids_set = set(all_selected_ann_ids)
1841
+
1842
+ # If a selection is made in the embedding viewer, isolate those widgets.
1843
+ if selected_ids_set:
1844
+ # This new method will handle setting the isolated set and selecting them.
1845
+ self.annotation_viewer.isolate_and_select_from_ids(selected_ids_set)
1846
+ # If the selection is cleared in the embedding viewer, exit isolation mode.
1847
+ elif self.annotation_viewer.isolated_mode:
1848
+ self.annotation_viewer.show_all_annotations()
1677
1849
 
1850
+ # We still need to update the label window based on the selection.
1678
1851
  self.update_label_window_selection()
1679
1852
 
1680
1853
  @pyqtSlot(list)
@@ -1691,6 +1864,32 @@ class ExplorerWindow(QMainWindow):
1691
1864
  widget = self.annotation_viewer.annotation_widgets_by_id.get(ann_id)
1692
1865
  if widget:
1693
1866
  widget.update_tooltip()
1867
+
1868
+ @pyqtSlot(str)
1869
+ def on_annotation_modified(self, annotation_id):
1870
+ """
1871
+ Handles an annotation being moved or resized in the AnnotationWindow.
1872
+ This invalidates the cached features and updates the annotation's thumbnail.
1873
+ """
1874
+ print(f"Annotation {annotation_id} was modified. Removing its cached features.")
1875
+ if hasattr(self, 'feature_store'):
1876
+ # This method must exist on the FeatureStore to clear features
1877
+ # for the given annotation ID across all stored models.
1878
+ self.feature_store.remove_features_for_annotation(annotation_id)
1879
+
1880
+ # Update the AnnotationImageWidget in the AnnotationViewer
1881
+ if hasattr(self, 'annotation_viewer'):
1882
+ # Find the corresponding widget by its annotation ID
1883
+ widget = self.annotation_viewer.annotation_widgets_by_id.get(annotation_id)
1884
+ if widget:
1885
+ # The annotation's geometry may have changed, so we need to update the widget.
1886
+ # 1. Recalculate the aspect ratio.
1887
+ widget.recalculate_aspect_ratio()
1888
+ # 2. Unload the stale image data. This marks the widget as "dirty".
1889
+ widget.unload_image()
1890
+ # 3. Recalculate the layout. This will resize the widget based on the new
1891
+ # aspect ratio and reload the image if the widget is currently visible.
1892
+ self.annotation_viewer.recalculate_layout()
1694
1893
 
1695
1894
  @pyqtSlot()
1696
1895
  def on_reset_view_requested(self):
@@ -1725,6 +1924,12 @@ class ExplorerWindow(QMainWindow):
1725
1924
  """Updates the stored parameters for uncertainty analysis."""
1726
1925
  self.uncertainty_params = params
1727
1926
  print(f"Uncertainty parameters updated: {self.uncertainty_params}")
1927
+
1928
+ @pyqtSlot(dict)
1929
+ def on_duplicate_params_changed(self, params):
1930
+ """Updates the stored parameters for duplicate detection."""
1931
+ self.duplicate_params = params
1932
+ print(f"Duplicate detection parameters updated: {self.duplicate_params}")
1728
1933
 
1729
1934
  @pyqtSlot(dict)
1730
1935
  def on_similarity_params_changed(self, params):
@@ -1905,6 +2110,98 @@ class ExplorerWindow(QMainWindow):
1905
2110
 
1906
2111
  finally:
1907
2112
  QApplication.restoreOverrideCursor()
2113
+
2114
+ def find_duplicate_annotations(self):
2115
+ """
2116
+ Identifies annotations that are likely duplicates based on feature similarity.
2117
+ It uses a nearest-neighbor approach in the high-dimensional feature space.
2118
+ For each group of duplicates found, it selects all but one "original".
2119
+ """
2120
+ threshold = self.duplicate_params.get('threshold', 0.05)
2121
+
2122
+ if not self.embedding_viewer.points_by_id or len(self.embedding_viewer.points_by_id) < 2:
2123
+ QMessageBox.information(self,
2124
+ "Not Enough Data",
2125
+ "This feature requires at least 2 points in the embedding viewer.")
2126
+ return
2127
+
2128
+ items_in_view = list(self.embedding_viewer.points_by_id.values())
2129
+ data_items_in_view = [p.data_item for p in items_in_view]
2130
+
2131
+ model_info = self.model_settings_widget.get_selected_model()
2132
+ model_name, feature_mode = model_info if isinstance(model_info, tuple) else (model_info, "default")
2133
+ sanitized_model_name = os.path.basename(model_name).replace(' ', '_')
2134
+ sanitized_feature_mode = feature_mode.replace(' ', '_').replace('/', '_')
2135
+ model_key = f"{sanitized_model_name}_{sanitized_feature_mode}"
2136
+
2137
+ # Make cursor busy
2138
+ QApplication.setOverrideCursor(Qt.WaitCursor)
2139
+ try:
2140
+ index = self.feature_store._get_or_load_index(model_key)
2141
+ if index is None:
2142
+ QMessageBox.warning(self, "Error", "Could not find a valid feature index for the current model.")
2143
+ return
2144
+
2145
+ features_dict, _ = self.feature_store.get_features(data_items_in_view, model_key)
2146
+ if not features_dict:
2147
+ QMessageBox.warning(self, "Error", "Could not retrieve features for the items in view.")
2148
+ return
2149
+
2150
+ query_ann_ids = list(features_dict.keys())
2151
+ query_vectors = np.array([features_dict[ann_id] for ann_id in query_ann_ids]).astype('float32')
2152
+
2153
+ # Find the 2 nearest neighbors for each vector. D = squared L2 distances.
2154
+ D, I = index.search(query_vectors, 2)
2155
+
2156
+ # Use a Disjoint Set Union (DSU) data structure to group duplicates.
2157
+ parent = {ann_id: ann_id for ann_id in query_ann_ids}
2158
+
2159
+ # Helper functions for DSU
2160
+ def find_set(v):
2161
+ if v == parent[v]:
2162
+ return v
2163
+ parent[v] = find_set(parent[v])
2164
+ return parent[v]
2165
+
2166
+ def unite_sets(a, b):
2167
+ a = find_set(a)
2168
+ b = find_set(b)
2169
+ if a != b:
2170
+ parent[b] = a
2171
+
2172
+ id_map = self.feature_store.get_faiss_index_to_annotation_id_map(model_key)
2173
+
2174
+ for i, ann_id in enumerate(query_ann_ids):
2175
+ neighbor_faiss_idx = I[i, 1] # The second result is the nearest neighbor
2176
+ distance = D[i, 1]
2177
+
2178
+ if distance < threshold:
2179
+ neighbor_ann_id = id_map.get(neighbor_faiss_idx)
2180
+ if neighbor_ann_id and neighbor_ann_id in parent:
2181
+ unite_sets(ann_id, neighbor_ann_id)
2182
+
2183
+ # Group annotations by their set representative
2184
+ groups = {}
2185
+ for ann_id in query_ann_ids:
2186
+ root = find_set(ann_id)
2187
+ if root not in groups:
2188
+ groups[root] = []
2189
+ groups[root].append(ann_id)
2190
+
2191
+ copies_to_select = set()
2192
+ for root_id, group_ids in groups.items():
2193
+ if len(group_ids) > 1:
2194
+ # Sort IDs to consistently pick the same "original".
2195
+ # Sorting strings is reliable.
2196
+ sorted_ids = sorted(group_ids)
2197
+ # The first ID is the original, add the rest to the selection.
2198
+ copies_to_select.update(sorted_ids[1:])
2199
+
2200
+ print(f"Found {len(copies_to_select)} duplicate annotations.")
2201
+ self.embedding_viewer.render_selection_from_ids(copies_to_select)
2202
+
2203
+ finally:
2204
+ QApplication.restoreOverrideCursor()
1908
2205
 
1909
2206
  def find_uncertain_annotations(self):
1910
2207
  """
@@ -2496,6 +2793,7 @@ class ExplorerWindow(QMainWindow):
2496
2793
  norm_y = (embedded_features[i, 1] - min_vals[1]) / range_vals[1] if range_vals[1] > 0 else 0.5
2497
2794
  item.embedding_x = (norm_x * scale_factor) - (scale_factor / 2)
2498
2795
  item.embedding_y = (norm_y * scale_factor) - (scale_factor / 2)
2796
+ item.embedding_id = i
2499
2797
 
2500
2798
  def run_embedding_pipeline(self):
2501
2799
  """
@@ -2651,6 +2949,10 @@ class ExplorerWindow(QMainWindow):
2651
2949
  self.current_data_items = [
2652
2950
  item for item in self.current_data_items if item.annotation.id not in deleted_ann_ids
2653
2951
  ]
2952
+ # Also update the annotation viewer's list to keep it in sync
2953
+ self.annotation_viewer.all_data_items = [
2954
+ item for item in self.annotation_viewer.all_data_items if item.annotation.id not in deleted_ann_ids
2955
+ ]
2654
2956
  for ann_id in deleted_ann_ids:
2655
2957
  if ann_id in self.data_item_cache:
2656
2958
  del self.data_item_cache[ann_id]
@@ -2665,7 +2967,7 @@ class ExplorerWindow(QMainWindow):
2665
2967
  widget.setParent(None)
2666
2968
  widget.deleteLater()
2667
2969
  blocker.unblock()
2668
- self.annotation_viewer.recalculate_widget_positions()
2970
+ self.annotation_viewer.recalculate_layout()
2669
2971
 
2670
2972
  # 4. Remove from EmbeddingViewer
2671
2973
  blocker = QSignalBlocker(self.embedding_viewer.graphics_scene)
@@ -2693,8 +2995,12 @@ class ExplorerWindow(QMainWindow):
2693
2995
 
2694
2996
  def clear_preview_changes(self):
2695
2997
  """
2696
- Clears all preview changes in the annotation viewer and updates tooltips.
2998
+ Clears all preview changes in the annotation viewer, reverts tooltips,
2999
+ and clears any active selections.
2697
3000
  """
3001
+ # First, clear any active selections from the UI.
3002
+ self._clear_selections()
3003
+
2698
3004
  if hasattr(self, 'annotation_viewer'):
2699
3005
  self.annotation_viewer.clear_preview_states()
2700
3006
 
@@ -2704,7 +3010,7 @@ class ExplorerWindow(QMainWindow):
2704
3010
  for point in self.embedding_viewer.points_by_id.values():
2705
3011
  point.update_tooltip()
2706
3012
 
2707
- # After reverting all changes, update the button states
3013
+ # After reverting all changes, update the button states.
2708
3014
  self.update_button_states()
2709
3015
  print("Cleared all pending changes.")
2710
3016
 
@@ -2748,13 +3054,12 @@ class ExplorerWindow(QMainWindow):
2748
3054
  self.image_window.update_image_annotations(image_path)
2749
3055
  self.annotation_window.load_annotations()
2750
3056
 
2751
- # Refresh the annotation viewer since its underlying data has changed
3057
+ # Refresh the annotation viewer since its underlying data has changed.
3058
+ # This implicitly deselects everything by rebuilding the widgets.
2752
3059
  self.annotation_viewer.update_annotations(self.current_data_items)
2753
3060
 
2754
- # Reset selections and button states
2755
- self.embedding_viewer.render_selection_from_ids(set())
2756
- self.update_label_window_selection()
2757
- self.update_button_states()
3061
+ # Explicitly clear selections and update UI states for consistency.
3062
+ self._clear_selections()
2758
3063
 
2759
3064
  print("Applied changes successfully.")
2760
3065