coralnet-toolbox 0.0.72__py2.py3-none-any.whl → 0.0.74__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.
Files changed (57) hide show
  1. coralnet_toolbox/Annotations/QtAnnotation.py +28 -69
  2. coralnet_toolbox/Annotations/QtMaskAnnotation.py +408 -0
  3. coralnet_toolbox/Annotations/QtMultiPolygonAnnotation.py +72 -56
  4. coralnet_toolbox/Annotations/QtPatchAnnotation.py +165 -216
  5. coralnet_toolbox/Annotations/QtPolygonAnnotation.py +497 -353
  6. coralnet_toolbox/Annotations/QtRectangleAnnotation.py +126 -116
  7. coralnet_toolbox/AutoDistill/QtDeployModel.py +23 -12
  8. coralnet_toolbox/CoralNet/QtDownload.py +2 -1
  9. coralnet_toolbox/Explorer/QtDataItem.py +1 -1
  10. coralnet_toolbox/Explorer/QtExplorer.py +159 -17
  11. coralnet_toolbox/Explorer/QtSettingsWidgets.py +160 -86
  12. coralnet_toolbox/IO/QtExportTagLabAnnotations.py +30 -10
  13. coralnet_toolbox/IO/QtImportTagLabAnnotations.py +21 -15
  14. coralnet_toolbox/IO/QtOpenProject.py +46 -78
  15. coralnet_toolbox/IO/QtSaveProject.py +18 -43
  16. coralnet_toolbox/MachineLearning/DeployModel/QtDetect.py +22 -11
  17. coralnet_toolbox/MachineLearning/DeployModel/QtSegment.py +22 -10
  18. coralnet_toolbox/MachineLearning/ExportDataset/QtBase.py +61 -24
  19. coralnet_toolbox/MachineLearning/ExportDataset/QtClassify.py +5 -1
  20. coralnet_toolbox/MachineLearning/ExportDataset/QtDetect.py +19 -6
  21. coralnet_toolbox/MachineLearning/ExportDataset/QtSegment.py +21 -8
  22. coralnet_toolbox/MachineLearning/ImportDataset/QtBase.py +42 -22
  23. coralnet_toolbox/MachineLearning/VideoInference/QtBase.py +0 -4
  24. coralnet_toolbox/QtAnnotationWindow.py +42 -14
  25. coralnet_toolbox/QtEventFilter.py +19 -2
  26. coralnet_toolbox/QtImageWindow.py +134 -86
  27. coralnet_toolbox/QtLabelWindow.py +14 -2
  28. coralnet_toolbox/QtMainWindow.py +122 -9
  29. coralnet_toolbox/QtProgressBar.py +52 -27
  30. coralnet_toolbox/Rasters/QtRaster.py +59 -7
  31. coralnet_toolbox/Rasters/RasterTableModel.py +42 -14
  32. coralnet_toolbox/SAM/QtBatchInference.py +0 -2
  33. coralnet_toolbox/SAM/QtDeployGenerator.py +22 -11
  34. coralnet_toolbox/SAM/QtDeployPredictor.py +10 -0
  35. coralnet_toolbox/SeeAnything/QtBatchInference.py +19 -221
  36. coralnet_toolbox/SeeAnything/QtDeployGenerator.py +1634 -0
  37. coralnet_toolbox/SeeAnything/QtDeployPredictor.py +107 -154
  38. coralnet_toolbox/SeeAnything/QtTrainModel.py +115 -45
  39. coralnet_toolbox/SeeAnything/__init__.py +2 -0
  40. coralnet_toolbox/Tools/QtCutSubTool.py +18 -2
  41. coralnet_toolbox/Tools/QtResizeSubTool.py +19 -2
  42. coralnet_toolbox/Tools/QtSAMTool.py +222 -57
  43. coralnet_toolbox/Tools/QtSeeAnythingTool.py +223 -55
  44. coralnet_toolbox/Tools/QtSelectSubTool.py +6 -4
  45. coralnet_toolbox/Tools/QtSelectTool.py +27 -3
  46. coralnet_toolbox/Tools/QtSubtractSubTool.py +66 -0
  47. coralnet_toolbox/Tools/QtWorkAreaTool.py +25 -13
  48. coralnet_toolbox/Tools/__init__.py +2 -0
  49. coralnet_toolbox/__init__.py +1 -1
  50. coralnet_toolbox/utilities.py +137 -47
  51. coralnet_toolbox-0.0.74.dist-info/METADATA +375 -0
  52. {coralnet_toolbox-0.0.72.dist-info → coralnet_toolbox-0.0.74.dist-info}/RECORD +56 -53
  53. coralnet_toolbox-0.0.72.dist-info/METADATA +0 -341
  54. {coralnet_toolbox-0.0.72.dist-info → coralnet_toolbox-0.0.74.dist-info}/WHEEL +0 -0
  55. {coralnet_toolbox-0.0.72.dist-info → coralnet_toolbox-0.0.74.dist-info}/entry_points.txt +0 -0
  56. {coralnet_toolbox-0.0.72.dist-info → coralnet_toolbox-0.0.74.dist-info}/licenses/LICENSE.txt +0 -0
  57. {coralnet_toolbox-0.0.72.dist-info → coralnet_toolbox-0.0.74.dist-info}/top_level.txt +0 -0
