coralnet-toolbox 0.0.71__py2.py3-none-any.whl → 0.0.72__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.
@@ -11,7 +11,7 @@ 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,
@@ -29,6 +29,8 @@ 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
31
 
32
+ from coralnet_toolbox.Annotations.QtRectangleAnnotation import RectangleAnnotation
33
+
32
34
  from coralnet_toolbox.QtProgressBar import ProgressBar
33
35
 
34
36
  try:
@@ -99,6 +101,11 @@ class EmbeddingViewer(QWidget):
99
101
  self.animation_timer.timeout.connect(self.animate_selection)
100
102
  self.animation_timer.setInterval(100)
101
103
 
104
+ # New timer for virtualization
105
+ self.view_update_timer = QTimer(self)
106
+ self.view_update_timer.setSingleShot(True)
107
+ self.view_update_timer.timeout.connect(self._update_visible_points)
108
+
102
109
  self.graphics_scene.selectionChanged.connect(self.on_selection_changed)
103
110
  self.setup_ui()
104
111
  self.graphics_view.mousePressEvent = self.mousePressEvent
@@ -224,6 +231,26 @@ class EmbeddingViewer(QWidget):
224
231
  separator.setStyleSheet("color: gray; margin: 0 5px;")
225
232
  return separator
226
233
 
234
+ def _schedule_view_update(self):
235
+ """Schedules a delayed update of visible points to avoid performance issues."""
236
+ self.view_update_timer.start(50) # 50ms delay
237
+
238
+ def _update_visible_points(self):
239
+ """Sets visibility for points based on whether they are in the viewport."""
240
+ if self.isolated_mode or not self.points_by_id:
241
+ return
242
+
243
+ # Get the visible rectangle in scene coordinates
244
+ visible_rect = self.graphics_view.mapToScene(self.graphics_view.viewport().rect()).boundingRect()
245
+
246
+ # Add a buffer to make scrolling smoother by loading points before they enter the view
247
+ buffer_x = visible_rect.width() * 0.2
248
+ buffer_y = visible_rect.height() * 0.2
249
+ buffered_visible_rect = visible_rect.adjusted(-buffer_x, -buffer_y, buffer_x, buffer_y)
250
+
251
+ for point in self.points_by_id.values():
252
+ point.setVisible(buffered_visible_rect.contains(point.pos()) or point.isSelected())
253
+
227
254
  @pyqtSlot()
228
255
  def isolate_selection(self):
229
256
  """Hides all points that are not currently selected."""
@@ -235,8 +262,7 @@ class EmbeddingViewer(QWidget):
235
262
  self.graphics_view.setUpdatesEnabled(False)
236
263
  try:
237
264
  for point in self.points_by_id.values():
238
- if point not in self.isolated_points:
239
- point.hide()
265
+ point.setVisible(point in self.isolated_points)
240
266
  self.isolated_mode = True
241
267
  finally:
242
268
  self.graphics_view.setUpdatesEnabled(True)
@@ -253,8 +279,8 @@ class EmbeddingViewer(QWidget):
253
279
  self.isolated_points.clear()
254
280
  self.graphics_view.setUpdatesEnabled(False)
255
281
  try:
256
- for point in self.points_by_id.values():
257
- point.show()
282
+ # Instead of showing all, let the virtualization logic take over
283
+ self._update_visible_points()
258
284
  finally:
259
285
  self.graphics_view.setUpdatesEnabled(True)
260
286
 
@@ -483,6 +509,7 @@ class EmbeddingViewer(QWidget):
483
509
  # Forward right-drag as left-drag for panning
484
510
  left_event = QMouseEvent(event.type(), event.localPos(), Qt.LeftButton, Qt.LeftButton, event.modifiers())
485
511
  QGraphicsView.mouseMoveEvent(self.graphics_view, left_event)
512
+ self._schedule_view_update()
486
513
  else:
487
514
  # Default mouse move handling
488
515
  QGraphicsView.mouseMoveEvent(self.graphics_view, event)
@@ -496,6 +523,7 @@ class EmbeddingViewer(QWidget):
496
523
  elif event.button() == Qt.RightButton:
497
524
  left_event = QMouseEvent(event.type(), event.localPos(), Qt.LeftButton, Qt.LeftButton, event.modifiers())
498
525
  QGraphicsView.mouseReleaseEvent(self.graphics_view, left_event)
526
+ self._schedule_view_update()
499
527
  self.graphics_view.setDragMode(QGraphicsView.NoDrag)
500
528
  else:
501
529
  QGraphicsView.mouseReleaseEvent(self.graphics_view, event)
@@ -525,6 +553,7 @@ class EmbeddingViewer(QWidget):
525
553
  # Translate view to keep mouse position stable
526
554
  delta = new_pos - old_pos
527
555
  self.graphics_view.translate(delta.x(), delta.y())
556
+ self._schedule_view_update()
528
557
 
529
558
  def update_embeddings(self, data_items):
530
559
  """Update the embedding visualization. Creates an EmbeddingPointItem for
@@ -541,6 +570,8 @@ class EmbeddingViewer(QWidget):
541
570
 
542
571
  # Ensure buttons are in the correct initial state
543
572
  self._update_toolbar_state()
573
+ # Set initial visibility
574
+ self._update_visible_points()
544
575
 
545
576
  def clear_points(self):
546
577
  """Clear all embedding points from the scene."""
@@ -585,6 +616,9 @@ class EmbeddingViewer(QWidget):
585
616
 
586
617
  # Update button states based on new selection
587
618
  self._update_toolbar_state()
619
+
620
+ # A selection change can affect visibility (e.g., deselecting an off-screen point)
621
+ self._schedule_view_update()
588
622
 
589
623
  def animate_selection(self):
590
624
  """Animate selected points with a marching ants effect."""
@@ -624,6 +658,9 @@ class EmbeddingViewer(QWidget):
624
658
 
625
659
  # Manually trigger on_selection_changed to update animation and emit signals
626
660
  self.on_selection_changed()
661
+
662
+ # After selection, update visibility to ensure newly selected points are shown
663
+ self._update_visible_points()
627
664
 
628
665
  def fit_view_to_points(self):
629
666
  """Fit the view to show all embedding points."""
@@ -631,11 +668,13 @@ class EmbeddingViewer(QWidget):
631
668
  self.graphics_view.fitInView(self.graphics_scene.itemsBoundingRect(), Qt.KeepAspectRatio)
632
669
  else:
633
670
  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."""
