coralnet-toolbox 0.0.70__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
@@ -130,7 +137,7 @@ class EmbeddingViewer(QWidget):
130
137
  # Create a QToolButton to have both a primary action and a dropdown menu
131
138
  self.find_mislabels_button = QToolButton()
132
139
  self.find_mislabels_button.setText("Find Potential Mislabels")
133
- self.find_mislabels_button.setPopupMode(QToolButton.MenuButtonPopup) # Key change for split-button style
140
+ self.find_mislabels_button.setPopupMode(QToolButton.MenuButtonPopup) # Key change for split-button style
134
141
  self.find_mislabels_button.setToolButtonStyle(Qt.ToolButtonTextOnly)
135
142
  self.find_mislabels_button.setStyleSheet(
136
143
  "QToolButton::menu-indicator {"
@@ -186,9 +193,18 @@ class EmbeddingViewer(QWidget):
186
193
 
187
194
  uncertainty_settings_widget.parameters_changed.connect(self.uncertainty_parameters_changed.emit)
188
195
  toolbar_layout.addWidget(self.find_uncertain_button)
189
-
196
+
197
+ # Add a strech and separator
190
198
  toolbar_layout.addStretch()
191
-
199
+ toolbar_layout.addWidget(self._create_separator())
200
+
201
+ # Center on selection button
202
+ self.center_on_selection_button = QPushButton()
203
+ self.center_on_selection_button.setIcon(get_icon("target.png"))
204
+ self.center_on_selection_button.setToolTip("Center view on selected point(s)")
205
+ self.center_on_selection_button.clicked.connect(self.center_on_selection)
206
+ toolbar_layout.addWidget(self.center_on_selection_button)
207
+
192
208
  # Home button to reset view
193
209
  self.home_button = QPushButton()
194
210
  self.home_button.setIcon(get_icon("home.png"))
@@ -215,6 +231,26 @@ class EmbeddingViewer(QWidget):
215
231
  separator.setStyleSheet("color: gray; margin: 0 5px;")
216
232
  return separator
217
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
+
218
254
  @pyqtSlot()
219
255
  def isolate_selection(self):
220
256
  """Hides all points that are not currently selected."""
@@ -226,8 +262,7 @@ class EmbeddingViewer(QWidget):
226
262
  self.graphics_view.setUpdatesEnabled(False)
227
263
  try:
228
264
  for point in self.points_by_id.values():
229
- if point not in self.isolated_points:
230
- point.hide()
265
+ point.setVisible(point in self.isolated_points)
231
266
  self.isolated_mode = True
232
267
  finally:
233
268
  self.graphics_view.setUpdatesEnabled(True)
@@ -244,8 +279,8 @@ class EmbeddingViewer(QWidget):
244
279
  self.isolated_points.clear()
245
280
  self.graphics_view.setUpdatesEnabled(False)
246
281
  try:
247
- for point in self.points_by_id.values():
248
- point.show()
282
+ # Instead of showing all, let the virtualization logic take over
283
+ self._update_visible_points()
249
284
  finally:
250
285
  self.graphics_view.setUpdatesEnabled(True)
251
286
 
@@ -258,6 +293,7 @@ class EmbeddingViewer(QWidget):
258
293
 
259
294
  self.find_mislabels_button.setEnabled(points_exist)
260
295
  self.find_uncertain_button.setEnabled(points_exist and self.is_uncertainty_analysis_available)
296
+ self.center_on_selection_button.setEnabled(points_exist and selection_exists)
261
297
 
262
298
  if self.isolated_mode:
263
299
  self.isolate_button.hide()
@@ -270,12 +306,46 @@ class EmbeddingViewer(QWidget):
270
306
  def reset_view(self):
271
307
  """Reset the view to fit all embedding points."""
272
308
  self.fit_view_to_points()
309
+
310
+ def center_on_selection(self):
311
+ """Centers the view on selected point(s) or maintains the current view if no points are selected."""
312
+ selected_items = self.graphics_scene.selectedItems()
313
+ if not selected_items:
314
+ # No selection, show a message
315
+ QMessageBox.information(self, "No Selection", "Please select one or more points first.")
316
+ return
317
+
318
+ # Create a bounding rect that encompasses all selected points
319
+ selection_rect = None
320
+
321
+ for item in selected_items:
322
+ if isinstance(item, EmbeddingPointItem):
323
+ # Get the item's bounding rect in scene coordinates
324
+ item_rect = item.sceneBoundingRect()
325
+
326
+ # Add padding around the point for better visibility
327
+ padding = 50 # pixels
328
+ item_rect = item_rect.adjusted(-padding, -padding, padding, padding)
329
+
330
+ if selection_rect is None:
331
+ selection_rect = item_rect
332
+ else:
333
+ selection_rect = selection_rect.united(item_rect)
334
+
335
+ if selection_rect:
336
+ # Add extra margin for better visibility
337
+ margin = 20
338
+ selection_rect = selection_rect.adjusted(-margin, -margin, margin, margin)
339
+
340
+ # Fit the view to the selection rect
341
+ self.graphics_view.fitInView(selection_rect, Qt.KeepAspectRatio)
273
342
 
274
343
  def show_placeholder(self):
275
344
  """Show the placeholder message and hide the graphics view."""
276
345
  self.graphics_view.setVisible(False)
277
346
  self.placeholder_label.setVisible(True)
278
347
  self.home_button.setEnabled(False)
348
+ self.center_on_selection_button.setEnabled(False) # Disable center button
279
349
  self.find_mislabels_button.setEnabled(False)
280
350
  self.find_uncertain_button.setEnabled(False)
281
351
 
@@ -439,6 +509,7 @@ class EmbeddingViewer(QWidget):
439
509
  # Forward right-drag as left-drag for panning
440
510
  left_event = QMouseEvent(event.type(), event.localPos(), Qt.LeftButton, Qt.LeftButton, event.modifiers())
441
511
  QGraphicsView.mouseMoveEvent(self.graphics_view, left_event)
512
+ self._schedule_view_update()
442
513
  else:
443
514
  # Default mouse move handling
444
515
  QGraphicsView.mouseMoveEvent(self.graphics_view, event)
@@ -452,6 +523,7 @@ class EmbeddingViewer(QWidget):
452
523
  elif event.button() == Qt.RightButton:
453
524
  left_event = QMouseEvent(event.type(), event.localPos(), Qt.LeftButton, Qt.LeftButton, event.modifiers())
454
525
  QGraphicsView.mouseReleaseEvent(self.graphics_view, left_event)
526
+ self._schedule_view_update()
455
527
  self.graphics_view.setDragMode(QGraphicsView.NoDrag)
456
528
  else:
457
529
  QGraphicsView.mouseReleaseEvent(self.graphics_view, event)
@@ -481,6 +553,7 @@ class EmbeddingViewer(QWidget):
481
553
  # Translate view to keep mouse position stable
482
554
  delta = new_pos - old_pos
483
555
  self.graphics_view.translate(delta.x(), delta.y())
556
+ self._schedule_view_update()
484
557
 
485
558
  def update_embeddings(self, data_items):
486
559
  """Update the embedding visualization. Creates an EmbeddingPointItem for
@@ -497,6 +570,8 @@ class EmbeddingViewer(QWidget):
497
570
 
498
571
  # Ensure buttons are in the correct initial state
499
572
  self._update_toolbar_state()
573
+ # Set initial visibility
574
+ self._update_visible_points()
500
575
 
501
576
  def clear_points(self):
502
577
  """Clear all embedding points from the scene."""
@@ -541,6 +616,9 @@ class EmbeddingViewer(QWidget):
541
616
 
542
617
  # Update button states based on new selection
543
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()
544
622
 
545
623
  def animate_selection(self):
546
624
  """Animate selected points with a marching ants effect."""
@@ -580,6 +658,9 @@ class EmbeddingViewer(QWidget):
580
658
 
581
659
  # Manually trigger on_selection_changed to update animation and emit signals
582
660
  self.on_selection_changed()
661
+
662
+ # After selection, update visibility to ensure newly selected points are shown
663
+ self._update_visible_points()
583
664
 
584
665
  def fit_view_to_points(self):
585
666
  """Fit the view to show all embedding points."""
@@ -587,11 +668,13 @@ class EmbeddingViewer(QWidget):
587
668
  self.graphics_view.fitInView(self.graphics_scene.itemsBoundingRect(), Qt.KeepAspectRatio)
588
669
  else:
589
670
  self.graphics_view.fitInView(-2500, -2500, 5000, 5000, Qt.KeepAspectRatio)
590
-
591
-
592
- class AnnotationViewer(QScrollArea):
593
- """Scrollable grid widget for displaying annotation image crops with selection,
594
- 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
+ """
595
678
  selection_changed = pyqtSignal(list)
596
679
  preview_changed = pyqtSignal(list)
597
680
  reset_view_requested = pyqtSignal()
@@ -604,7 +687,7 @@ class AnnotationViewer(QScrollArea):
604
687
 
605
688
  self.annotation_widgets_by_id = {}
606
689
  self.selected_widgets = []
607
- self.last_selected_index = -1
690
+ self.last_selected_item_id = None # Use a persistent ID for the selection anchor
608
691
  self.current_widget_size = 96
609
692
  self.selection_at_press = set()
610
693
  self.rubber_band = None
@@ -616,23 +699,32 @@ class AnnotationViewer(QScrollArea):
616
699
  self.isolated_mode = False
617
700
  self.isolated_widgets = set()
618
701
 
619
- # State for new sorting options
702
+ # State for sorting options
620
703
  self.active_ordered_ids = []
621
704
  self.is_confidence_sort_available = False
622
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
+
623
713
  self.setup_ui()
624
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
+
625
720
  def setup_ui(self):
626
721
  """Set up the UI with a toolbar and a scrollable content area."""
627
- self.setWidgetResizable(True)
628
- self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
629
- self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
630
-
631
- main_container = QWidget()
632
- main_layout = QVBoxLayout(main_container)
722
+ # This widget is the main container with its own layout
723
+ main_layout = QVBoxLayout(self)
633
724
  main_layout.setContentsMargins(0, 0, 0, 0)
634
725
  main_layout.setSpacing(4)
635
726
 
727
+ # Create and add the toolbar to the main layout
636
728
  toolbar_widget = QWidget()
637
729
  toolbar_layout = QHBoxLayout(toolbar_widget)
638
730
  toolbar_layout.setContentsMargins(4, 2, 4, 2)
@@ -698,16 +790,16 @@ class AnnotationViewer(QScrollArea):
698
790
  self.size_value_label.setMinimumWidth(30)
699
791
  toolbar_layout.addWidget(self.size_value_label)
700
792
  main_layout.addWidget(toolbar_widget)
701
-
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
+
702
800
  self.content_widget = QWidget()
703
- content_scroll = QScrollArea()
704
- content_scroll.setWidgetResizable(True)
705
- content_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
706
- content_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
707
- content_scroll.setWidget(self.content_widget)
708
-
709
- main_layout.addWidget(content_scroll)
710
- self.setWidget(main_container)
801
+ self.scroll_area.setWidget(self.content_widget)
802
+ main_layout.addWidget(self.scroll_area)
711
803
 
712
804
  # Set the initial state of the sort options
713
805
  self._update_sort_options_state()
@@ -736,7 +828,7 @@ class AnnotationViewer(QScrollArea):
736
828
  annotation_to_select = widget.annotation
737
829
 
738
830
  # ctrl+right click to only select this annotation (single selection):
739
- self.clear_selection()
831
+ self.clear_selection()
740
832
  self.select_widget(widget)
741
833
  changed_ids = [widget.data_item.annotation.id]
742
834
 
@@ -749,13 +841,25 @@ class AnnotationViewer(QScrollArea):
749
841
  if hasattr(explorer.annotation_window, 'set_image'):
750
842
  explorer.annotation_window.set_image(image_path)
751
843
 
752
- # Now, select the annotation in the annotation_window
844
+ # Now, select the annotation in the annotation_window (activates animation)
753
845
  if hasattr(explorer.annotation_window, 'select_annotation'):
754
- explorer.annotation_window.select_annotation(annotation_to_select)
846
+ explorer.annotation_window.select_annotation(annotation_to_select, quiet_mode=True)
755
847
 
756
848
  # Center the annotation window view on the selected annotation
757
849
  if hasattr(explorer.annotation_window, 'center_on_annotation'):
758
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()
759
863
 
760
864
  # Also clear any existing selection in the explorer window itself
761
865
  explorer.embedding_viewer.render_selection_from_ids({widget.data_item.annotation.id})
@@ -767,7 +871,7 @@ class AnnotationViewer(QScrollArea):
767
871
  @pyqtSlot()
768
872
  def isolate_selection(self):
769
873
  """Hides all annotation widgets that are not currently selected."""
770
- if not self.selected_widgets or self.isolated_mode:
874
+ if not self.selected_widgets:
771
875
  return
772
876
 
773
877
  self.isolated_widgets = set(self.selected_widgets)
@@ -777,13 +881,36 @@ class AnnotationViewer(QScrollArea):
777
881
  if widget not in self.isolated_widgets:
778
882
  widget.hide()
779
883
  self.isolated_mode = True
780
- self.recalculate_widget_positions()
884
+ self.recalculate_layout()
781
885
  finally:
782
886
  self.content_widget.setUpdatesEnabled(True)
783
887
 
784
888
  self._update_toolbar_state()
785
889
  self.explorer_window.main_window.label_window.update_annotation_count()
786
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
+
787
914
  def display_and_isolate_ordered_results(self, ordered_ids):
788
915
  """
789
916
  Isolates the view to a specific set of ordered widgets, ensuring the
@@ -807,7 +934,7 @@ class AnnotationViewer(QScrollArea):
807
934
  widget.hide()
808
935
 
809
936
  self.isolated_mode = True
810
- self.recalculate_widget_positions() # Crucial grid update
937
+ self.recalculate_layout() # Crucial grid update
811
938
  finally:
812
939
  self.content_widget.setUpdatesEnabled(True)
813
940
 
@@ -830,7 +957,7 @@ class AnnotationViewer(QScrollArea):
830
957
  for widget in self.annotation_widgets_by_id.values():
831
958
  widget.show()
832
959
 
833
- self.recalculate_widget_positions()
960
+ self.recalculate_layout()
834
961
  finally:
835
962
  self.content_widget.setUpdatesEnabled(True)
836
963
 
@@ -852,62 +979,71 @@ class AnnotationViewer(QScrollArea):
852
979
  def on_sort_changed(self, sort_type):
853
980
  """Handle sort type change."""
854
981
  self.active_ordered_ids = [] # Clear any special ordering
855
- self.recalculate_widget_positions()
982
+ self.recalculate_layout()
856
983
 
857
984
  def set_confidence_sort_availability(self, is_available):
858
985
  """Sets the availability of the confidence sort option."""
859
986
  self.is_confidence_sort_available = is_available
860
987
  self._update_sort_options_state()
861
988
 
862
- def _get_sorted_widgets(self):
863
- """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."""
864
991
  # If a specific order is active (e.g., from similarity search), use it.
865
992
  if self.active_ordered_ids:
866
- widget_map = {w.data_item.annotation.id: w for w in self.annotation_widgets_by_id.values()}
867
- ordered_widgets = [widget_map[ann_id] for ann_id in self.active_ordered_ids if ann_id in widget_map]
868
- return ordered_widgets
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
869
996
 
870
997
  # Otherwise, use the dropdown sort logic
871
998
  sort_type = self.sort_combo.currentText()
872
- widgets = list(self.annotation_widgets_by_id.values())
999
+ items = list(self.all_data_items)
873
1000
 
874
1001
  if sort_type == "Label":
875
- 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)
876
1003
  elif sort_type == "Image":
