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.
- coralnet_toolbox/Annotations/QtRectangleAnnotation.py +31 -2
- coralnet_toolbox/Explorer/QtDataItem.py +52 -20
- coralnet_toolbox/Explorer/QtExplorer.py +439 -274
- coralnet_toolbox/Explorer/QtFeatureStore.py +15 -0
- coralnet_toolbox/Explorer/QtSettingsWidgets.py +3 -3
- coralnet_toolbox/QtAnnotationWindow.py +10 -2
- coralnet_toolbox/QtMainWindow.py +60 -0
- coralnet_toolbox/Tools/QtResizeSubTool.py +6 -1
- coralnet_toolbox/Tools/QtSelectTool.py +48 -6
- coralnet_toolbox/__init__.py +1 -1
- {coralnet_toolbox-0.0.71.dist-info → coralnet_toolbox-0.0.72.dist-info}/METADATA +1 -1
- {coralnet_toolbox-0.0.71.dist-info → coralnet_toolbox-0.0.72.dist-info}/RECORD +16 -16
- {coralnet_toolbox-0.0.71.dist-info → coralnet_toolbox-0.0.72.dist-info}/WHEEL +0 -0
- {coralnet_toolbox-0.0.71.dist-info → coralnet_toolbox-0.0.72.dist-info}/entry_points.txt +0 -0
- {coralnet_toolbox-0.0.71.dist-info → coralnet_toolbox-0.0.72.dist-info}/licenses/LICENSE.txt +0 -0
- {coralnet_toolbox-0.0.71.dist-info → coralnet_toolbox-0.0.72.dist-info}/top_level.txt +0 -0
@@ -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
|
-
|
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
|
-
|
257
|
-
|
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(
|
637
|
-
"""
|
638
|
-
|
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.
|
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
|
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
|
-
|
672
|
-
self
|
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
|
-
|
748
|
-
|
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
|
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.
|
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.
|
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.
|
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.
|
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
|
907
|
-
"""Get
|
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
|
-
|
911
|
-
|
912
|
-
return
|
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
|
-
|
999
|
+
items = list(self.all_data_items)
|
917
1000
|
|
918
1001
|
if sort_type == "Label":
|
919
|
-
|
1002
|
+
items.sort(key=lambda i: i.effective_label.short_label_code)
|
920
1003
|
elif sort_type == "Image":
|
921
|
-
|
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
|
-
|
925
|
-
|
926
|
-
return
|
1007
|
+
items.sort(key=lambda i: i.get_effective_confidence(), reverse=True)
|
1008
|
+
|
1009
|
+
return items
|
927
1010
|
|
928
|
-
def
|
929
|
-
"""
|
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 [("",
|
933
|
-
|
934
|
-
if self.active_ordered_ids:
|
935
|
-
return [("",
|
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
|
1032
|
+
for item in data_items:
|
941
1033
|
if sort_type == "Label":
|
942
|
-
key =
|
1034
|
+
key = item.effective_label.short_label_code
|
943
1035
|
elif sort_type == "Image":
|
944
|
-
key = os.path.basename(
|
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 = [
|
1043
|
+
current_group = [item]
|
952
1044
|
current_key = key
|
953
1045
|
else:
|
954
|
-
current_group.append(
|
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.
|
1089
|
+
self.recalculate_layout()
|
998
1090
|
|
999
|
-
|
1000
|
-
|
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
|
1006
|
-
"""
|
1007
|
-
if not self.
|
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
|
-
|
1013
|
-
|
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.
|
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
|
-
|
1025
|
-
|
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
|
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
|
-
|
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
|
1056
|
-
|
1057
|
-
|
1058
|
-
|
1059
|
-
|
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.
|
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
|
-
|
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.
|
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
|
1103
|
-
"""
|
1104
|
-
if
|
1105
|
-
if
|
1106
|
-
|
1107
|
-
|
1108
|
-
|
1109
|
-
|
1110
|
-
|
1111
|
-
|
1112
|
-
|
1113
|
-
|
1114
|
-
|
1115
|
-
|
1116
|
-
|
1117
|
-
|
1118
|
-
|
1119
|
-
|
1120
|
-
|
1121
|
-
|
1122
|
-
|
1123
|
-
|
1124
|
-
|
1125
|
-
|
1126
|
-
|
1127
|
-
|
1128
|
-
|
1129
|
-
|
1130
|
-
|
1131
|
-
|
1132
|
-
|
1133
|
-
|
1134
|
-
|
1135
|
-
|
1136
|
-
|
1137
|
-
|
1138
|
-
if
|
1139
|
-
|
1140
|
-
|
1141
|
-
|
1142
|
-
|
1143
|
-
|
1144
|
-
|
1145
|
-
|
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
|
-
|
1168
|
-
|
1169
|
-
super().mouseDoubleClickEvent(event)
|
1304
|
+
return True
|
1305
|
+
return False
|
1170
1306
|
|
1171
|
-
def
|
1172
|
-
"""Handle mouse move for
|
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
|
-
|
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
|
-
|
1206
|
-
|
1207
|
-
widget_rect_in_viewport = QRect(
|
1208
|
-
|
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
|
-
|
1228
|
-
|
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
|
-
|
1238
|
-
|
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
|
-
|
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
|
-
|
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
|
1257
|
-
|
1258
|
-
|
1259
|
-
|
1260
|
-
|
1261
|
-
|
1262
|
-
|
1263
|
-
|
1264
|
-
|
1265
|
-
|
1266
|
-
|
1267
|
-
|
1268
|
-
|
1269
|
-
|
1270
|
-
|
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
|
-
|
1282
|
-
|
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
|
-
|
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
|
-
|
1292
|
-
|
1293
|
-
|
1294
|
-
|
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.
|
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
|
-
|
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
|
1323
|
-
"""
|
1324
|
-
if
|
1325
|
-
return
|
1326
|
-
|
1327
|
-
|
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.
|
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()
|
1433
|
-
self.
|
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
|
1645
|
-
#
|
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
|
-
#
|
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
|
-
|
1660
|
-
|
1661
|
-
|
1662
|
-
|
1663
|
-
|
1664
|
-
|
1665
|
-
|
1666
|
-
|
1667
|
-
|
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.
|
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
|
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
|
-
#
|
2755
|
-
self.
|
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
|
|