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