@@ -16,7 +16,7 @@ from PyQt5.QtWidgets import (QVBoxLayout, QHBoxLayout, QGraphicsView, QScrollAre
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,7 @@ 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
31
32
 
32
33
  from coralnet_toolbox.Annotations.QtRectangleAnnotation import RectangleAnnotation
33
34
 
@@ -68,6 +69,8 @@ class EmbeddingViewer(QWidget):
68
69
  mislabel_parameters_changed = pyqtSignal(dict)
69
70
  find_uncertain_requested = pyqtSignal()
70
71
  uncertainty_parameters_changed = pyqtSignal(dict)
72
+ find_duplicates_requested = pyqtSignal()
73
+ duplicate_parameters_changed = pyqtSignal(dict)
71
74
 
72
75
  def __init__(self, parent=None):
73
76
  """Initialize the EmbeddingViewer widget."""
@@ -193,8 +196,37 @@ class EmbeddingViewer(QWidget):
193
196
 
194
197
  uncertainty_settings_widget.parameters_changed.connect(self.uncertainty_parameters_changed.emit)
195
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)
196
228
 
197
- # Add a strech and separator
229
+ # Add a stretch and separator
198
230
  toolbar_layout.addStretch()
199
231
  toolbar_layout.addWidget(self._create_separator())
200
232
 
@@ -293,6 +325,7 @@ class EmbeddingViewer(QWidget):
293
325
 
294
326
  self.find_mislabels_button.setEnabled(points_exist)
295
327
  self.find_uncertain_button.setEnabled(points_exist and self.is_uncertainty_analysis_available)
328
+ self.find_duplicates_button.setEnabled(points_exist)
296
329
  self.center_on_selection_button.setEnabled(points_exist and selection_exists)
297
330
 
298
331
  if self.isolated_mode:
@@ -348,6 +381,7 @@ class EmbeddingViewer(QWidget):
348
381
  self.center_on_selection_button.setEnabled(False) # Disable center button
349
382
  self.find_mislabels_button.setEnabled(False)
350
383
  self.find_uncertain_button.setEnabled(False)
384
+ self.find_duplicates_button.setEnabled(False)
351
385
 
352
386
  self.isolate_button.show()
353
387
  self.isolate_button.setEnabled(False)
@@ -851,7 +885,7 @@ class AnnotationViewer(QWidget):
851
885
 
852
886
  # Show resize handles for Rectangle annotations
853
887
  if isinstance(annotation_to_select, RectangleAnnotation):
854
- explorer.annotation_window.set_selected_tool('select') # Accidently unselects in AnnotationWindow
888
+ explorer.annotation_window.set_selected_tool('select') # Accidentally unselects in AnnotationWindow
855
889
  explorer.annotation_window.select_annotation(annotation_to_select, quiet_mode=True)
856
890
  select_tool = explorer.annotation_window.tools.get('select')
857
891
 
@@ -1584,6 +1618,7 @@ class ExplorerWindow(QMainWindow):
1584
1618
  self.mislabel_params = {'k': 20, 'threshold': 0.6}
1585
1619
  self.uncertainty_params = {'confidence': 0.6, 'margin': 0.1}
1586
1620
  self.similarity_params = {'k': 30}
1621
+ self.duplicate_params = {'threshold': 0.05}
1587
1622
 
1588
1623
  self.data_item_cache = {} # Cache for AnnotationDataItem objects
1589
1624
 
@@ -1670,37 +1705,27 @@ class ExplorerWindow(QMainWindow):
1670
1705
  if child.widget():
1671
1706
  child.widget().setParent(None)
1672
1707
 
1673
- # Lazily initialize the settings and viewer widgets if they haven't been created yet.
1674
- # This ensures that the widgets are only created once per ExplorerWindow instance.
1675
-
1676
- # Annotation settings panel (filters by image, type, label)
1708
+ # Lazily initialize the settings and viewer widgets
1677
1709
  if self.annotation_settings_widget is None:
1678
1710
  self.annotation_settings_widget = AnnotationSettingsWidget(self.main_window, self)
1679
-
1680
- # Model selection panel (choose feature extraction model)
1681
1711
  if self.model_settings_widget is None:
1682
1712
  self.model_settings_widget = ModelSettingsWidget(self.main_window, self)
1683
-
1684
- # Embedding settings panel (choose dimensionality reduction method)
1685
1713
  if self.embedding_settings_widget is None:
1686
1714
  self.embedding_settings_widget = EmbeddingSettingsWidget(self.main_window, self)