671
+
672
+
673
+ class AnnotationViewer(QWidget):
674
+ """
675
+ Widget containing a toolbar and a scrollable grid for displaying annotation image crops.
676
+ Implements virtualization to only render visible widgets.
677
+ """
639
678
  selection_changed = pyqtSignal(list)
640
679
  preview_changed = pyqtSignal(list)
641
680
  reset_view_requested = pyqtSignal()
@@ -648,7 +687,7 @@ class AnnotationViewer(QScrollArea):
648
687
 
649
688
  self.annotation_widgets_by_id = {}
650
689
  self.selected_widgets = []
651
- self.last_selected_index = -1
690
+ self.last_selected_item_id = None # Use a persistent ID for the selection anchor
652
691
  self.current_widget_size = 96
653
692
  self.selection_at_press = set()
654
693
  self.rubber_band = None
@@ -660,23 +699,32 @@ class AnnotationViewer(QScrollArea):
660
699
  self.isolated_mode = False
661
700
  self.isolated_widgets = set()
662
701
 
663
- # State for new sorting options
702
+ # State for sorting options
664
703
  self.active_ordered_ids = []
665
704
  self.is_confidence_sort_available = False
666
705
 
706
+ # New attributes for virtualization
707
+ self.all_data_items = []
708
+ self.widget_positions = {} # ann_id -> QRect
709
+ self.update_timer = QTimer(self)
710
+ self.update_timer.setSingleShot(True)
711
+ self.update_timer.timeout.connect(self._update_visible_widgets)
712
+
667
713
  self.setup_ui()
668
714
 
715
+ # Connect scrollbar value changed to schedule an update for virtualization
716
+ self.scroll_area.verticalScrollBar().valueChanged.connect(self._schedule_update)
717
+ # Install an event filter on the viewport to handle mouse events for rubber band selection
718
+ self.scroll_area.viewport().installEventFilter(self)
719
+
669
720
  def setup_ui(self):
670
721
  """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)
722
+ # This widget is the main container with its own layout
723
+ main_layout = QVBoxLayout(self)
677
724
  main_layout.setContentsMargins(0, 0, 0, 0)
678
725
  main_layout.setSpacing(4)
679
726
 
727
+ # Create and add the toolbar to the main layout
680
728
  toolbar_widget = QWidget()
681
729
  toolbar_layout = QHBoxLayout(toolbar_widget)
682
730
  toolbar_layout.setContentsMargins(4, 2, 4, 2)
@@ -742,16 +790,16 @@ class AnnotationViewer(QScrollArea):
742
790
  self.size_value_label.setMinimumWidth(30)
743
791
  toolbar_layout.addWidget(self.size_value_label)
744
792
  main_layout.addWidget(toolbar_widget)
745
-
793
+
794
+ # Create the scroll area which will contain the content
795
+ self.scroll_area = QScrollArea()
796
+ self.scroll_area.setWidgetResizable(True)
797
+ self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
798
+ self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
799
+
746
800
  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)
801
+ self.scroll_area.setWidget(self.content_widget)
802
+ main_layout.addWidget(self.scroll_area)
755
803
 
756
804
  # Set the initial state of the sort options
757
805
  self._update_sort_options_state()
@@ -780,7 +828,7 @@ class AnnotationViewer(QScrollArea):
780
828
  annotation_to_select = widget.annotation
781
829
 
782
830
  # ctrl+right click to only select this annotation (single selection):
783
- self.clear_selection()
831
+ self.clear_selection()
784
832
  self.select_widget(widget)
785
833
  changed_ids = [widget.data_item.annotation.id]
786
834
 
@@ -793,13 +841,25 @@ class AnnotationViewer(QScrollArea):
793
841
  if hasattr(explorer.annotation_window, 'set_image'):
794
842
  explorer.annotation_window.set_image(image_path)
795
843
 
796
- # Now, select the annotation in the annotation_window
844
+ # Now, select the annotation in the annotation_window (activates animation)
797
845
  if hasattr(explorer.annotation_window, 'select_annotation'):
798
- explorer.annotation_window.select_annotation(annotation_to_select)
846
+ explorer.annotation_window.select_annotation(annotation_to_select, quiet_mode=True)
799
847
 
800
848
  # Center the annotation window view on the selected annotation
801
849
  if hasattr(explorer.annotation_window, 'center_on_annotation'):
802
850
  explorer.annotation_window.center_on_annotation(annotation_to_select)
851
+
852
+ # Show resize handles for Rectangle annotations
853
+ if isinstance(annotation_to_select, RectangleAnnotation):
854
+ explorer.annotation_window.set_selected_tool('select') # Accidently unselects in AnnotationWindow
855
+ explorer.annotation_window.select_annotation(annotation_to_select, quiet_mode=True)
856
+ select_tool = explorer.annotation_window.tools.get('select')
857
+
858
+ if select_tool:
859
+ # Engage the selection lock.
860
+ select_tool.selection_locked = True
861
+ # Show the resize handles for the now-selected annotation.
862
+ select_tool._show_resize_handles()
803
863
 
804
864
  # Also clear any existing selection in the explorer window itself
805
865
  explorer.embedding_viewer.render_selection_from_ids({widget.data_item.annotation.id})
@@ -811,7 +871,7 @@ class AnnotationViewer(QScrollArea):
811
871
  @pyqtSlot()
812
872
  def isolate_selection(self):
813
873
  """Hides all annotation widgets that are not currently selected."""
814
- if not self.selected_widgets or self.isolated_mode:
874
+ if not self.selected_widgets:
815
875
  return
816
876
 
817
877
  self.isolated_widgets = set(self.selected_widgets)
@@ -821,13 +881,36 @@ class AnnotationViewer(QScrollArea):
821
881
  if widget not in self.isolated_widgets:
822
882
  widget.hide()
823
883
  self.isolated_mode = True
824
- self.recalculate_widget_positions()
884
+ self.recalculate_layout()
825
885
  finally:
826
886
  self.content_widget.setUpdatesEnabled(True)
827
887
 
828
888
  self._update_toolbar_state()
829
889
  self.explorer_window.main_window.label_window.update_annotation_count()
830
890
 
891
+ def isolate_and_select_from_ids(self, ids_to_isolate):
892
+ """
893
+ Enters isolated mode showing only widgets for the given IDs, and also
894
+ selects them. This is the primary entry point from external viewers.
895
+ The isolated set is 'sticky' and will not change on subsequent internal
896
+ selection changes.
897
+ """
898
+ # Get the widget objects from the IDs
899
+ widgets_to_isolate = {
900
+ self.annotation_widgets_by_id[ann_id]
901
+ for ann_id in ids_to_isolate
902
+ if ann_id in self.annotation_widgets_by_id
903
+ }
904
+
905
+ if not widgets_to_isolate:
906
+ return
907
+
908
+ self.isolated_widgets = widgets_to_isolate
909
+ self.isolated_mode = True
910
+
911
+ self.render_selection_from_ids(ids_to_isolate)
912
+ self.recalculate_layout()
913
+
831
914
  def display_and_isolate_ordered_results(self, ordered_ids):
832
915
  """