877
- 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))
878
1005
  elif sort_type == "Confidence":
879
1006
  # Sort by confidence, descending. Handles cases with no confidence gracefully.
880
- widgets.sort(key=lambda w: w.data_item.get_effective_confidence(), reverse=True)
881
-
882
- return widgets
1007
+ items.sort(key=lambda i: i.get_effective_confidence(), reverse=True)
1008
+
1009
+ return items
1010
+
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]
883
1019
 
884
- def _group_widgets_by_sort_key(self, widgets):
885
- """Group widgets by the current sort key."""
1020
+ def _group_data_items_by_sort_key(self, data_items):
1021
+ """Group data items by the current sort key."""
886
1022
  sort_type = self.sort_combo.currentText()
887
1023
  if not self.active_ordered_ids and sort_type == "None":
888
- return [("", widgets)]
889
-
890
- if self.active_ordered_ids: # Don't show group headers for similarity results
891
- 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)]
892
1028
 
893
1029
  groups = []
894
1030
  current_group = []
895
1031
  current_key = None
896
- for widget in widgets:
1032
+ for item in data_items:
897
1033
  if sort_type == "Label":
898
- key = widget.data_item.effective_label.short_label_code
1034
+ key = item.effective_label.short_label_code
899
1035
  elif sort_type == "Image":