1687
-
1688
- # Annotation viewer (shows annotation image crops in a grid)
1689
1715
  if self.annotation_viewer is None:
1690
1716
  self.annotation_viewer = AnnotationViewer(self)
1691
-
1692
- # Embedding viewer (shows 2D embedding scatter plot)
1693
1717
  if self.embedding_viewer is None:
1694
1718
  self.embedding_viewer = EmbeddingViewer(self)
1695
1719
 
1720
+ # Horizontal layout for the three settings panels (original horizontal layout)
1696
1721
  top_layout = QHBoxLayout()
1697
1722
  top_layout.addWidget(self.annotation_settings_widget, 2)
1698
1723
  top_layout.addWidget(self.model_settings_widget, 1)
1699
1724
  top_layout.addWidget(self.embedding_settings_widget, 1)
1700
1725
  top_container = QWidget()
1701
1726
  top_container.setLayout(top_layout)
1702
- self.main_layout.addWidget(top_container)
1703
1727
 
1728
+ # Horizontal splitter for the two main viewer panels
1704
1729
  middle_splitter = QSplitter(Qt.Horizontal)
1705
1730
  annotation_group = QGroupBox("Annotation Viewer")
1706
1731
  annotation_layout = QVBoxLayout(annotation_group)
@@ -1712,7 +1737,19 @@ class ExplorerWindow(QMainWindow):
1712
1737
  embedding_layout.addWidget(self.embedding_viewer)
1713
1738
  middle_splitter.addWidget(embedding_group)
1714
1739
  middle_splitter.setSizes([500, 500])
1715
- self.main_layout.addWidget(middle_splitter, 1)
1740
+
1741
+ # Create a VERTICAL splitter to manage the height between the settings and viewers.
1742
+ # This makes the top settings panel vertically resizable.
1743
+ main_splitter = QSplitter(Qt.Vertical)
1744
+ main_splitter.addWidget(top_container)
1745
+ main_splitter.addWidget(middle_splitter)
1746
+
1747
+ # Set initial heights to give the settings panel a bit more space by default
1748
+ main_splitter.setSizes([250, 750])
1749
+
1750
+ # Add the new main splitter to the layout instead of the individual components
1751
+ self.main_layout.addWidget(main_splitter, 1)
1752
+
1716
1753
  self.main_layout.addWidget(self.label_window)
1717
1754
 
1718
1755
  self.buttons_layout = QHBoxLayout()
@@ -1744,6 +1781,8 @@ class ExplorerWindow(QMainWindow):
1744
1781
  self.model_settings_widget.selection_changed.connect(self.on_model_selection_changed)
1745
1782
  self.embedding_viewer.find_uncertain_requested.connect(self.find_uncertain_annotations)
1746
1783
  self.embedding_viewer.uncertainty_parameters_changed.connect(self.on_uncertainty_params_changed)
1784
+ self.embedding_viewer.find_duplicates_requested.connect(self.find_duplicate_annotations)
1785
+ self.embedding_viewer.duplicate_parameters_changed.connect(self.on_duplicate_params_changed)
1747
1786
  self.annotation_viewer.find_similar_requested.connect(self.find_similar_annotations)
1748
1787
  self.annotation_viewer.similarity_settings_widget.parameters_changed.connect(self.on_similarity_params_changed)
1749
1788
 
@@ -1887,6 +1926,12 @@ class ExplorerWindow(QMainWindow):
1887
1926
  """Updates the stored parameters for uncertainty analysis."""
1888
1927
  self.uncertainty_params = params
1889
1928
  print(f"Uncertainty parameters updated: {self.uncertainty_params}")
1929
+
1930
+ @pyqtSlot(dict)
1931
+ def on_duplicate_params_changed(self, params):
1932
+ """Updates the stored parameters for duplicate detection."""
1933
+ self.duplicate_params = params
1934
+ print(f"Duplicate detection parameters updated: {self.duplicate_params}")
1890
1935
 
1891
1936
  @pyqtSlot(dict)
1892
1937
  def on_similarity_params_changed(self, params):
@@ -2067,6 +2112,98 @@ class ExplorerWindow(QMainWindow):
2067
2112
 
2068
2113
  finally:
2069
2114
  QApplication.restoreOverrideCursor()