833
916
  Isolates the view to a specific set of ordered widgets, ensuring the
@@ -851,7 +934,7 @@ class AnnotationViewer(QScrollArea):
851
934
  widget.hide()
852
935
 
853
936
  self.isolated_mode = True
854
- self.recalculate_widget_positions() # Crucial grid update
937
+ self.recalculate_layout() # Crucial grid update
855
938
  finally:
856
939
  self.content_widget.setUpdatesEnabled(True)
857
940
 
@@ -874,7 +957,7 @@ class AnnotationViewer(QScrollArea):
874
957
  for widget in self.annotation_widgets_by_id.values():
875
958
  widget.show()
876
959
 
877
- self.recalculate_widget_positions()
960
+ self.recalculate_layout()
878
961
  finally:
879
962
  self.content_widget.setUpdatesEnabled(True)
880
963
 
@@ -896,62 +979,71 @@ class AnnotationViewer(QScrollArea):
896
979
  def on_sort_changed(self, sort_type):
897
980
  """Handle sort type change."""
898
981
  self.active_ordered_ids = [] # Clear any special ordering
899
- self.recalculate_widget_positions()
982
+ self.recalculate_layout()
900
983
 
901
984
  def set_confidence_sort_availability(self, is_available):
902
985
  """Sets the availability of the confidence sort option."""
903
986
  self.is_confidence_sort_available = is_available
904
987
  self._update_sort_options_state()
905
988
 
906
- def _get_sorted_widgets(self):
907
- """Get widgets sorted according to the current sort setting."""
989
+ def _get_sorted_data_items(self):
990
+ """Get data items sorted according to the current sort setting."""
908
991
  # If a specific order is active (e.g., from similarity search), use it.
909
992
  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
993
+ item_map = {i.annotation.id: i for i in self.all_data_items}
994
+ ordered_items = [item_map[ann_id] for ann_id in self.active_ordered_ids if ann_id in item_map]
995
+ return ordered_items
913
996
 
914
997
  # Otherwise, use the dropdown sort logic
915
998
  sort_type = self.sort_combo.currentText()
916
- widgets = list(self.annotation_widgets_by_id.values())
999
+ items = list(self.all_data_items)
917
1000
 
918
1001
  if sort_type == "Label":
919
- widgets.sort(key=lambda w: w.data_item.effective_label.short_label_code)
1002
+ items.sort(key=lambda i: i.effective_label.short_label_code)
920
1003
  elif sort_type == "Image":
921
- widgets.sort(key=lambda w: os.path.basename(w.data_item.annotation.image_path))
1004
+ items.sort(key=lambda i: os.path.basename(i.annotation.image_path))
922
1005
  elif sort_type == "Confidence":
923
1006
  # 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
1007
+ items.sort(key=lambda i: i.get_effective_confidence(), reverse=True)
1008
+
1009
+ return items
927
1010
 
928
- def _group_widgets_by_sort_key(self, widgets):
929
- """Group widgets by the current sort key."""
1011
+ def _get_sorted_widgets(self):
1012
+ """
1013
+ Get widgets sorted according to the current sort setting.
1014
+ This is kept for compatibility with selection logic.
1015
+ """
1016
+ sorted_data_items = self._get_sorted_data_items()
1017
+ return [self.annotation_widgets_by_id[item.annotation.id]
1018
+ for item in sorted_data_items if item.annotation.id in self.annotation_widgets_by_id]
1019
+
1020
+ def _group_data_items_by_sort_key(self, data_items):
1021
+ """Group data items by the current sort key."""
930
1022
  sort_type = self.sort_combo.currentText()
931
1023
  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)]
1024
+ return [("", data_items)]
1025
+
1026
+ if self.active_ordered_ids: # Don't show group headers for similarity results
1027
+ return [("", data_items)]
936
1028
 
937
1029
  groups = []
938
1030
  current_group = []
939
1031
  current_key = None
940
- for widget in widgets:
1032
+ for item in data_items:
941
1033
  if sort_type == "Label":
942
- key = widget.data_item.effective_label.short_label_code
1034
+ key = item.effective_label.short_label_code
943
1035
  elif sort_type == "Image":
944
- key = os.path.basename(widget.data_item.annotation.image_path)
1036
+ key = os.path.basename(item.annotation.image_path)
945
1037
  else:
946
1038
  key = "" # No headers for Confidence or None
947
1039
 
948
1040
  if key and current_key != key:
949
1041
  if current_group:
950
1042
  groups.append((current_key, current_group))