900
- key = os.path.basename(widget.data_item.annotation.image_path)
1036
+ key = os.path.basename(item.annotation.image_path)
901
1037
  else:
902
1038
  key = "" # No headers for Confidence or None
903
1039
 
904
1040
  if key and current_key != key:
905
1041
  if current_group:
906
1042
  groups.append((current_key, current_group))
907
- current_group = [widget]
1043
+ current_group = [item]
908
1044
  current_key = key
909
1045
  else:
910
- current_group.append(widget)
1046
+ current_group.append(item)
911
1047
  if current_group:
912
1048
  groups.append((current_key, current_group))
913
1049
  return groups
@@ -938,7 +1074,7 @@ class AnnotationViewer(QScrollArea):
938
1074
  " }"
939
1075
  )
940
1076
  header.setFixedHeight(30)
941
- header.setMinimumWidth(self.viewport().width() - 20)
1077
+ header.setMinimumWidth(self.scroll_area.viewport().width() - 20)
942
1078
  header.show()
943
1079
  self._group_headers.append(header)
944
1080
  return header
@@ -950,35 +1086,79 @@ class AnnotationViewer(QScrollArea):
950
1086
 
951
1087
  self.current_widget_size = value
952
1088
  self.size_value_label.setText(str(value))
953
- self.content_widget.setUpdatesEnabled(False)
1089
+ self.recalculate_layout()
954
1090
 