2115
+
2116
+ def find_duplicate_annotations(self):
2117
+ """
2118
+ Identifies annotations that are likely duplicates based on feature similarity.
2119
+ It uses a nearest-neighbor approach in the high-dimensional feature space.
2120
+ For each group of duplicates found, it selects all but one "original".
2121
+ """
2122
+ threshold = self.duplicate_params.get('threshold', 0.05)
2123
+
2124
+ if not self.embedding_viewer.points_by_id or len(self.embedding_viewer.points_by_id) < 2:
2125
+ QMessageBox.information(self,
2126
+ "Not Enough Data",
2127
+ "This feature requires at least 2 points in the embedding viewer.")
2128
+ return
2129
+
2130
+ items_in_view = list(self.embedding_viewer.points_by_id.values())
2131
+ data_items_in_view = [p.data_item for p in items_in_view]
2132
+
2133
+ model_info = self.model_settings_widget.get_selected_model()
2134
+ model_name, feature_mode = model_info if isinstance(model_info, tuple) else (model_info, "default")
2135
+ sanitized_model_name = os.path.basename(model_name).replace(' ', '_')
2136
+ sanitized_feature_mode = feature_mode.replace(' ', '_').replace('/', '_')
2137
+ model_key = f"{sanitized_model_name}_{sanitized_feature_mode}"
2138
+
2139
+ # Make cursor busy
2140
+ QApplication.setOverrideCursor(Qt.WaitCursor)
2141
+ try:
2142
+ index = self.feature_store._get_or_load_index(model_key)
2143
+ if index is None:
2144
+ QMessageBox.warning(self, "Error", "Could not find a valid feature index for the current model.")
2145
+ return
2146
+
2147
+ features_dict, _ = self.feature_store.get_features(data_items_in_view, model_key)
2148
+ if not features_dict:
2149
+ QMessageBox.warning(self, "Error", "Could not retrieve features for the items in view.")
2150
+ return
2151
+
2152
+ query_ann_ids = list(features_dict.keys())
2153
+ query_vectors = np.array([features_dict[ann_id] for ann_id in query_ann_ids]).astype('float32')
2154
+
2155
+ # Find the 2 nearest neighbors for each vector. D = squared L2 distances.
2156
+ D, I = index.search(query_vectors, 2)
2157
+
2158
+ # Use a Disjoint Set Union (DSU) data structure to group duplicates.
2159
+ parent = {ann_id: ann_id for ann_id in query_ann_ids}
2160
+
2161
+ # Helper functions for DSU
2162
+ def find_set(v):
2163
+ if v == parent[v]:
2164
+ return v
2165
+ parent[v] = find_set(parent[v])
2166
+ return parent[v]
2167
+
2168
+ def unite_sets(a, b):
2169
+ a = find_set(a)
2170
+ b = find_set(b)
2171
+ if a != b:
2172
+ parent[b] = a
2173
+
2174
+ id_map = self.feature_store.get_faiss_index_to_annotation_id_map(model_key)
2175
+
2176
+ for i, ann_id in enumerate(query_ann_ids):
2177
+ neighbor_faiss_idx = I[i, 1] # The second result is the nearest neighbor
2178
+ distance = D[i, 1]
2179
+
2180
+ if distance < threshold:
2181
+ neighbor_ann_id = id_map.get(neighbor_faiss_idx)
2182
+ if neighbor_ann_id and neighbor_ann_id in parent:
2183
+ unite_sets(ann_id, neighbor_ann_id)
2184
+
2185
+ # Group annotations by their set representative
2186
+ groups = {}
2187
+ for ann_id in query_ann_ids:
2188
+ root = find_set(ann_id)
2189
+ if root not in groups:
2190
+ groups[root] = []
2191
+ groups[root].append(ann_id)
2192
+
2193
+ copies_to_select = set()
2194
+ for root_id, group_ids in groups.items():
2195
+ if len(group_ids) > 1:
2196
+ # Sort IDs to consistently pick the same "original".
2197
+ # Sorting strings is reliable.
2198
+ sorted_ids = sorted(group_ids)
2199
+ # The first ID is the original, add the rest to the selection.
2200
+ copies_to_select.update(sorted_ids[1:])
2201
+
2202
+ print(f"Found {len(copies_to_select)} duplicate annotations.")
2203
+ self.embedding_viewer.render_selection_from_ids(copies_to_select)
2204
+
2205
+ finally:
2206
+ QApplication.restoreOverrideCursor()
2070
2207
 
2071
2208
  def find_uncertain_annotations(self):
2072
2209
  """
@@ -2658,6 +2795,7 @@ class ExplorerWindow(QMainWindow):
2658
2795
  norm_y = (embedded_features[i, 1] - min_vals[1]) / range_vals[1] if range_vals[1] > 0 else 0.5
2659
2796
  item.embedding_x = (norm_x * scale_factor) - (scale_factor / 2)
2660
2797
  item.embedding_y = (norm_y * scale_factor) - (scale_factor / 2)
2798
+ item.embedding_id = i
2661
2799
 
2662
2800
  def run_embedding_pipeline(self):
2663
2801
  """
@@ -2813,6 +2951,10 @@ class ExplorerWindow(QMainWindow):
2813
2951
  self.current_data_items = [
2814
2952
  item for item in self.current_data_items if item.annotation.id not in deleted_ann_ids
2815
2953
  ]