951
- current_group = [widget]
1043
+ current_group = [item]
952
1044
  current_key = key
953
1045
  else:
954
- current_group.append(widget)
1046
+ current_group.append(item)
955
1047
  if current_group:
956
1048
  groups.append((current_key, current_group))
957
1049
  return groups
@@ -982,7 +1074,7 @@ class AnnotationViewer(QScrollArea):
982
1074
  " }"
983
1075
  )
984
1076
  header.setFixedHeight(30)
985
- header.setMinimumWidth(self.viewport().width() - 20)
1077
+ header.setMinimumWidth(self.scroll_area.viewport().width() - 20)
986
1078
  header.show()
987
1079
  self._group_headers.append(header)
988
1080
  return header
@@ -994,35 +1086,79 @@ class AnnotationViewer(QScrollArea):
994
1086
 
995
1087
  self.current_widget_size = value
996
1088
  self.size_value_label.setText(str(value))
997
- self.content_widget.setUpdatesEnabled(False)
1089
+ self.recalculate_layout()
998
1090
 
999
- for widget in self.annotation_widgets_by_id.values():
1000
- widget.update_height(value)
1091
+ def _schedule_update(self):
1092
+ """Schedules a delayed update of visible widgets to avoid performance issues during rapid scrolling."""
1093
+ self.update_timer.start(50) # 50ms delay
1094
+
1095
+ def _update_visible_widgets(self):
1096
+ """Shows and loads widgets that are in the viewport, and hides/unloads others."""
1097
+ if not self.widget_positions:
1098
+ return
1099
+
1100
+ self.content_widget.setUpdatesEnabled(False)
1101
+
1102
+ # Determine the visible rectangle in the content widget's coordinates
1103
+ scroll_y = self.scroll_area.verticalScrollBar().value()
1104
+ visible_content_rect = QRect(0,
1105
+ scroll_y,
1106
+ self.scroll_area.viewport().width(),
1107
+ self.scroll_area.viewport().height())
1108
+
1109
+ # Add a buffer to load images slightly before they become visible
1110
+ buffer = self.scroll_area.viewport().height() // 2
1111
+ visible_content_rect.adjust(0, -buffer, 0, buffer)
1112
+
1113
+ visible_ids = set()
1114
+ for ann_id, rect in self.widget_positions.items():
1115
+ if rect.intersects(visible_content_rect):
1116
+ visible_ids.add(ann_id)
1117
+
1118
+ # Update widgets based on visibility
1119
+ for ann_id, widget in self.annotation_widgets_by_id.items():
1120
+ if ann_id in visible_ids:
1121
+ # This widget should be visible
1122
+ widget.setGeometry(self.widget_positions[ann_id])
1123
+ widget.load_image() # Lazy-loads the image
1124
+ widget.show()
1125
+ else:
1126
+ # This widget is not visible
1127
+ if widget.isVisible():
1128
+ widget.hide()
1129
+ widget.unload_image() # Free up memory
1001
1130
 
1002
1131
  self.content_widget.setUpdatesEnabled(True)
1003
- self.recalculate_widget_positions()
1004
1132
 
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:
1133
+ def recalculate_layout(self):
1134
+ """Calculates the positions for all widgets and the total size of the content area."""
1135
+ if not self.all_data_items:
1008
1136
  self.content_widget.setMinimumSize(1, 1)
1009
1137
  return
1010
1138
 
1011
1139
  self._clear_separator_labels()
1012
- visible_widgets = [w for w in self._get_sorted_widgets() if not w.isHidden()]
1013
- if not visible_widgets:
1140
+ sorted_data_items = self._get_sorted_data_items()
1141
+
1142
+ # If in isolated mode, only consider the isolated widgets for layout
1143
+ if self.isolated_mode:
1144
+ isolated_ids = {w.data_item.annotation.id for w in self.isolated_widgets}
1145
+ sorted_data_items = [item for item in sorted_data_items if item.annotation.id in isolated_ids]
1146
+
1147
+ if not sorted_data_items:
1014
1148
  self.content_widget.setMinimumSize(1, 1)
1015
1149
  return
1016
1150
 
1017
1151
  # Create groups based on the current sort key
1018
- groups = self._group_widgets_by_sort_key(visible_widgets)
1152
+ groups = self._group_data_items_by_sort_key(sorted_data_items)
1019
1153
  spacing = max(5, int(self.current_widget_size * 0.08))
1020
- available_width = self.viewport().width()
1154
+ available_width = self.scroll_area.viewport().width()
1021
1155
  x, y = spacing, spacing
1022
1156
  max_height_in_row = 0
1023
1157
 
1024
- # Calculate the maximum height of the widgets in each row
1025
- for group_name, group_widgets in groups:
1158
+ self.widget_positions.clear()
1159
+
1160
+ # Calculate positions
1161
+ for group_name, group_data_items in groups:
1026
1162
  if group_name and self.sort_combo.currentText() != "None":
1027
1163
  if x > spacing:
1028
1164
  x = spacing
@@ -1034,51 +1170,65 @@ class AnnotationViewer(QScrollArea):
1034
1170
  x = spacing
1035
1171
  max_height_in_row = 0
1036
1172
 
1037
- for widget in group_widgets:
1173
+ for data_item in group_data_items:
1174
+ ann_id = data_item.annotation.id
1175
+ # Get or create widget to determine its size
1176
+ if ann_id in self.annotation_widgets_by_id:
1177
+ widget = self.annotation_widgets_by_id[ann_id]
1178
+ widget.update_height(self.current_widget_size) # Ensure size is up-to-date
1179
+ else:
1180
+ widget = AnnotationImageWidget(data_item, self.current_widget_size, self, self.content_widget)
1181
+ self.annotation_widgets_by_id[ann_id] = widget
1182
+ widget.hide() # Hide by default
1183
+
1038
1184
  widget_size = widget.size()
1039
1185
  if x > spacing and x + widget_size.width() > available_width:
1040
1186
  x = spacing
1041
1187
  y += max_height_in_row + spacing
1042
1188
  max_height_in_row = 0