955
- for widget in self.annotation_widgets_by_id.values():
956
- 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
957
1130
 
958
1131
  self.content_widget.setUpdatesEnabled(True)
959
- self.recalculate_widget_positions()
960
1132
 
961
- def recalculate_widget_positions(self):
962
- """Manually positions widgets in a flow layout with sorting and group headers."""
963
- 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:
964
1136
  self.content_widget.setMinimumSize(1, 1)
965
1137
  return
966
1138
 
967
1139
  self._clear_separator_labels()
968
- visible_widgets = [w for w in self._get_sorted_widgets() if not w.isHidden()]
969
- 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:
970
1148
  self.content_widget.setMinimumSize(1, 1)
971
1149
  return
972
1150
 
973
1151
  # Create groups based on the current sort key
974
- groups = self._group_widgets_by_sort_key(visible_widgets)
1152
+ groups = self._group_data_items_by_sort_key(sorted_data_items)
975
1153
  spacing = max(5, int(self.current_widget_size * 0.08))
976
- available_width = self.viewport().width()
1154
+ available_width = self.scroll_area.viewport().width()
977
1155
  x, y = spacing, spacing
978
1156
  max_height_in_row = 0
979
1157
 
980
- # Calculate the maximum height of the widgets in each row
981
- 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:
982
1162
  if group_name and self.sort_combo.currentText() != "None":
983
1163
  if x > spacing:
984
1164
  x = spacing
@@ -990,51 +1170,65 @@ class AnnotationViewer(QScrollArea):
990
1170
  x = spacing
991
1171
  max_height_in_row = 0
992
1172
 
993
- 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
+
994
1184
  widget_size = widget.size()
995
1185
  if x > spacing and x + widget_size.width() > available_width:
996
1186
  x = spacing
997
1187
  y += max_height_in_row + spacing
998
1188
  max_height_in_row = 0
999
- widget.move(x, y)
1189
+
1190
+ self.widget_positions[ann_id] = QRect(x, y, widget_size.width(), widget_size.height())
1191
+
1000
1192
  x += widget_size.width() + spacing
1001
1193
  max_height_in_row = max(max_height_in_row, widget_size.height())
1002
1194
 
1003
1195
  total_height = y + max_height_in_row + spacing
1004
1196
  self.content_widget.setMinimumSize(available_width, total_height)
1005
1197
 
1198
+ # After calculating layout, update what's visible
1199
+ self._update_visible_widgets()
1200
+
1006
1201
  def update_annotations(self, data_items):
1007
1202
  """Update displayed annotations, creating new widgets for them."""
1008
1203
  if self.isolated_mode:
1009
1204
  self.show_all_annotations()
1010
1205
 
1011
- for widget in self.annotation_widgets_by_id.values():
1012
- widget.setParent(None)
1013
- widget.deleteLater()
1014
-
1015
- 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
1016
1217
  self.selected_widgets.clear()
1017
- self.last_selected_index = -1
1218
+ self.last_selected_item_id = None
1018
1219
 
1019
- for data_item in data_items:
1020
- annotation_widget = AnnotationImageWidget(
1021
- data_item, self.current_widget_size, self, self.content_widget)
1022
-
1023
- annotation_widget.show()
1024
- self.annotation_widgets_by_id[data_item.annotation.id] = annotation_widget
1025
-
1026
- self.recalculate_widget_positions()
1220
+ self.recalculate_layout()
1027
1221
  self._update_toolbar_state()
1028
1222
  # Update the label window with the new annotation count
1029
1223
  self.explorer_window.main_window.label_window.update_annotation_count()
1030
1224
 
1031
1225
  def resizeEvent(self, event):
1032
1226
  """On window resize, reflow the annotation widgets."""
1033
- super().resizeEvent(event)
1227
+ super(AnnotationViewer, self).resizeEvent(event)
1034
1228
  if not hasattr(self, '_resize_timer'):
1035
1229
  self._resize_timer = QTimer(self)
1036
1230
  self._resize_timer.setSingleShot(True)
1037
- self._resize_timer.timeout.connect(self.recalculate_widget_positions)
1231
+ self._resize_timer.timeout.connect(self.recalculate_layout)
1038
1232
  self._resize_timer.start(100)
1039
1233
 
1040
1234
  def keyPressEvent(self, event):
@@ -1055,64 +1249,51 @@ class AnnotationViewer(QScrollArea):
1055
1249
  else:
1056
1250
  super().keyPressEvent(event)
1057
1251
 
1058
- def mousePressEvent(self, event):
1059
- """Handle mouse press for starting rubber band selection OR clearing selection."""
1060
- if event.button() == Qt.LeftButton:
1061
- if not event.modifiers():
1062
- # If left click with no modifiers, check if click is outside widgets
1063
- is_on_widget = False
1064
- child_at_pos = self.childAt(event.pos())
1065
-
1066
- if child_at_pos:
1067
- widget = child_at_pos
1068
- # Traverse up the parent chain to see if click is on an annotation widget
1069
- while widget and widget != self:
1070
- if hasattr(widget, 'annotation_viewer') and widget.annotation_viewer == self:
1071
- is_on_widget = True
1072
- break
1073
- widget = widget.parent()
1074
-
1075
- # If click is outside widgets, clear annotation_window selection
1076
- if not is_on_widget:
1077
- # Clear annotation selection in the annotation_window as well
1078
- if hasattr(self.explorer_window, 'annotation_window') and self.explorer_window.annotation_window:
1079
- if hasattr(self.explorer_window.annotation_window, 'unselect_annotations'):
1080
- self.explorer_window.annotation_window.unselect_annotations()
1081
- # If there is a selection in the viewer, clear it
1082
- if self.selected_widgets:
1083
- changed_ids = [w.data_item.annotation.id for w in self.selected_widgets]
1084
- self.clear_selection()
1085
- self.selection_changed.emit(changed_ids)
1086
- return
1087
-
1088
- elif event.modifiers() == Qt.ControlModifier:
1089
- # Start rubber band selection with Ctrl+Left click
1090
- self.selection_at_press = set(self.selected_widgets)
1091
- self.rubber_band_origin = event.pos()
1092
- self.mouse_pressed_on_widget = False
1093
- child_widget = self.childAt(event.pos())
1094
- if child_widget:
1095
- widget = child_widget
1096
- # Check if click is on a widget to avoid starting rubber band
1097
- while widget and widget != self:
1098
- if hasattr(widget, 'annotation_viewer') and widget.annotation_viewer == self:
1099
- self.mouse_pressed_on_widget = True
1100
- break
1101
- widget = widget.parent()
1102
- return
1103
-
1104
- elif event.button() == Qt.RightButton:
1105
- # Ignore right clicks
1106
- event.ignore()
1107
- return
1108
-
1109
- # Default handler for other cases
1110
- super().mousePressEvent(event)
1111
-
1112
- def mouseDoubleClickEvent(self, event):
1113
- """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."""
1114
1296
  if event.button() == Qt.LeftButton:
1115
- changed_ids = []
1116
1297
  if self.selected_widgets:
1117
1298
  changed_ids = [w.data_item.annotation.id for w in self.selected_widgets]
1118
1299
  self.clear_selection()
@@ -1120,50 +1301,42 @@ class AnnotationViewer(QScrollArea):
1120
1301
  if self.isolated_mode:
1121
1302
  self.show_all_annotations()
1122
1303
  self.reset_view_requested.emit()
1123
- event.accept()
1124
- else:
1125
- super().mouseDoubleClickEvent(event)
1304
+ return True
1305
+ return False
1126
1306
 
1127
- def mouseMoveEvent(self, event):
1128
- """Handle mouse move for DYNAMIC rubber band selection."""
1129
- # 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."""
1130
1309
  if (
1131
1310
  self.rubber_band_origin is None or
1132
1311
  event.buttons() != Qt.LeftButton or
1133
- event.modifiers() != Qt.ControlModifier
1312
+ event.modifiers() != Qt.ControlModifier or
1313
+ self.mouse_pressed_on_widget
1134
1314
  ):
1135
- super().mouseMoveEvent(event)
1136
- return
1137
-
1138
- if self.mouse_pressed_on_widget:
1139
- # If drag started on a widget, do not start rubber band
1140
- super().mouseMoveEvent(event)
1141
- return
1315
+ return False
1142
1316
 
1143
1317
  # Only start selection if drag distance exceeds threshold
1144
1318
  distance = (event.pos() - self.rubber_band_origin).manhattanLength()
1145
1319
  if distance < self.drag_threshold:
1146
- return
1320
+ return True
1147
1321
 
1148
1322
  # Create and show the rubber band if not already present
1149
1323
  if not self.rubber_band:
1150
- self.rubber_band = QRubberBand(QRubberBand.Rectangle, self.viewport())
1324
+ self.rubber_band = QRubberBand(QRubberBand.Rectangle, self.scroll_area.viewport())
1151
1325
 
1152
1326
  rect = QRect(self.rubber_band_origin, event.pos()).normalized()
1153
1327
  self.rubber_band.setGeometry(rect)
1154
1328
  self.rubber_band.show()
1329
+
1155
1330
  selection_rect = self.rubber_band.geometry()
1156
1331
  content_widget = self.content_widget
1157
1332
  changed_ids = []
1158
1333
 
1159
1334
  # Iterate over all annotation widgets to update selection state
1160
1335
  for widget in self.annotation_widgets_by_id.values():
1161
- widget_rect_in_content = widget.geometry()
1162
- # Map widget's rect to viewport coordinates
1163
- widget_rect_in_viewport = QRect(
1164
- content_widget.mapTo(self.viewport(), widget_rect_in_content.topLeft()),
1165
- widget_rect_in_content.size()
1166
- )
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
+
1167
1340
  is_in_band = selection_rect.intersects(widget_rect_in_viewport)
1168
1341
  should_be_selected = (widget in self.selection_at_press) or is_in_band
1169
1342
 
@@ -1180,77 +1353,75 @@ class AnnotationViewer(QScrollArea):
1180
1353
  if changed_ids:
1181
1354
  self.selection_changed.emit(changed_ids)
1182
1355
 
1183
- def mouseReleaseEvent(self, event):
1184
- """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."""
1185
1360
  if self.rubber_band_origin is not None and event.button() == Qt.LeftButton:
1186
1361
  if self.rubber_band and self.rubber_band.isVisible():
1187
1362
  self.rubber_band.hide()
1188
1363
  self.rubber_band.deleteLater()
1189
1364
  self.rubber_band = None
1190
-
1191
- self.selection_at_press = set()
1192
1365
  self.rubber_band_origin = None
1193
- self.mouse_pressed_on_widget = False
1194
- event.accept()
1195
- return
1196
-
1197
- super().mouseReleaseEvent(event)
1366
+ return True
1367
+ return False
1198
1368
 
1199
1369
  def handle_annotation_selection(self, widget, event):
1200
1370
  """Handle selection of annotation widgets with different modes (single, ctrl, shift)."""
1201
- 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]
1202
1378
 
1203
1379
  try:
1204
- 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)
1205
1383
  except ValueError:
1206
1384
  return
1207
1385
 
1208
1386
  modifiers = event.modifiers()
1209
1387
  changed_ids = []
1210
1388
 
1211
- # Shift or Shift+Ctrl: range selection
1212
- if modifiers == Qt.ShiftModifier or modifiers == (Qt.ShiftModifier | Qt.ControlModifier):
1213
- if self.last_selected_index != -1:
1214
- # Find the last selected widget in the current list
1215
- last_selected_widget = None
1216
- for w in self.selected_widgets:
1217
- if w in widget_list:
1218
- try:
1219
- last_index_in_current_list = widget_list.index(w)
1220
- if (
1221
- last_selected_widget is None
1222
- or last_index_in_current_list > widget_list.index(last_selected_widget)
1223
- ):
1224
- last_selected_widget = w
1225
- except ValueError:
1226
- continue
1227
-
1228
- if last_selected_widget:
1229
- last_selected_index_in_current_list = widget_list.index(last_selected_widget)
1230
- start = min(last_selected_index_in_current_list, widget_index)
1231
- end = max(last_selected_index_in_current_list, widget_index)
1232
- else:
1233
- 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)
1234
1405
 
1235
1406
  # Select all widgets in the range
1236
1407
  for i in range(start, end + 1):
1237
- if self.select_widget(widget_list[i]):
1238
- 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)
1239
1412
  else:
1240
1413
  # No previous selection, just select the clicked widget
1241
1414
  if self.select_widget(widget):
1242
1415
  changed_ids.append(widget.data_item.annotation.id)
1243
- self.last_selected_index = widget_index
1416
+
1417
+ self.last_selected_item_id = widget.data_item.annotation.id
1244
1418
 
1245
1419
  # Ctrl: toggle selection of the clicked widget
1246
1420
  elif modifiers == Qt.ControlModifier:
1247
- if widget.is_selected():
1248
- if self.deselect_widget(widget):
1249
- changed_ids.append(widget.data_item.annotation.id)
1250
- else:
1251
- if self.select_widget(widget):
1252
- changed_ids.append(widget.data_item.annotation.id)
1253
- 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
1254
1425
 
1255
1426
  # No modifier: single selection
1256
1427
  else:
@@ -1265,34 +1436,22 @@ class AnnotationViewer(QScrollArea):
1265
1436
  # Select the clicked widget
1266
1437
  if self.select_widget(widget):
1267
1438
  changed_ids.append(newly_selected_id)
1268
- self.last_selected_index = widget_index
1439
+ self.last_selected_item_id = widget.data_item.annotation.id
1269
1440
 
1270
1441
  # If in isolated mode, update which widgets are visible
1271
1442
  if self.isolated_mode:
1272
- self._update_isolation()
1443
+ pass # Do not change the isolated set on internal selection changes
1273
1444
 
1274
1445
  # Emit signal if any selection state changed
1275
1446
  if changed_ids:
1276
1447
  self.selection_changed.emit(changed_ids)
1277
1448
 
1278
- def _update_isolation(self):
1279
- """Update the isolated view to show only currently selected widgets."""
1280
- if not self.isolated_mode:
1281
- return
1282
- # If in isolated mode, only show selected widgets
1283
- if self.selected_widgets:
1284
- self.isolated_widgets.update(self.selected_widgets)
1285
- self.setUpdatesEnabled(False)
1286
- try:
1287
- for widget in self.annotation_widgets_by_id.values():
1288
- if widget not in self.isolated_widgets:
1289
- widget.hide()
1290
- else:
1291
- widget.show()
1292
- self.recalculate_widget_positions()
1293
-
1294
- finally:
1295
- 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)
1296
1455
 
1297
1456
  def select_widget(self, widget):
1298
1457
  """Selects a widget, updates its data_item, and returns True if state changed."""
@@ -1346,11 +1505,6 @@ class AnnotationViewer(QScrollArea):
1346
1505
  # Resync internal list of selected widgets from the source of truth
1347
1506
  self.selected_widgets = [w for w in self.annotation_widgets_by_id.values() if w.is_selected()]
1348
1507
 
1349
- if self.isolated_mode and self.selected_widgets:
1350
- self.isolated_widgets.update(self.selected_widgets)
1351
- for widget in self.annotation_widgets_by_id.values():
1352
- widget.setHidden(widget not in self.isolated_widgets)
1353
- self.recalculate_widget_positions()
1354
1508
  finally:
1355
1509
  self.setUpdatesEnabled(True)
1356
1510
  self._update_toolbar_state()
@@ -1366,7 +1520,7 @@ class AnnotationViewer(QScrollArea):
1366
1520
  changed_ids.append(widget.data_item.annotation.id)
1367
1521
 
1368
1522
  if self.sort_combo.currentText() == "Label":
1369
- self.recalculate_widget_positions()
1523
+ self.recalculate_layout()
1370
1524
  if changed_ids:
1371
1525
  self.preview_changed.emit(changed_ids)
1372
1526
 
@@ -1385,8 +1539,8 @@ class AnnotationViewer(QScrollArea):
1385
1539
 
1386
1540
  if something_changed:
1387
1541
  # Recalculate positions to update sorting and re-flow the layout
1388
- if self.sort_combo.currentText() in ("Label", "Image"):
1389
- self.recalculate_widget_positions()
1542
+ if self.sort_combo.currentText() == "Label":
1543
+ self.recalculate_layout()
1390
1544
 
1391
1545
  def has_preview_changes(self):
1392
1546
  """Return True if there are preview changes."""
@@ -1422,6 +1576,7 @@ class ExplorerWindow(QMainWindow):
1422
1576
 
1423
1577
  self.device = main_window.device
1424
1578
  self.loaded_model = None
1579
+ self.loaded_model_imgsz = 128
1425
1580
 
1426
1581
  self.feature_store = FeatureStore()
1427
1582
 
@@ -1482,7 +1637,7 @@ class ExplorerWindow(QMainWindow):
1482
1637
  if hasattr(self.embedding_viewer, 'animation_timer') and self.embedding_viewer.animation_timer:
1483
1638
  self.embedding_viewer.animation_timer.stop()
1484
1639
 
1485
- # Call the main cancellation method to revert any pending changes
1640
+ # Call the main cancellation method to revert any pending changes and clear selections.
1486
1641
  self.clear_preview_changes()
1487
1642
 
1488
1643
  # Clean up the feature store by deleting its files
@@ -1570,9 +1725,6 @@ class ExplorerWindow(QMainWindow):
1570
1725
  self._initialize_data_item_cache()
1571
1726
  self.annotation_settings_widget.set_default_to_current_image()
1572
1727
  self.refresh_filters()
1573
-
1574
- self.annotation_settings_widget.set_default_to_current_image()
1575
- self.refresh_filters()
1576
1728
 
1577
1729
  try:
1578
1730
  self.label_window.labelSelected.disconnect(self.on_label_selected_for_preview)
@@ -1580,6 +1732,7 @@ class ExplorerWindow(QMainWindow):
1580
1732
  pass
1581
1733
 
1582
1734
  # Connect signals to slots
1735
+ self.annotation_window.annotationModified.connect(self.on_annotation_modified)
1583
1736
  self.label_window.labelSelected.connect(self.on_label_selected_for_preview)
1584
1737
  self.annotation_viewer.selection_changed.connect(self.on_annotation_view_selection_changed)
1585
1738
  self.annotation_viewer.preview_changed.connect(self.on_preview_changed)