2954
+ # Also update the annotation viewer's list to keep it in sync
2955
+ self.annotation_viewer.all_data_items = [
2956
+ item for item in self.annotation_viewer.all_data_items if item.annotation.id not in deleted_ann_ids
2957
+ ]
2816
2958
  for ann_id in deleted_ann_ids:
2817
2959
  if ann_id in self.data_item_cache:
2818
2960
  del self.data_item_cache[ann_id]
@@ -4,7 +4,7 @@ import warnings
4
4
  from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot
5
5
  from PyQt5.QtWidgets import (QVBoxLayout, QHBoxLayout, QPushButton, QComboBox, QLabel,
6
6
  QWidget, QGroupBox, QSlider, QListWidget, QTabWidget,
7
- QLineEdit, QFileDialog, QFormLayout, QSpinBox)
7
+ QLineEdit, QFileDialog, QFormLayout, QSpinBox, QDoubleSpinBox)
8
8
 
9
9
  from coralnet_toolbox.MachineLearning.Community.cfg import get_available_configs
10
10
 
@@ -189,6 +189,48 @@ class SimilaritySettingsWidget(QWidget):
189
189
  'k': self.k_spinbox.value()
190
190
  }
191
191
 
192
+
193
+ class DuplicateSettingsWidget(QWidget):
194
+ """Widget for configuring duplicate detection parameters."""
195
+ parameters_changed = pyqtSignal(dict)
196
+
197
+ def __init__(self, parent=None):
198
+ super(DuplicateSettingsWidget, self).__init__(parent)
199
+ layout = QVBoxLayout(self)
200
+ layout.setContentsMargins(10, 10, 10, 10)
201
+
202
+ # Using a DoubleSpinBox for the distance threshold
203
+ self.threshold_spinbox = QDoubleSpinBox()
204
+ self.threshold_spinbox.setDecimals(3)
205
+ self.threshold_spinbox.setRange(0.0, 10.0)
206
+ self.threshold_spinbox.setSingleStep(0.01)
207
+ self.threshold_spinbox.setValue(0.1) # Default value for squared L2 distance
208
+ self.threshold_spinbox.setToolTip(
209
+ "Similarity Threshold (Squared L2 Distance).\n"
210
+ "Lower values mean more similar.\n"
211
+ "A value of 0 means identical features."
212
+ )
213
+
214
+ self.threshold_spinbox.valueChanged.connect(self._emit_parameters)
215
+
216
+ form_layout = QHBoxLayout()
217
+ form_layout.addWidget(QLabel("Threshold:"))
218
+ form_layout.addWidget(self.threshold_spinbox)
219
+ layout.addLayout(form_layout)
220
+
221
+ def _emit_parameters(self):
222
+ """Emits the current parameters."""
223
+ params = {
224
+ 'threshold': self.threshold_spinbox.value()
225
+ }
226
+ self.parameters_changed.emit(params)
227
+
228
+ def get_parameters(self):
229
+ """Returns the current parameters as a dictionary."""
230
+ return {
231
+ 'threshold': self.threshold_spinbox.value()
232
+ }
233
+
192
234
 
193
235
  class AnnotationSettingsWidget(QGroupBox):
194
236
  """Widget for filtering annotations by image, type, and label in a multi-column layout."""
@@ -200,7 +242,7 @@ class AnnotationSettingsWidget(QGroupBox):
200
242
  self.setup_ui()
201
243
 
202
244
  def setup_ui(self):
203
- # The main layout is vertical, to hold the top columns, the stretch, and the bottom buttons
245
+ # The main layout is vertical, to hold the top columns and the bottom buttons
204
246
  layout = QVBoxLayout(self)
205
247
 
206
248
  # A horizontal layout to contain the filter columns
@@ -213,8 +255,8 @@ class AnnotationSettingsWidget(QGroupBox):
213
255
  images_column.addWidget(images_label)
214
256
 
215
257
  self.images_list = QListWidget()
216
- self.images_list.setSelectionMode(QListWidget.MultiSelection)
217
- self.images_list.setMaximumHeight(50)
258
+ self.images_list.setSelectionMode(QListWidget.ExtendedSelection)
259
+ self.images_list.setMinimumHeight(100)
218
260
 
219
261
  if hasattr(self.main_window, 'image_window') and hasattr(self.main_window.image_window, 'raster_manager'):
220
262
  for path in self.main_window.image_window.raster_manager.image_paths:
@@ -241,8 +283,8 @@ class AnnotationSettingsWidget(QGroupBox):
241
283
  type_column.addWidget(type_label)
242
284
 
243
285
  self.annotation_type_list = QListWidget()