1043
- widget.move(x, y)
1189
+
1190
+ self.widget_positions[ann_id] = QRect(x, y, widget_size.width(), widget_size.height())
1191
+
1044
1192
  x += widget_size.width() + spacing
1045
1193
  max_height_in_row = max(max_height_in_row, widget_size.height())
1046
1194
 
1047
1195
  total_height = y + max_height_in_row + spacing
1048
1196
  self.content_widget.setMinimumSize(available_width, total_height)
1049
1197
 
1198
+ # After calculating layout, update what's visible
1199
+ self._update_visible_widgets()
1200
+
1050
1201
  def update_annotations(self, data_items):
1051
1202
  """Update displayed annotations, creating new widgets for them."""
1052
1203
  if self.isolated_mode:
1053
1204
  self.show_all_annotations()
1054
1205
 
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()
1206
+ # Clear out widgets for data items that are no longer in the new set
1207
+ all_ann_ids = {item.annotation.id for item in data_items}
1208
+ for ann_id, widget in list(self.annotation_widgets_by_id.items()):
1209
+ if ann_id not in all_ann_ids:
1210
+ if widget in self.selected_widgets:
1211
+ self.selected_widgets.remove(widget)
1212
+ widget.setParent(None)
1213
+ widget.deleteLater()
1214
+ del self.annotation_widgets_by_id[ann_id]
1215
+
1216
+ self.all_data_items = data_items
1060
1217
  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)
1218
+ self.last_selected_item_id = None
1066
1219
 
1067
- annotation_widget.show()
1068
- self.annotation_widgets_by_id[data_item.annotation.id] = annotation_widget
1069
-
1070
- self.recalculate_widget_positions()
1220
+ self.recalculate_layout()
1071
1221
  self._update_toolbar_state()
1072
1222
  # Update the label window with the new annotation count
1073
1223
  self.explorer_window.main_window.label_window.update_annotation_count()
1074
1224
 
1075
1225
  def resizeEvent(self, event):
1076
1226
  """On window resize, reflow the annotation widgets."""
1077
- super().resizeEvent(event)
1227
+ super(AnnotationViewer, self).resizeEvent(event)
1078
1228
  if not hasattr(self, '_resize_timer'):
1079
1229
  self._resize_timer = QTimer(self)
1080
1230
  self._resize_timer.setSingleShot(True)
1081
- self._resize_timer.timeout.connect(self.recalculate_widget_positions)
1231
+ self._resize_timer.timeout.connect(self.recalculate_layout)
1082
1232
  self._resize_timer.start(100)
1083
1233
 
1084
1234
  def keyPressEvent(self, event):
@@ -1099,64 +1249,51 @@ class AnnotationViewer(QScrollArea):
1099
1249
  else:
1100
1250
  super().keyPressEvent(event)
1101
1251
 
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."""
1252
+ def eventFilter(self, source, event):
1253
+ """Filters events from the scroll area's viewport to handle mouse interactions."""
1254
+ if source is self.scroll_area.viewport():
1255
+ if event.type() == QEvent.MouseButtonPress:
1256
+ return self.viewport_mouse_press(event)
1257
+ elif event.type() == QEvent.MouseMove:
1258
+ return self.viewport_mouse_move(event)
1259
+ elif event.type() == QEvent.MouseButtonRelease:
1260
+ return self.viewport_mouse_release(event)
1261
+ elif event.type() == QEvent.MouseButtonDblClick:
1262
+ return self.viewport_mouse_double_click(event)
1263
+
1264
+ return super(AnnotationViewer, self).eventFilter(source, event)
1265
+
1266
+ def viewport_mouse_press(self, event):
1267
+ """Handle mouse press inside the viewport for selection."""
1268
+ if event.button() == Qt.LeftButton and event.modifiers() == Qt.ControlModifier:
1269
+ # Start rubber band selection
1270
+ self.selection_at_press = set(self.selected_widgets)
1271
+ self.rubber_band_origin = event.pos()
1272
+
1273
+ # Check if the press was on a widget to avoid starting rubber band on a widget click
1274
+ content_pos = self.content_widget.mapFrom(self.scroll_area.viewport(), event.pos())
1275
+ child_at_pos = self.content_widget.childAt(content_pos)
1276
+ self.mouse_pressed_on_widget = isinstance(child_at_pos, AnnotationImageWidget)
1277
+
1278
+ return True # Event handled
1279
+
1280
+ elif event.button() == Qt.LeftButton and not event.modifiers():
1281
+ # Clear selection if clicking on the background
1282
+ content_pos = self.content_widget.mapFrom(self.scroll_area.viewport(), event.pos())
1283
+ if self.content_widget.childAt(content_pos) is None:
1284
+ if self.selected_widgets:
1285
+ changed_ids = [w.data_item.annotation.id for w in self.selected_widgets]
1286
+ self.clear_selection()
1287
+ self.selection_changed.emit(changed_ids)
1288
+ if hasattr(self.explorer_window.annotation_window, 'unselect_annotations'):
1289
+ self.explorer_window.annotation_window.unselect_annotations()
1290
+ return True
1291
+
1292
+ return False # Let the event propagate for default behaviors like scrolling
1293
+
1294
+ def viewport_mouse_double_click(self, event):
1295
+ """Handle double-click in the viewport to clear selection and reset view."""
1158
1296
  if event.button() == Qt.LeftButton:
1159
- changed_ids = []
1160
1297
  if self.selected_widgets:
1161
1298
  changed_ids = [w.data_item.annotation.id for w in self.selected_widgets]
1162
1299
  self.clear_selection()
@@ -1164,50 +1301,42 @@ class AnnotationViewer(QScrollArea):
1164
1301
  if self.isolated_mode:
1165
1302
  self.show_all_annotations()
1166
1303
  self.reset_view_requested.emit()