@@ -1594,42 +1747,70 @@ class ExplorerWindow(QMainWindow):
1594
1747
  self.annotation_viewer.find_similar_requested.connect(self.find_similar_annotations)
1595
1748
  self.annotation_viewer.similarity_settings_widget.parameters_changed.connect(self.on_similarity_params_changed)
1596
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
+
1597
1772
  @pyqtSlot(list)
1598
1773
  def on_annotation_view_selection_changed(self, changed_ann_ids):
1599
- """Syncs selection from AnnotationViewer to EmbeddingViewer."""
1600
- # 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
1601
1776
  if hasattr(self, 'annotation_window'):
1602
1777
  self.annotation_window.unselect_annotations()
1603
1778
 
1604
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
1605
1782
  if self.embedding_viewer.points_by_id:
1783
+ blocker = QSignalBlocker(self.embedding_viewer)
1606
1784
  self.embedding_viewer.render_selection_from_ids(all_selected_ids)
1607
1785
 
1608
- # 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
1609
1798
  self.update_label_window_selection()
1610
1799
 
1611
1800
  @pyqtSlot(list)
1612
1801
  def on_embedding_view_selection_changed(self, all_selected_ann_ids):
1613
- """Syncs selection from EmbeddingViewer to AnnotationViewer."""
1614
- # Per request, unselect any annotation in the main AnnotationWindow
1615
- if hasattr(self, 'annotation_window'):
1616
- self.annotation_window.unselect_annotations()
1617
-
1618
- # Check the state BEFORE the selection is changed
1619
- was_empty_selection = len(self.annotation_viewer.selected_widgets) == 0
1620
-
1621
- # Now, update the selection in the annotation viewer
1622
- self.annotation_viewer.render_selection_from_ids(set(all_selected_ann_ids))
1623
-
1624
- # The rest of the logic now works correctly
1625
- is_new_selection = len(all_selected_ann_ids) > 0
1626
- if (
1627
- was_empty_selection and
1628
- is_new_selection and
1629
- not self.annotation_viewer.isolated_mode
1630
- ):
1631
- 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()
1632
1812
 
1813
+ # We still need to update the label window based on the selection.
1633
1814
  self.update_label_window_selection()
1634
1815
 
1635
1816
  @pyqtSlot(list)
@@ -1646,6 +1827,32 @@ class ExplorerWindow(QMainWindow):
1646
1827
  widget = self.annotation_viewer.annotation_widgets_by_id.get(ann_id)
1647
1828
  if widget:
1648
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()
1649
1856
 
1650
1857
  @pyqtSlot()
1651
1858
  def on_reset_view_requested(self):
@@ -1775,13 +1982,15 @@ class ExplorerWindow(QMainWindow):
1775
1982
  """
1776
1983
  Identifies annotations whose label does not match the majority of its
1777
1984
  k-nearest neighbors in the high-dimensional feature space.
1985
+ Skips any annotation or neighbor with an invalid label (id == -1).
1778
1986
  """
1779
1987
  # Get parameters from the stored property instead of hardcoding
1780
1988
  K = self.mislabel_params.get('k', 5)
1781
1989
  agreement_threshold = self.mislabel_params.get('threshold', 0.6)
1782
1990
 
1783
1991
  if not self.embedding_viewer.points_by_id or len(self.embedding_viewer.points_by_id) < K:
