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