1167
- event.accept()
1168
- else:
1169
- super().mouseDoubleClickEvent(event)
1304
+ return True
1305
+ return False
1170
1306
 
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
1307
+ def viewport_mouse_move(self, event):
1308
+ """Handle mouse move in the viewport for dynamic rubber band selection."""
1174
1309
  if (
1175
1310
  self.rubber_band_origin is None or
1176
1311
  event.buttons() != Qt.LeftButton or
1177
- event.modifiers() != Qt.ControlModifier
1312
+ event.modifiers() != Qt.ControlModifier or
1313
+ self.mouse_pressed_on_widget
1178
1314
  ):
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
1315
+ return False
1186
1316
 
1187
1317
  # Only start selection if drag distance exceeds threshold
1188
1318
  distance = (event.pos() - self.rubber_band_origin).manhattanLength()
1189
1319
  if distance < self.drag_threshold:
1190
- return
1320
+ return True
1191
1321
 
1192
1322
  # Create and show the rubber band if not already present
1193
1323
  if not self.rubber_band:
1194
- self.rubber_band = QRubberBand(QRubberBand.Rectangle, self.viewport())
1324
+ self.rubber_band = QRubberBand(QRubberBand.Rectangle, self.scroll_area.viewport())
1195
1325
 
1196
1326
  rect = QRect(self.rubber_band_origin, event.pos()).normalized()
1197
1327
  self.rubber_band.setGeometry(rect)
1198
1328
  self.rubber_band.show()
1329
+
1199
1330
  selection_rect = self.rubber_band.geometry()
1200
1331
  content_widget = self.content_widget
1201
1332
  changed_ids = []
1202
1333
 
1203
1334
  # Iterate over all annotation widgets to update selection state
1204
1335
  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
- )
1336
+ # Map widget's geometry from content_widget coordinates to viewport coordinates
1337
+ mapped_top_left = content_widget.mapTo(self.scroll_area.viewport(), widget.geometry().topLeft())
1338
+ widget_rect_in_viewport = QRect(mapped_top_left, widget.geometry().size())
1339
+
1211
1340
  is_in_band = selection_rect.intersects(widget_rect_in_viewport)
1212
1341
  should_be_selected = (widget in self.selection_at_press) or is_in_band
1213
1342
 
@@ -1224,77 +1353,75 @@ class AnnotationViewer(QScrollArea):
1224
1353
  if changed_ids:
1225
1354
  self.selection_changed.emit(changed_ids)
1226
1355
 
1227
- def mouseReleaseEvent(self, event):
1228
- """Handle mouse release to complete rubber band selection."""
1356
+ return True
1357
+
1358
+ def viewport_mouse_release(self, event):
1359
+ """Handle mouse release in the viewport to finalize rubber band selection."""
1229
1360
  if self.rubber_band_origin is not None and event.button() == Qt.LeftButton:
1230
1361
  if self.rubber_band and self.rubber_band.isVisible():
1231
1362
  self.rubber_band.hide()
1232
1363
  self.rubber_band.deleteLater()
1233
1364
  self.rubber_band = None
1234
-
1235
- self.selection_at_press = set()
1236
1365
  self.rubber_band_origin = None
1237
- self.mouse_pressed_on_widget = False
1238
- event.accept()
1239
- return
1240
-
1241
- super().mouseReleaseEvent(event)
1366
+ return True
1367
+ return False
1242
1368
 
1243
1369
  def handle_annotation_selection(self, widget, event):