1784
- QMessageBox.information(self, "Not Enough Data",
1992
+ QMessageBox.information(self,
1993
+ "Not Enough Data",
1785
1994
  f"This feature requires at least {K} points in the embedding viewer.")
1786
1995
  return
1787
1996
 
@@ -1792,7 +2001,6 @@ class ExplorerWindow(QMainWindow):
1792
2001
  model_info = self.model_settings_widget.get_selected_model()
1793
2002
  model_name, feature_mode = model_info if isinstance(model_info, tuple) else (model_info, "default")
1794
2003
  sanitized_model_name = os.path.basename(model_name).replace(' ', '_')
1795
- # FIX: Also replace the forward slash to handle "N/A"
1796
2004
  sanitized_feature_mode = feature_mode.replace(' ', '_').replace('/', '_')
1797
2005
  model_key = f"{sanitized_model_name}_{sanitized_feature_mode}"
1798
2006
 
@@ -1802,13 +2010,17 @@ class ExplorerWindow(QMainWindow):
1802
2010
  index = self.feature_store._get_or_load_index(model_key)
1803
2011
  faiss_idx_to_ann_id = self.feature_store.get_faiss_index_to_annotation_id_map(model_key)
1804
2012
  if index is None or not faiss_idx_to_ann_id:
1805
- QMessageBox.warning(self, "Error", "Could not find a valid feature index for the current model.")
2013
+ QMessageBox.warning(self,
2014
+ "Error",
2015
+ "Could not find a valid feature index for the current model.")
1806
2016
  return
1807
2017
 
1808
2018
  # Get the high-dimensional features for the points in the current view
1809
2019
  features_dict, _ = self.feature_store.get_features(data_items_in_view, model_key)
1810
2020
  if not features_dict:
1811
- QMessageBox.warning(self, "Error", "Could not retrieve features for the items in view.")
2021
+ QMessageBox.warning(self,
2022
+ "Error",
2023
+ "Could not retrieve features for the items in view.")
1812
2024
  return
1813
2025
 
1814
2026
  query_ann_ids = list(features_dict.keys())
@@ -1819,25 +2031,33 @@ class ExplorerWindow(QMainWindow):
1819
2031
 
1820
2032
  mislabeled_ann_ids = []
1821
2033
  for i, ann_id in enumerate(query_ann_ids):
1822
- current_label = self.data_item_cache[ann_id].effective_label.id
1823
-
2034
+ data_item = self.data_item_cache[ann_id]
2035
+ # Use preview_label if present, else effective_label
2036
+ label_obj = getattr(data_item, "preview_label", None) or data_item.effective_label
2037
+ current_label_id = getattr(label_obj, "id", "-1")
2038
+ if current_label_id == "-1":
2039
+ continue # Skip if label is invalid
2040
+
1824
2041
  # Get neighbor labels, ignoring the first result (the point itself)
1825
2042
  neighbor_faiss_indices = I[i][1:]
1826
-
2043
+
1827
2044
  neighbor_labels = []
1828
2045
  for n_idx in neighbor_faiss_indices:
1829
- # THIS IS THE CORRECTED LOGIC
1830
2046
  if n_idx in faiss_idx_to_ann_id:
1831
2047
  neighbor_ann_id = faiss_idx_to_ann_id[n_idx]
1832
- # ADD THIS CHECK to ensure the neighbor hasn't been deleted
1833
2048
  if neighbor_ann_id in self.data_item_cache:
1834
- neighbor_labels.append(self.data_item_cache[neighbor_ann_id].effective_label.id)
2049
+ neighbor_item = self.data_item_cache[neighbor_ann_id]
2050
+ neighbor_label_obj = getattr(neighbor_item, "preview_label", None)
2051
+ if neighbor_label_obj is None:
2052
+ neighbor_label_obj = neighbor_item.effective_label
2053
+ neighbor_label_id = getattr(neighbor_label_obj, "id", "-1")
2054
+ if neighbor_label_id != "-1":
2055
+ neighbor_labels.append(neighbor_label_id)
1835
2056
 
1836
2057
  if not neighbor_labels:
1837
2058
  continue
1838
2059
 
1839
- # Use the agreement threshold instead of strict majority
1840
- num_matching_neighbors = neighbor_labels.count(current_label)
2060
+ num_matching_neighbors = neighbor_labels.count(current_label_id)
1841
2061
  agreement_ratio = num_matching_neighbors / len(neighbor_labels)
1842
2062
 
1843
2063
  if agreement_ratio < agreement_threshold:
@@ -2082,16 +2302,36 @@ class ExplorerWindow(QMainWindow):
2082
2302
  print(f"Model or mode changed. Reloading {model_name} for '{feature_mode}'.")
2083
2303
  try:
2084
2304
  model = YOLO(model_name)
2305
+
2306
+ # Check if the model task is compatible with the selected feature mode
2307
+ if model.task != 'classify' and feature_mode == "Predictions":
2308
+ QMessageBox.warning(self,
2309
+ "Invalid Mode for Model",
2310
+ f"The selected model is a '{model.task}' model. "
2311
+ "The 'Predictions' feature mode is only available for 'classify' models. "
2312
+ "Reverting to 'Embed Features' mode.")
2313
+
2314
+ # Force the feature mode combo box back to "Embed Features"
2315
+ self.model_settings_widget.feature_mode_combo.setCurrentText("Embed Features")
2316
+
2317
+ # On failure, reset the model cache
2318
+ self.loaded_model = None
2319
+ self.current_feature_generating_model = None
2320
+ return None, None
2321
+
2085
2322
  # Update the cache key to the new successful combination
2086
2323
  self.current_feature_generating_model = current_run_key
2087
2324
  self.loaded_model = model
2088
- imgsz = getattr(model.model.args, 'imgsz', 128)
2325
+
2326
+ # Get the imgsz, but if it's larger than 128, default to 128
2327
+ imgsz = min(getattr(model.model.args, 'imgsz', 128), 128)
2328
+ self.loaded_model_imgsz = imgsz
2089
2329
 
2090
2330
  # Warm up the model
2091
2331
  dummy_image = np.zeros((imgsz, imgsz, 3), dtype=np.uint8)
2092
2332
  model.predict(dummy_image, imgsz=imgsz, half=True, device=self.device, verbose=False)
2093
2333
 
2094
- return model, imgsz
2334
+ return model, self.loaded_model_imgsz
2095
2335
 
2096
2336
  except Exception as e:
2097
2337
  print(f"ERROR: Could not load YOLO model '{model_name}': {e}")
@@ -2100,8 +2340,8 @@ class ExplorerWindow(QMainWindow):
2100
2340
  self.current_feature_generating_model = None
2101
2341
  return None, None
2102
2342
 
2103
- # Model already loaded and cached
2104
- return self.loaded_model, getattr(self.loaded_model.model.args, 'imgsz', 128)
2343
+ # Model already loaded and cached, return it and its image size
2344
+ return self.loaded_model, self.loaded_model_imgsz
2105
2345
 
2106
2346
  def _prepare_images_from_data_items(self, data_items, progress_bar=None):
2107
2347
  """
@@ -2587,7 +2827,7 @@ class ExplorerWindow(QMainWindow):
2587
2827
  widget.setParent(None)
2588
2828
  widget.deleteLater()
2589
2829
  blocker.unblock()
2590
- self.annotation_viewer.recalculate_widget_positions()
2830
+ self.annotation_viewer.recalculate_layout()
2591
2831
 
2592
2832
  # 4. Remove from EmbeddingViewer
2593
2833
  blocker = QSignalBlocker(self.embedding_viewer.graphics_scene)
@@ -2615,8 +2855,12 @@ class ExplorerWindow(QMainWindow):
2615
2855
 
2616
2856
  def clear_preview_changes(self):
2617
2857
  """
2618
- 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.
2619
2860
  """
2861
+ # First, clear any active selections from the UI.
2862
+ self._clear_selections()
2863
+
2620
2864
  if hasattr(self, 'annotation_viewer'):
2621
2865
  self.annotation_viewer.clear_preview_states()
2622
2866
 
@@ -2626,7 +2870,7 @@ class ExplorerWindow(QMainWindow):
2626
2870
  for point in self.embedding_viewer.points_by_id.values():
2627
2871
  point.update_tooltip()
2628
2872
 
2629
- # After reverting all changes, update the button states
2873
+ # After reverting all changes, update the button states.
2630
2874
  self.update_button_states()
2631
2875
  print("Cleared all pending changes.")
2632
2876
 
@@ -2670,13 +2914,12 @@ class ExplorerWindow(QMainWindow):
2670
2914
  self.image_window.update_image_annotations(image_path)
2671
2915
  self.annotation_window.load_annotations()
2672
2916
 
2673
- # 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.
2674
2919
  self.annotation_viewer.update_annotations(self.current_data_items)
2675
2920
 
2676
- # Reset selections and button states
2677
- self.embedding_viewer.render_selection_from_ids(set())
2678
- self.update_label_window_selection()
2679
- self.update_button_states()
2921
+ # Explicitly clear selections and update UI states for consistency.
2922
+ self._clear_selections()
2680
2923
 
2681
2924
  print("Applied changes successfully.")
2682
2925