244
- self.annotation_type_list.setSelectionMode(QListWidget.MultiSelection)
245
- self.annotation_type_list.setMaximumHeight(50)
286
+ self.annotation_type_list.setSelectionMode(QListWidget.ExtendedSelection)
287
+ self.annotation_type_list.setMinimumHeight(100)
246
288
  self.annotation_type_list.addItems(["PatchAnnotation",
247
289
  "RectangleAnnotation",
248
290
  "PolygonAnnotation",
@@ -269,8 +311,8 @@ class AnnotationSettingsWidget(QGroupBox):
269
311
  label_column.addWidget(label_label)
270
312
 
271
313
  self.label_list = QListWidget()
272
- self.label_list.setSelectionMode(QListWidget.MultiSelection)
273
- self.label_list.setMaximumHeight(50)
314
+ self.label_list.setSelectionMode(QListWidget.ExtendedSelection)
315
+ self.label_list.setMinimumHeight(100)
274
316
 
275
317
  if hasattr(self.main_window, 'label_window') and hasattr(self.main_window.label_window, 'labels'):
276
318
  for label in self.main_window.label_window.labels:
@@ -293,9 +335,6 @@ class AnnotationSettingsWidget(QGroupBox):
293
335
  # Add the horizontal layout of columns to the main vertical layout
294
336
  layout.addLayout(conditions_layout)
295
337
 
296
- # Add a stretch item to push the columns to the top
297
- layout.addStretch(1)
298
-
299
338
  # Bottom buttons layout with Apply and Clear buttons on the right
300
339
  bottom_layout = QHBoxLayout()
301
340
  bottom_layout.addStretch() # Push buttons to the right
@@ -308,9 +347,12 @@ class AnnotationSettingsWidget(QGroupBox):
308
347
  self.clear_button.clicked.connect(self.clear_all_conditions)
309
348
  bottom_layout.addWidget(self.clear_button)
310
349
 
311
- # Add the bottom buttons layout to the main layout, keeping it at the bottom
350
+ # Add the bottom buttons layout to the main layout
312
351
  layout.addLayout(bottom_layout)
313
352
 
353
+ # Add a stretch item to push all content to the top
354
+ layout.addStretch(1)
355
+
314
356
  # Set defaults
315
357
  self.set_defaults()
316
358
 
@@ -393,68 +435,66 @@ class AnnotationSettingsWidget(QGroupBox):
393
435
 
394
436
 
395
437
  class ModelSettingsWidget(QGroupBox):
396
- """Widget containing model selection with tabs for different model sources."""
438
+ """Widget containing a structured, hierarchical model selection system."""
397
439
  selection_changed = pyqtSignal()
398
440
 
399
441
  def __init__(self, main_window, parent=None):
400
442
  super(ModelSettingsWidget, self).__init__("Model Settings", parent)
401
443
  self.main_window = main_window
402
444
  self.explorer_window = parent
445
+
446
+ # --- Data for hierarchical selection ---
447
+ self.standard_models_map = {
448
+ 'YOLOv8': {'Nano': 'n', 'Small': 's', 'Medium': 'm', 'Large': 'l', 'X-Large': 'x'},
449
+ 'YOLOv11': {'Nano': 'n', 'Small': 's', 'Medium': 'm', 'Large': 'l', 'X-Large': 'x'},
450
+ 'YOLOv12': {'Nano': 'n', 'Small': 's', 'Medium': 'm', 'Large': 'l', 'X-Large': 'x'}
451
+ }
452
+ self.community_configs = get_available_configs(task='classify')
453
+
403
454
  self.setup_ui()
404
455
 
405
456
  def setup_ui(self):
406
457
  """Set up the UI with a tabbed interface for model selection."""
407
458
  main_layout = QVBoxLayout(self)
408
-
409
- # --- Tabbed Interface for Model Selection ---
410
459
  self.tabs = QTabWidget()
411
460
 
412
- # Tab 1: Select Model
461
+ # === Tab 1: Select Pre-defined Model ===
413
462
  model_select_tab = QWidget()
414
- model_select_layout = QFormLayout(model_select_tab)
415
- model_select_layout.setContentsMargins(5, 10, 5, 5) # Add some top margin
416
-
417
- self.model_combo = QComboBox()
418
- self.model_combo.addItems(["Color Features"])
419
- self.model_combo.insertSeparator(1) # Add a separator
420
-
421
- standard_models = [
422
- 'yolov8n-cls.pt',
423
- 'yolov8s-cls.pt',
424
- 'yolov8m-cls.pt',
425
- 'yolov8l-cls.pt',
426
- 'yolov8x-cls.pt',
427
- 'yolo11n-cls.pt',
428
- 'yolo11s-cls.pt',
429
- 'yolo11m-cls.pt',
430
- 'yolo11l-cls.pt',
431
- 'yolo11x-cls.pt',
432
- 'yolo12n-cls.pt',
433
- 'yolo12s-cls.pt',
434
- 'yolo12m-cls.pt',
435
- 'yolo12l-cls.pt',
436
- 'yolo12x-cls.pt'
437
- ]
438
-
439
- self.model_combo.addItems(standard_models)
440
-
441
- community_configs = get_available_configs(task='classify')
442
- if community_configs:
443
- self.model_combo.insertSeparator(len(standard_models) + 2)
444
- self.model_combo.addItems(list(community_configs.keys()))
445
-
446
- self.model_combo.setCurrentText('Color Features')
447
- model_select_layout.addRow("Model:", self.model_combo)
463
+ self.model_select_layout = QFormLayout(model_select_tab)
464
+ self.model_select_layout.setContentsMargins(5, 10, 5, 5)
465
+
466
+ # 1. Main Category ComboBox
467
+ self.category_combo = QComboBox()
468
+ self.category_combo.addItems(["Color Features", "Standard Model", "Community Model"])
469
+ self.model_select_layout.addRow("Category:", self.category_combo)
470
+
471
+ # 2. Standard Model Options (initially hidden)
472
+ self.family_combo = QComboBox()
473
+ self.family_combo.addItems(self.standard_models_map.keys())
474
+ self.size_combo = QComboBox()
475
+ self.family_combo.currentTextChanged.connect(self._update_size_combo)
476
+ self._update_size_combo(self.family_combo.currentText())
477
+
478
+ self.standard_model_widgets = [QLabel("Family:"), self.family_combo, QLabel("Size:"), self.size_combo]
479
+ self.model_select_layout.addRow(self.standard_model_widgets[0], self.standard_model_widgets[1])
480
+ self.model_select_layout.addRow(self.standard_model_widgets[2], self.standard_model_widgets[3])
481
+
482
+ # 3. Community Model Options (initially hidden)
483
+ self.community_combo = QComboBox()
484
+ if self.community_configs:
485
+ self.community_combo.addItems(list(self.community_configs.keys()))
486
+ self.community_model_widgets = [QLabel("Model:"), self.community_combo]
487
+ self.model_select_layout.addRow(self.community_model_widgets[0], self.community_model_widgets[1])
448
488
 
449
489
  self.tabs.addTab(model_select_tab, "Select Model")
450
490
 
451
- # Tab 2: Existing Model from File
491
+ # === Tab 2: Existing Model from File ===
452
492
  model_existing_tab = QWidget()
453
493
  model_existing_layout = QFormLayout(model_existing_tab)
454
494
  model_existing_layout.setContentsMargins(5, 10, 5, 5)
455
495
 
456
496
  self.model_path_edit = QLineEdit()
457
- self.model_path_edit.setPlaceholderText("Path to a existing .pt model file...")
497
+ self.model_path_edit.setPlaceholderText("Path to a compatible .pt model file...")
458
498
  browse_button = QPushButton("Browse...")
459
499
  browse_button.clicked.connect(self.browse_for_model)
460
500
 
@@ -463,82 +503,116 @@ class ModelSettingsWidget(QGroupBox):
463
503
  path_layout.addWidget(browse_button)
464
504
  model_existing_layout.addRow("Model Path:", path_layout)
465
505
 
506
+ help_label = QLabel("Note: Select an Ultralytics model (.pt).")
507
+ help_label.setStyleSheet("color: gray; font-style: italic;")
508
+ model_existing_layout.addRow("", help_label)
509
+
466
510
  self.tabs.addTab(model_existing_tab, "Use Existing Model")
467
511
 
468
512
  main_layout.addWidget(self.tabs)
469
-
470
- # Connect all relevant widgets to a single slot that emits the new signal
471
- self.model_combo.currentTextChanged.connect(self._on_selection_changed)
472
- self.tabs.currentChanged.connect(self._on_selection_changed)
473
- self.model_path_edit.textChanged.connect(self._on_selection_changed)
474
-
475
- # Add feature extraction mode selection outside of tabs
513
+
514
+ # === Feature Extraction Mode (Reverted to bottom) ===
476
515
  feature_mode_layout = QFormLayout()
477
516
  self.feature_mode_combo = QComboBox()
478
517
  self.feature_mode_combo.addItems(["Predictions", "Embed Features"])
479
- self.feature_mode_combo.currentTextChanged.connect(self._on_selection_changed)
480
518
  feature_mode_layout.addRow("Feature Mode:", self.feature_mode_combo)
481
519
  main_layout.addLayout(feature_mode_layout)
520
+
521
+ # --- Connect Signals ---
522
+ self.category_combo.currentTextChanged.connect(self._on_category_changed)
523
+ self.tabs.currentChanged.connect(self._on_selection_changed)
524
+ for widget in [self.category_combo, self.family_combo, self.size_combo,
525
+ self.community_combo, self.model_path_edit, self.feature_mode_combo]:
526
+ if isinstance(widget, QComboBox):
527
+ widget.currentTextChanged.connect(self._on_selection_changed)
528
+ elif isinstance(widget, QLineEdit):
529
+ widget.textChanged.connect(self._on_selection_changed)
530
+
531
+ # --- Initial State ---
532
+ self._on_category_changed(self.category_combo.currentText())
533
+ self._on_selection_changed()
534
+
535
+ @pyqtSlot(str)
536
+ def _update_size_combo(self, family):
537
+ """Populate the size combo box based on the selected family."""
538
+ self.size_combo.clear()
539
+ if family in self.standard_models_map:
540
+ self.size_combo.addItems(self.standard_models_map[family].keys())
541
+
542
+ @pyqtSlot(str)
543
+ def _on_category_changed(self, category):
544
+ """Show or hide sub-option widgets based on the selected category."""
545
+ is_standard = (category == "Standard Model")
546
+ is_community = (category == "Community Model")
547
+
548
+ for widget in self.standard_model_widgets:
549
+ widget.setVisible(is_standard)
550
+ for widget in self.community_model_widgets:
551
+ widget.setVisible(is_community)
482
552
 
483
- # Initialize the feature mode state and emit the first signal
484
553
  self._on_selection_changed()
485
554
 
486
555
  def browse_for_model(self):
487
556
  """Open a file dialog to browse for model files."""
488
557
  options = QFileDialog.Options()
489
558
  file_path, _ = QFileDialog.getOpenFileName(
490
- self,
491
- "Select Model File",
492
- "",
493
- "PyTorch Models (*.pt);;All Files (*)",
494
- options=options
495
- )
559
+ self, "Select Model File", "", "PyTorch Models (*.pt);;All Files (*)", options=options)
496
560
  if file_path:
497
561
  self.model_path_edit.setText(file_path)
498
562
 
499
563
  @pyqtSlot()
500
564
  def _on_selection_changed(self):
501
- """Central slot to handle any change in model selection and emit a single signal."""
565
+ """Central slot to handle any change and emit a single signal."""
502
566
  self._update_feature_mode_state()
503
567
  self.selection_changed.emit()
504
568
 
505
- def _update_feature_mode_state(self, *args):
506
- """Update the enabled state of the feature mode field based on the current model selection."""
507
- current_tab_index = self.tabs.currentIndex()
569
+ def _update_feature_mode_state(self):
570
+ """Update the enabled state and tooltip of the feature mode field."""
508
571
  is_color_features = False
572
+ current_tab_index = self.tabs.currentIndex()
509
573
 
510
574
  if current_tab_index == 0:
511
- # Select Model tab - check if Color Features is selected
512
- current_model = self.model_combo.currentText()
513
- is_color_features = current_model == "Color Features"
514
- elif current_tab_index == 1:
515
- # Use Existing Model tab - feature mode should always be enabled
516
- is_color_features = False
575
+ is_color_features = (self.category_combo.currentText() == "Color Features")
517
576
 
518
- # Enable feature mode only if not Color Features
519
577
  self.feature_mode_combo.setEnabled(not is_color_features)
520
578
 
521
- # Update the tooltip based on state
522
579
  if is_color_features:
523
- self.feature_mode_combo.setToolTip("Feature Mode is not available for Color Features")
580
+ self.feature_mode_combo.setToolTip("Feature Mode is not applicable for Color Features.")
524
581
  else:
525
- self.feature_mode_combo.setToolTip("Select the feature extraction mode")
582
+ self.feature_mode_combo.setToolTip(
583
+ "Choose 'Predictions' for class probabilities (for uncertainty analysis)\n"
584
+ "or 'Embed Features' for a general-purpose feature vector."
585
+ )
526
586
 
527
587
  def get_selected_model(self):
528
588
  """Get the currently selected model name/path and feature mode."""
529
589
  current_tab_index = self.tabs.currentIndex()
590
+ model_name = ""
530
591
 
531
- # Get model name/path and feature mode based on the active tab
532
592
  if current_tab_index == 0:
533
- model_name = self.model_combo.currentText()
593
+ category = self.category_combo.currentText()
594
+ if category == "Color Features":
595
+ model_name = "Color Features"
596
+ elif category == "Standard Model":
597
+ family_text = self.family_combo.currentText()
598
+ size_text = self.size_combo.currentText()
599
+
600
+ # Add a guard clause to prevent crashing if a combo is empty.
601
+ if not family_text or not size_text:
602
+ return "", "N/A" # Return a safe default
603
+
604
+ family_key = family_text.lower().replace('-', '')
605
+ size_key = self.standard_models_map[family_text][size_text]
606
+ model_name = f"{family_key}{size_key}-cls.pt"
607
+
608
+ elif category == "Community Model":
609
+ model_name = self.community_combo.currentText()
534
610
  elif current_tab_index == 1:
535
611
  model_name = self.model_path_edit.text()
536
- else:
537
- return "", None
538
612
 
539
613
  feature_mode = self.feature_mode_combo.currentText() if self.feature_mode_combo.isEnabled() else "N/A"
540
614
  return model_name, feature_mode
541
-
615
+
542
616
 
543
617
  class EmbeddingSettingsWidget(QGroupBox):
544
618
  """Widget containing settings with tabs for models and embedding."""