1244
1370
  """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()]
1371
+ # The list for range selection should be based on the sorted data items
1372
+ sorted_data_items = self._get_sorted_data_items()
1373
+
1374
+ # In isolated mode, the list should only contain isolated items
1375
+ if self.isolated_mode:
1376
+ isolated_ids = {w.data_item.annotation.id for w in self.isolated_widgets}
1377
+ sorted_data_items = [item for item in sorted_data_items if item.annotation.id in isolated_ids]
1246
1378
 
1247
1379
  try:
1248
- widget_index = widget_list.index(widget)
1380
+ # Find the index of the clicked widget's data item
1381
+ widget_data_item = widget.data_item
1382
+ current_index = sorted_data_items.index(widget_data_item)
1249
1383
  except ValueError:
1250
1384
  return
1251
1385
 
1252
1386
  modifiers = event.modifiers()
1253
1387
  changed_ids = []
1254
1388
 
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
1389
+ # Shift or Shift+Ctrl: range selection.
1390
+ if modifiers in (Qt.ShiftModifier, Qt.ShiftModifier | Qt.ControlModifier):
1391
+ last_index = -1
1392
+ if self.last_selected_item_id:
1393
+ try:
1394
+ # Find the data item corresponding to the last selected ID
1395
+ last_item = self.explorer_window.data_item_cache[self.last_selected_item_id]
1396
+ # Find its index in the *current* sorted list
1397
+ last_index = sorted_data_items.index(last_item)
1398
+ except (KeyError, ValueError):
1399
+ # The last selected item is not in the current view or cache, so no anchor
1400
+ last_index = -1
1401
+
1402
+ if last_index != -1:
1403
+ start = min(last_index, current_index)
1404
+ end = max(last_index, current_index)
1278
1405
 
1279
1406
  # Select all widgets in the range
1280
1407
  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)
1408
+ item_to_select = sorted_data_items[i]
1409
+ widget_to_select = self.annotation_widgets_by_id.get(item_to_select.annotation.id)
1410
+ if widget_to_select and self.select_widget(widget_to_select):
1411
+ changed_ids.append(item_to_select.annotation.id)
1283
1412
  else:
1284
1413
  # No previous selection, just select the clicked widget
1285
1414
  if self.select_widget(widget):
1286
1415
  changed_ids.append(widget.data_item.annotation.id)
1287
- self.last_selected_index = widget_index
1416
+
1417
+ self.last_selected_item_id = widget.data_item.annotation.id
1288
1418
 
1289
1419
  # Ctrl: toggle selection of the clicked widget
1290
1420
  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
1421
+ # Toggle selection and update the anchor
1422
+ if self.toggle_widget_selection(widget):
1423
+ changed_ids.append(widget.data_item.annotation.id)
1424
+ self.last_selected_item_id = widget.data_item.annotation.id
1298
1425
 
1299
1426
  # No modifier: single selection
1300
1427
  else:
@@ -1309,34 +1436,22 @@ class AnnotationViewer(QScrollArea):
1309
1436
  # Select the clicked widget
1310
1437
  if self.select_widget(widget):
1311
1438
  changed_ids.append(newly_selected_id)
1312
- self.last_selected_index = widget_index
1439
+ self.last_selected_item_id = widget.data_item.annotation.id
1313
1440
 
1314
1441
  # If in isolated mode, update which widgets are visible
1315
1442
  if self.isolated_mode:
1316
- self._update_isolation()
1443
+ pass # Do not change the isolated set on internal selection changes
1317
1444
 
1318
1445
  # Emit signal if any selection state changed
1319
1446
  if changed_ids:
1320
1447
  self.selection_changed.emit(changed_ids)
1321
1448
 
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)
1449
+ def toggle_widget_selection(self, widget):
1450
+ """Toggles the selection state of a widget and returns True if changed."""
1451
+ if widget.is_selected():
1452
+ return self.deselect_widget(widget)
1453
+ else:
1454
+ return self.select_widget(widget)
1340
1455
 
1341
1456
  def select_widget(self, widget):
1342
1457
  """Selects a widget, updates its data_item, and returns True if state changed."""
@@ -1390,11 +1505,6 @@ class AnnotationViewer(QScrollArea):
1390
1505
  # Resync internal list of selected widgets from the source of truth
1391
1506
  self.selected_widgets = [w for w in self.annotation_widgets_by_id.values() if w.is_selected()]
1392
1507
 
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
1508
  finally:
1399
1509
  self.setUpdatesEnabled(True)
1400
1510
  self._update_toolbar_state()
@@ -1410,7 +1520,7 @@ class AnnotationViewer(QScrollArea):
1410
1520
  changed_ids.append(widget.data_item.annotation.id)
1411
1521
 
1412
1522
  if self.sort_combo.currentText() == "Label":
1413
- self.recalculate_widget_positions()
1523
+ self.recalculate_layout()
1414
1524
  if changed_ids:
1415
1525
  self.preview_changed.emit(changed_ids)
1416
1526
 
@@ -1429,8 +1539,8 @@ class AnnotationViewer(QScrollArea):
1429
1539
 
1430
1540
  if something_changed:
1431
1541
  # Recalculate positions to update sorting and re-flow the layout
1432
- if self.sort_combo.currentText() in ("Label", "Image"):
1433
- self.recalculate_widget_positions()
1542
+ if self.sort_combo.currentText() == "Label":
1543
+ self.recalculate_layout()
1434
1544
 
1435
1545
  def has_preview_changes(self):
1436
1546
  """Return True if there are preview changes."""
@@ -1527,7 +1637,7 @@ class ExplorerWindow(QMainWindow):
1527
1637
  if hasattr(self.embedding_viewer, 'animation_timer') and self.embedding_viewer.animation_timer:
1528
1638
  self.embedding_viewer.animation_timer.stop()
1529
1639
 
1530
- # Call the main cancellation method to revert any pending changes
1640
+ # Call the main cancellation method to revert any pending changes and clear selections.
1531
1641
  self.clear_preview_changes()
1532
1642
 
1533
1643
  # Clean up the feature store by deleting its files
@@ -1615,9 +1725,6 @@ class ExplorerWindow(QMainWindow):
1615
1725
  self._initialize_data_item_cache()
1616
1726
  self.annotation_settings_widget.set_default_to_current_image()
1617
1727
  self.refresh_filters()
1618
-
1619
- self.annotation_settings_widget.set_default_to_current_image()
1620
- self.refresh_filters()
1621
1728
 
1622
1729
  try:
1623
1730
  self.label_window.labelSelected.disconnect(self.on_label_selected_for_preview)
@@ -1625,6 +1732,7 @@ class ExplorerWindow(QMainWindow):
1625
1732
  pass
1626
1733
 
1627
1734
  # Connect signals to slots
1735
+ self.annotation_window.annotationModified.connect(self.on_annotation_modified)
1628
1736
  self.label_window.labelSelected.connect(self.on_label_selected_for_preview)
1629
1737
  self.annotation_viewer.selection_changed.connect(self.on_annotation_view_selection_changed)
1630
1738
  self.annotation_viewer.preview_changed.connect(self.on_preview_changed)
@@ -1639,42 +1747,70 @@ class ExplorerWindow(QMainWindow):
1639
1747
  self.annotation_viewer.find_similar_requested.connect(self.find_similar_annotations)
1640
1748
  self.annotation_viewer.similarity_settings_widget.parameters_changed.connect(self.on_similarity_params_changed)
1641
1749
 
1750
+ def _clear_selections(self):
1751
+ """Clears selections in both viewers and stops animations."""
1752
+ if not self._ui_initialized:
1753
+ return
1754
+
1755
+ # Clear selection in the annotation viewer, which also stops widget animations.
1756
+ if self.annotation_viewer:
1757
+ self.annotation_viewer.clear_selection()
1758
+
1759
+ # Clear selection in the embedding viewer. This deselects all points
1760
+ # and stops the animation timer via its on_selection_changed handler.
1761
+ if self.embedding_viewer:
1762
+ self.embedding_viewer.render_selection_from_ids(set())
1763
+
1764
+ # Update other UI elements that depend on selection state.
1765
+ self.update_label_window_selection()
1766
+ self.update_button_states()
1767
+
1768
+ # Process events
1769
+ QApplication.processEvents()
1770
+ print("Cleared all active selections.")
1771
+
1642
1772
  @pyqtSlot(list)
1643
1773
  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
1774
+ """Syncs selection from AnnotationViewer to other components and manages UI state."""
1775
+ # Unselect any annotation in the main AnnotationWindow for a clean slate
1646
1776
  if hasattr(self, 'annotation_window'):
1647
1777
  self.annotation_window.unselect_annotations()
1648
1778
 
1649
1779
  all_selected_ids = {w.data_item.annotation.id for w in self.annotation_viewer.selected_widgets}
1780
+
1781
+ # Sync selection to the embedding viewer
1650
1782
  if self.embedding_viewer.points_by_id:
1783
+ blocker = QSignalBlocker(self.embedding_viewer)
1651
1784
  self.embedding_viewer.render_selection_from_ids(all_selected_ids)
1652
1785
 
1653
- # Call the new centralized method
1786
+ # Get the select tool to manage its state
1787
+ select_tool = self.annotation_window.tools.get('select')
1788
+ if select_tool:
1789
+ # If the selection from the explorer is not a single item (i.e., it's empty
1790
+ # or a multi-selection), hide the handles and release the lock.
1791
+ if len(all_selected_ids) != 1:
1792
+ select_tool._hide_resize_handles()
1793
+ select_tool.selection_locked = False
1794
+ # Ensure that the select tool is not active
1795
+ self.annotation_window.set_selected_tool(None)
1796
+
1797
+ # Update the label window based on the new selection
1654
1798
  self.update_label_window_selection()
1655
1799
 
1656
1800
  @pyqtSlot(list)
1657
1801
  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()
1802
+ """Syncs selection from EmbeddingViewer to AnnotationViewer and isolates."""
1803
+ selected_ids_set = set(all_selected_ann_ids)
1804
+
1805
+ # If a selection is made in the embedding viewer, isolate those widgets.
1806
+ if selected_ids_set:
1807
+ # This new method will handle setting the isolated set and selecting them.
1808
+ self.annotation_viewer.isolate_and_select_from_ids(selected_ids_set)
1809
+ # If the selection is cleared in the embedding viewer, exit isolation mode.
1810
+ elif self.annotation_viewer.isolated_mode:
1811
+ self.annotation_viewer.show_all_annotations()
1677
1812
 
1813
+ # We still need to update the label window based on the selection.
1678
1814
  self.update_label_window_selection()
1679
1815
 
1680
1816
  @pyqtSlot(list)
@@ -1691,6 +1827,32 @@ class ExplorerWindow(QMainWindow):
1691
1827
  widget = self.annotation_viewer.annotation_widgets_by_id.get(ann_id)
1692
1828
  if widget:
1693
1829
  widget.update_tooltip()
1830
+
1831
+ @pyqtSlot(str)
1832
+ def on_annotation_modified(self, annotation_id):
1833
+ """
1834
+ Handles an annotation being moved or resized in the AnnotationWindow.
1835
+ This invalidates the cached features and updates the annotation's thumbnail.
1836
+ """
1837
+ print(f"Annotation {annotation_id} was modified. Removing its cached features.")
1838
+ if hasattr(self, 'feature_store'):
1839
+ # This method must exist on the FeatureStore to clear features
1840
+ # for the given annotation ID across all stored models.
1841
+ self.feature_store.remove_features_for_annotation(annotation_id)
1842
+
1843
+ # Update the AnnotationImageWidget in the AnnotationViewer
1844
+ if hasattr(self, 'annotation_viewer'):
1845
+ # Find the corresponding widget by its annotation ID
1846
+ widget = self.annotation_viewer.annotation_widgets_by_id.get(annotation_id)
1847
+ if widget:
1848
+ # The annotation's geometry may have changed, so we need to update the widget.
1849
+ # 1. Recalculate the aspect ratio.
1850
+ widget.recalculate_aspect_ratio()
1851
+ # 2. Unload the stale image data. This marks the widget as "dirty".
1852
+ widget.unload_image()
1853
+ # 3. Recalculate the layout. This will resize the widget based on the new
1854
+ # aspect ratio and reload the image if the widget is currently visible.
1855
+ self.annotation_viewer.recalculate_layout()
1694
1856
 
1695
1857
  @pyqtSlot()
1696
1858
  def on_reset_view_requested(self):
@@ -2665,7 +2827,7 @@ class ExplorerWindow(QMainWindow):
2665
2827
  widget.setParent(None)
2666
2828
  widget.deleteLater()
2667
2829
  blocker.unblock()
2668
- self.annotation_viewer.recalculate_widget_positions()
2830
+ self.annotation_viewer.recalculate_layout()
2669
2831
 
2670
2832
  # 4. Remove from EmbeddingViewer
2671
2833
  blocker = QSignalBlocker(self.embedding_viewer.graphics_scene)
@@ -2693,8 +2855,12 @@ class ExplorerWindow(QMainWindow):
2693
2855
 
2694
2856
  def clear_preview_changes(self):
2695
2857
  """
2696
- Clears all preview changes in the annotation viewer and updates tooltips.
2858
+ Clears all preview changes in the annotation viewer, reverts tooltips,
2859
+ and clears any active selections.
2697
2860
  """
2861
+ # First, clear any active selections from the UI.
2862
+ self._clear_selections()
2863
+
2698
2864
  if hasattr(self, 'annotation_viewer'):
2699
2865
  self.annotation_viewer.clear_preview_states()
2700
2866
 
@@ -2704,7 +2870,7 @@ class ExplorerWindow(QMainWindow):
2704
2870
  for point in self.embedding_viewer.points_by_id.values():
2705
2871
  point.update_tooltip()
2706
2872
 
2707
- # After reverting all changes, update the button states
2873
+ # After reverting all changes, update the button states.
2708
2874
  self.update_button_states()
2709
2875
  print("Cleared all pending changes.")
2710
2876
 
@@ -2748,13 +2914,12 @@ class ExplorerWindow(QMainWindow):
2748
2914
  self.image_window.update_image_annotations(image_path)
2749
2915
  self.annotation_window.load_annotations()
2750
2916
 
2751
- # Refresh the annotation viewer since its underlying data has changed
2917
+ # Refresh the annotation viewer since its underlying data has changed.
2918
+ # This implicitly deselects everything by rebuilding the widgets.
2752
2919
  self.annotation_viewer.update_annotations(self.current_data_items)
2753
2920
 
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()
2921
+ # Explicitly clear selections and update UI states for consistency.
2922
+ self._clear_selections()
2758
2923
 
2759
2924
  print("Applied changes successfully.")
2760
2925