coralnet-toolbox 0.0.75__py2.py3-none-any.whl → 0.0.76__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 (37) hide show
  1. coralnet_toolbox/Annotations/QtPolygonAnnotation.py +57 -12
  2. coralnet_toolbox/Annotations/QtRectangleAnnotation.py +44 -14
  3. coralnet_toolbox/Explorer/transformer_models.py +13 -2
  4. coralnet_toolbox/IO/QtExportMaskAnnotations.py +538 -403
  5. coralnet_toolbox/Icons/system_monitor.png +0 -0
  6. coralnet_toolbox/QtEventFilter.py +4 -4
  7. coralnet_toolbox/QtMainWindow.py +104 -64
  8. coralnet_toolbox/QtProgressBar.py +1 -0
  9. coralnet_toolbox/QtSystemMonitor.py +370 -0
  10. coralnet_toolbox/Results/ConvertResults.py +14 -8
  11. coralnet_toolbox/Results/ResultsProcessor.py +3 -2
  12. coralnet_toolbox/SAM/QtDeployGenerator.py +1 -1
  13. coralnet_toolbox/SAM/QtDeployPredictor.py +10 -0
  14. coralnet_toolbox/SeeAnything/QtDeployGenerator.py +15 -10
  15. coralnet_toolbox/SeeAnything/QtDeployPredictor.py +10 -6
  16. coralnet_toolbox/Tile/QtTileBatchInference.py +4 -4
  17. coralnet_toolbox/Tools/QtSAMTool.py +140 -91
  18. coralnet_toolbox/Transformers/Models/GroundingDINO.py +72 -0
  19. coralnet_toolbox/Transformers/Models/OWLViT.py +72 -0
  20. coralnet_toolbox/Transformers/Models/OmDetTurbo.py +68 -0
  21. coralnet_toolbox/Transformers/Models/QtBase.py +120 -0
  22. coralnet_toolbox/{AutoDistill → Transformers}/Models/__init__.py +1 -1
  23. coralnet_toolbox/{AutoDistill → Transformers}/QtBatchInference.py +15 -15
  24. coralnet_toolbox/{AutoDistill → Transformers}/QtDeployModel.py +18 -16
  25. coralnet_toolbox/{AutoDistill → Transformers}/__init__.py +1 -1
  26. coralnet_toolbox/__init__.py +1 -1
  27. coralnet_toolbox/utilities.py +0 -15
  28. {coralnet_toolbox-0.0.75.dist-info → coralnet_toolbox-0.0.76.dist-info}/METADATA +9 -9
  29. {coralnet_toolbox-0.0.75.dist-info → coralnet_toolbox-0.0.76.dist-info}/RECORD +33 -31
  30. coralnet_toolbox/AutoDistill/Models/GroundingDINO.py +0 -81
  31. coralnet_toolbox/AutoDistill/Models/OWLViT.py +0 -76
  32. coralnet_toolbox/AutoDistill/Models/OmDetTurbo.py +0 -75
  33. coralnet_toolbox/AutoDistill/Models/QtBase.py +0 -112
  34. {coralnet_toolbox-0.0.75.dist-info → coralnet_toolbox-0.0.76.dist-info}/WHEEL +0 -0
  35. {coralnet_toolbox-0.0.75.dist-info → coralnet_toolbox-0.0.76.dist-info}/entry_points.txt +0 -0
  36. {coralnet_toolbox-0.0.75.dist-info → coralnet_toolbox-0.0.76.dist-info}/licenses/LICENSE.txt +0 -0
  37. {coralnet_toolbox-0.0.75.dist-info → coralnet_toolbox-0.0.76.dist-info}/top_level.txt +0 -0
@@ -1,20 +1,19 @@
1
1
  import warnings
2
- warnings.filterwarnings("ignore", category=DeprecationWarning)
3
-
4
2
  import os
5
3
  import ujson as json
6
4
 
7
5
  import cv2
8
6
  import numpy as np
9
7
  import rasterio
10
- from PIL import Image
8
+ from PIL import Image, ImageColor
11
9
 
12
- from PyQt5.QtCore import Qt
10
+ from PyQt5.QtCore import Qt, pyqtSignal
11
+ from PyQt5.QtGui import QColor, QPainter, QPen
13
12
  from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
14
13
  QCheckBox, QComboBox, QLineEdit, QPushButton, QFileDialog,
15
- QApplication, QMessageBox, QLabel, QProgressDialog,
16
- QGroupBox, QListWidget, QAbstractItemView, QListWidgetItem,
17
- QButtonGroup, QScrollArea, QWidget)
14
+ QApplication, QMessageBox, QLabel, QTableWidgetItem,
15
+ QButtonGroup, QWidget, QTableWidget, QHeaderView,
16
+ QAbstractItemView, QSpinBox, QRadioButton, QColorDialog)
18
17
 
19
18
  from coralnet_toolbox.Annotations.QtPatchAnnotation import PatchAnnotation
20
19
  from coralnet_toolbox.Annotations.QtPolygonAnnotation import PolygonAnnotation
@@ -22,12 +21,56 @@ from coralnet_toolbox.Annotations.QtRectangleAnnotation import RectangleAnnotati
22
21
  from coralnet_toolbox.Annotations.QtMultiPolygonAnnotation import MultiPolygonAnnotation
23
22
 
24
23
  from coralnet_toolbox.QtProgressBar import ProgressBar
25
-
26
24
  from coralnet_toolbox.Icons import get_icon
27
25
 
26
+ warnings.filterwarnings("ignore", category=DeprecationWarning)
27
+
28
28
 
29
29
  # ----------------------------------------------------------------------------------------------------------------------
30
- # Classes
30
+ # Helper Classes
31
+ # ----------------------------------------------------------------------------------------------------------------------
32
+
33
+
34
+ class ColorSwatchWidget(QWidget):
35
+ """A simple widget to display a color swatch with a border."""
36
+ def __init__(self, color, parent=None):
37
+ super().__init__(parent)
38
+ self.color = color
39
+ self.setFixedSize(24, 24)
40
+
41
+ def paintEvent(self, event):
42
+ painter = QPainter(self)
43
+ painter.setRenderHint(QPainter.Antialiasing)
44
+
45
+ # Set the brush for the fill color
46
+ painter.setBrush(self.color)
47
+
48
+ # Set the pen for the black border
49
+ pen = QPen(QColor("black"))
50
+ pen.setWidth(1)
51
+ painter.setPen(pen)
52
+
53
+ # Draw the rectangle, adjusted inward so the border is fully visible
54
+ painter.drawRect(self.rect().adjusted(0, 0, -1, -1))
55
+
56
+ def setColor(self, color):
57
+ """Update the swatch's color and repaint."""
58
+ self.color = color
59
+ self.update() # Triggers a repaint
60
+
61
+
62
+ class ClickableColorSwatchWidget(ColorSwatchWidget):
63
+ """A ColorSwatchWidget that emits a clicked signal."""
64
+ clicked = pyqtSignal()
65
+
66
+ def mousePressEvent(self, event):
67
+ if event.button() == Qt.LeftButton:
68
+ self.clicked.emit()
69
+ super().mousePressEvent(event)
70
+
71
+
72
+ # ----------------------------------------------------------------------------------------------------------------------
73
+ # Main Dialog Class
31
74
  # ----------------------------------------------------------------------------------------------------------------------
32
75
 
33
76
 
@@ -40,60 +83,70 @@ class ExportMaskAnnotations(QDialog):
40
83
  self.annotation_window = main_window.annotation_window
41
84
 
42
85
  self.setWindowIcon(get_icon("coral.png"))
43
- self.setWindowTitle("Export Segmentation Masks")
44
- self.resize(500, 250)
86
+ self.setWindowTitle("Export Annotations to Masks")
87
+ self.resize(1000, 800)
45
88
 
46
- self.selected_labels = []
47
- self.annotation_types = []
48
- self.class_mapping = {}
49
-
50
- # Create the layout
51
- self.layout = QVBoxLayout(self)
52
-
53
- # Setup the information layout
54
- self.setup_info_layout()
55
- # Setup the output directory and file format layout
56
- self.setup_output_layout()
57
- # Setup image selection layout
58
- self.setup_image_selection_layout()
59
- # Setup the annotation layout
60
- self.setup_annotation_layout()
61
- # Setup the label layout
62
- self.setup_label_layout()
63
- # Setup the mask format layout
64
- self.setup_mask_format_layout()
65
- # Setup the buttons layout
66
- self.setup_buttons_layout()
89
+ self.mask_mode = 'semantic' # 'semantic', 'sfm', or 'rgb'
90
+ self.rgb_background_color = QColor(0, 0, 0)
91
+
92
+ # Main layout for the dialog
93
+ self.main_layout = QVBoxLayout(self)
94
+
95
+ # Top section
96
+ top_section = QVBoxLayout()
97
+ self.setup_info_layout(parent_layout=top_section)
98
+ self.setup_output_layout(parent_layout=top_section)
99
+ self.setup_mask_format_layout(parent_layout=top_section)
100
+ self.main_layout.addLayout(top_section)
101
+
102
+ # Middle section
103
+ columns_layout = QHBoxLayout()
104
+ left_col = QVBoxLayout()
105
+ right_col = QVBoxLayout()
106
+
107
+ self.setup_annotation_layout(parent_layout=left_col)
108
+ self.setup_image_selection_layout(parent_layout=left_col)
109
+ self.setup_label_layout(parent_layout=right_col)
110
+
111
+ columns_layout.addLayout(left_col, 1)
112
+ columns_layout.addLayout(right_col, 2)
113
+ self.main_layout.addLayout(columns_layout)
114
+
115
+ # Bottom buttons
116
+ self.setup_buttons_layout(parent_layout=self.main_layout)
117
+
118
+ # Set initial mode and update UI
119
+ self.semantic_radio.setChecked(True)
120
+ self.update_ui_for_mode()
67
121
 
68
122
  def showEvent(self, event):
69
- """Handle the show event"""
70
123
  super().showEvent(event)
71
- # Update the labels in the label selection list
72
- self.update_label_selection_list()
124
+ self.update_ui_for_mode()
73
125
 
74
- def setup_info_layout(self):
75
- """
76
- Set up the layout and widgets for the info layout.
77
- """
126
+ def setup_info_layout(self, parent_layout=None):
78
127
  group_box = QGroupBox("Information")
79
128
  layout = QVBoxLayout()
80
-
81
- # Create a QLabel with explanatory text and hyperlink
82
- info_label = QLabel("Export Annotations to Segmentation Masks")
83
-
84
- info_label.setOpenExternalLinks(True)
129
+ info_text = (
130
+ "This tool exports annotations to image masks for three primary use cases:<br><br>"
131
+ "<b>1. Semantic Segmentation (Integer IDs):</b> Creates masks where each class (e.g., coral, rock) "
132
+ "is represented by a unique integer value (1, 2, 3...). The background is typically 0. These are used "
133
+ "to train machine learning models.<br><br>"
134
+ "<b>2. Structure from Motion (SfM) (Binary Mask):</b> Creates masks where a foreground value "
135
+ "(e.g., 255) represents objects to keep, and a background value (e.g., 0) represents areas to "
136
+ "ignore. This is used by software like Metashape to improve 3D model reconstruction.<br><br>"
137
+ "<b>3. Visualization (RGB Colors):</b> Creates a human-readable color mask using the colors "
138
+ "assigned to each label. Ideal for reports, presentations, and qualitative analysis."
139
+ )
140
+ info_label = QLabel(info_text)
85
141
  info_label.setWordWrap(True)
86
142
  layout.addWidget(info_label)
87
-
88
143
  group_box.setLayout(layout)
89
- self.layout.addWidget(group_box)
144
+ parent_layout.addWidget(group_box)
90
145
 
91
- def setup_output_layout(self):
92
- """Setup the output directory and file format layout."""
146
+ def setup_output_layout(self, parent_layout=None):
93
147
  groupbox = QGroupBox("Output Directory and File Format")
94
148
  layout = QFormLayout()
95
149
 
96
- # Output directory selection
97
150
  output_dir_layout = QHBoxLayout()
98
151
  self.output_dir_edit = QLineEdit()
99
152
  self.output_dir_button = QPushButton("Browse...")
@@ -102,18 +155,58 @@ class ExportMaskAnnotations(QDialog):
102
155
  output_dir_layout.addWidget(self.output_dir_button)
103
156
  layout.addRow("Output Directory:", output_dir_layout)
104
157
 
105
- # Output folder name
106
- self.output_name_edit = QLineEdit("")
158
+ self.output_name_edit = QLineEdit("masks")
107
159
  layout.addRow("Folder Name:", self.output_name_edit)
108
160
 
109
161
  groupbox.setLayout(layout)
110
- self.layout.addWidget(groupbox)
162
+ parent_layout.addWidget(groupbox)
163
+
164
+ def setup_mask_format_layout(self, parent_layout=None):
165
+ groupbox = QGroupBox("Export Mode and Format")
166
+ main_layout = QVBoxLayout()
167
+
168
+ # Mode Selection
169
+ mode_layout = QHBoxLayout()
170
+ self.semantic_radio = QRadioButton("Semantic Segmentation (Integer IDs)")
171
+ self.sfm_radio = QRadioButton("Structure from Motion (Binary Mask)")
172
+ self.rgb_radio = QRadioButton("Visualization (RGB Colors)")
173
+
174
+ self.mode_group = QButtonGroup(self)
175
+ self.mode_group.addButton(self.semantic_radio)
176
+ self.mode_group.addButton(self.sfm_radio)
177
+ self.mode_group.addButton(self.rgb_radio)
178
+ self.mode_group.buttonClicked.connect(self.update_ui_for_mode)
179
+
180
+ mode_layout.addWidget(self.semantic_radio)
181
+ mode_layout.addWidget(self.sfm_radio)
182
+ mode_layout.addWidget(self.rgb_radio)
183
+ main_layout.addLayout(mode_layout)
184
+
185
+ # Format and Options
186
+ options_layout = QFormLayout()
187
+
188
+ # General file format
189
+ self.file_format_combo = QComboBox()
190
+ self.file_format_combo.addItems([".png", ".bmp", ".tif"])
191
+ self.file_format_combo.currentTextChanged.connect(self.update_georef_availability)
192
+ options_layout.addRow("File Format:", self.file_format_combo)
111
193
 
112
- def setup_image_selection_layout(self):
113
- """Setup the image selection layout."""
194
+ # Georeferencing
195
+ self.preserve_georef_checkbox = QCheckBox("Preserve georeferencing (if available)")
196
+ self.preserve_georef_checkbox.setChecked(True)
197
+ options_layout.addRow(self.preserve_georef_checkbox)
198
+ self.georef_note = QLabel("Note: Georeferencing is only supported for TIF format.")
199
+ self.georef_note.setStyleSheet("color: #666; font-style: italic;")
200
+ options_layout.addRow(self.georef_note)
201
+
202
+ main_layout.addLayout(options_layout)
203
+ groupbox.setLayout(main_layout)
204
+ parent_layout.addWidget(groupbox)
205
+ self.update_georef_availability()
206
+
207
+ def setup_image_selection_layout(self, parent_layout=None):
114
208
  group_box = QGroupBox("Apply To")
115
209
  layout = QVBoxLayout()
116
-
117
210
  self.apply_filtered_checkbox = QCheckBox("▼ Apply to filtered images")
118
211
  self.apply_prev_checkbox = QCheckBox("↑ Apply to previous images")
119
212
  self.apply_next_checkbox = QCheckBox("↓ Apply to next images")
@@ -130,24 +223,18 @@ class ExportMaskAnnotations(QDialog):
130
223
  self.apply_group.addButton(self.apply_next_checkbox)
131
224
  self.apply_group.addButton(self.apply_all_checkbox)
132
225
  self.apply_group.setExclusive(True)
133
-
134
226
  group_box.setLayout(layout)
135
- self.layout.addWidget(group_box)
227
+ parent_layout.addWidget(group_box)
136
228
 
137
- def setup_annotation_layout(self):
138
- """Setup the annotation types layout."""
229
+ def setup_annotation_layout(self, parent_layout=None):
139
230
  groupbox = QGroupBox("Annotations to Include")
140
231
  layout = QVBoxLayout()
141
-
142
- # Annotation types checkboxes
143
232
  self.patch_checkbox = QCheckBox("Patch Annotations")
144
233
  self.patch_checkbox.setChecked(True)
145
234
  self.rectangle_checkbox = QCheckBox("Rectangle Annotations")
146
235
  self.rectangle_checkbox.setChecked(True)
147
236
  self.polygon_checkbox = QCheckBox("Polygon Annotations")
148
237
  self.polygon_checkbox.setChecked(True)
149
-
150
- # Include negative samples
151
238
  self.include_negative_samples_checkbox = QCheckBox("Include negative samples")
152
239
  self.include_negative_samples_checkbox.setChecked(True)
153
240
 
@@ -155,438 +242,486 @@ class ExportMaskAnnotations(QDialog):
155
242
  layout.addWidget(self.rectangle_checkbox)
156
243
  layout.addWidget(self.polygon_checkbox)
157
244
  layout.addWidget(self.include_negative_samples_checkbox)
158
-
159
245
  groupbox.setLayout(layout)
160
- self.layout.addWidget(groupbox)
246
+ parent_layout.addWidget(groupbox)
161
247
 
162
- def setup_label_layout(self):
163
- """Setup the label selection layout."""
164
- groupbox = QGroupBox("Labels to Include")
248
+ def setup_label_layout(self, parent_layout=None):
249
+ groupbox = QGroupBox("Labels to Include / Rasterization Order")
165
250
  layout = QVBoxLayout()
166
-
167
- # Label selection
168
- self.label_selection_label = QLabel("Select Labels:")
169
- layout.addWidget(self.label_selection_label)
170
-
171
- # Create a scroll area for the labels
172
- scroll_area = QScrollArea()
173
- scroll_area.setWidgetResizable(True)
174
-
175
- # Create a widget to hold the checkboxes
176
- self.label_container = QWidget()
177
- self.label_container.setMinimumHeight(200) # Set a minimum height for the container
178
- self.label_layout = QVBoxLayout(self.label_container)
179
- self.label_layout.setSizeConstraint(QVBoxLayout.SetMinAndMaxSize) # Respect widget sizes
180
-
181
- scroll_area.setWidget(self.label_container)
182
- layout.addWidget(scroll_area)
183
-
184
- # Store the checkbox references
185
- self.label_checkboxes = []
186
-
187
- groupbox.setLayout(layout)
188
- self.layout.addWidget(groupbox)
189
-
190
- def setup_mask_format_layout(self):
191
- """Setup the mask format layout."""
192
- groupbox = QGroupBox("Mask Format")
193
- layout = QFormLayout()
194
-
195
- # File format combo box
196
- self.file_format_combo = QComboBox()
197
- self.file_format_combo.addItems([".png", ".bmp", ".tif"])
198
- self.file_format_combo.setEditable(True)
199
- layout.addRow("File Format:", self.file_format_combo)
200
-
201
- # Add option to preserve georeferencing
202
- self.preserve_georef_checkbox = QCheckBox("Preserve georeferencing (if available)")
203
- self.preserve_georef_checkbox.setChecked(True)
204
- layout.addRow("", self.preserve_georef_checkbox)
205
-
206
- # Connect the checkbox to update based on file format
207
- self.file_format_combo.currentTextChanged.connect(self.update_georef_availability)
208
-
209
- # Add a note about georeferencing
210
- self.georef_note = QLabel("Note: Georeferencing can only be preserved with TIF format")
211
- self.georef_note.setStyleSheet("color: #666; font-style: italic;")
212
- layout.addRow("", self.georef_note)
213
-
251
+ self.label_table = QTableWidget()
252
+ self.label_table.setColumnCount(3)
253
+ self.label_table.setSelectionBehavior(QAbstractItemView.SelectRows)
254
+ self.label_table.setSelectionMode(QAbstractItemView.SingleSelection)
255
+ header = self.label_table.horizontalHeader()
256
+ header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
257
+ header.setSectionResizeMode(1, QHeaderView.Stretch)
258
+ header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
259
+ layout.addWidget(self.label_table)
260
+
261
+ button_layout = QHBoxLayout()
262
+ button_layout.addStretch(1)
263
+ self.move_up_button = QPushButton("▲ Move Up")
264
+ self.move_down_button = QPushButton("▼ Move Down")
265
+ self.move_up_button.clicked.connect(self.move_row_up)
266
+ self.move_down_button.clicked.connect(self.move_row_down)
267
+ button_layout.addWidget(self.move_up_button)
268
+ button_layout.addWidget(self.move_down_button)
269
+ button_layout.addStretch(1)
270
+ layout.addLayout(button_layout)
271
+
272
+ order_note = QLabel(
273
+ "<b>Layer Order is Important:</b> Labels lower in the list will be drawn on top of labels "
274
+ "higher in the list. For overlapping annotations, only the topmost class will appear."
275
+ )
276
+ order_note.setStyleSheet("color: #666;")
277
+ order_note.setWordWrap(True)
278
+ layout.addWidget(order_note)
279
+
214
280
  groupbox.setLayout(layout)
215
- self.layout.addWidget(groupbox)
281
+ parent_layout.addWidget(groupbox)
216
282
 
217
- # Initial update based on default format
218
- self.update_georef_availability()
219
-
220
- def setup_buttons_layout(self):
221
- """Setup the buttons layout."""
283
+ def setup_buttons_layout(self, parent_layout=None):
222
284
  button_layout = QHBoxLayout()
223
-
285
+ button_layout.addStretch(1)
224
286
  self.export_button = QPushButton("Export")
225
- self.export_button.clicked.connect(self.export_masks)
287
+ self.export_button.clicked.connect(self.run_export_process)
226
288
  self.cancel_button = QPushButton("Cancel")
227
289
  self.cancel_button.clicked.connect(self.reject)
228
-
229
290
  button_layout.addWidget(self.export_button)
230
291
  button_layout.addWidget(self.cancel_button)
231
-
232
- self.layout.addLayout(button_layout)
233
-
292
+ parent_layout.addLayout(button_layout)
293
+
294
+ def update_ui_for_mode(self):
295
+ """Update the UI dynamically based on the selected export mode."""
296
+ if self.semantic_radio.isChecked():
297
+ self.mask_mode = 'semantic'
298
+ elif self.sfm_radio.isChecked():
299
+ self.mask_mode = 'sfm'
300
+ elif self.rgb_radio.isChecked():
301
+ self.mask_mode = 'rgb'
302
+
303
+ self.populate_label_table()
304
+
305
+ def populate_label_table(self):
306
+ """Populate the label table based on the current mode."""
307
+ self.label_table.blockSignals(True)
308
+ self.label_table.setRowCount(0)
309
+
310
+ # Set table headers based on mode
311
+ headers = ["Include", "Label Name"]
312
+ if self.mask_mode in ['semantic', 'sfm']:
313
+ headers.append("Mask Value")
314
+ elif self.mask_mode == 'rgb':
315
+ headers.append("Color Preview")
316
+ self.label_table.setHorizontalHeaderLabels(headers)
317
+
318
+ # --- BACKGROUND ROW (ROW 0) ---
319
+ self.label_table.insertRow(0)
320
+ checkbox_widget = self.create_centered_checkbox(checked=True)
321
+ self.label_table.setCellWidget(0, 0, checkbox_widget)
322
+
323
+ label_item = QTableWidgetItem("Background")
324
+ label_item.setFlags(label_item.flags() & ~Qt.ItemIsEditable)
325
+ label_item.setData(Qt.UserRole, "Background")
326
+ self.label_table.setItem(0, 1, label_item)
327
+
328
+ if self.mask_mode in ['semantic', 'sfm']:
329
+ spinbox = QSpinBox()
330
+ spinbox.setRange(0, 255)
331
+ spinbox.setValue(0)
332
+ self.label_table.setCellWidget(0, 2, spinbox)
333
+ elif self.mask_mode == 'rgb':
334
+ swatch = ClickableColorSwatchWidget(self.rgb_background_color)
335
+ swatch.clicked.connect(self.pick_background_color)
336
+ # Create a container widget to center the swatch
337
+ container_widget = QWidget()
338
+ layout = QHBoxLayout(container_widget)
339
+ layout.addWidget(swatch)
340
+ layout.setAlignment(Qt.AlignCenter)
341
+ layout.setContentsMargins(0, 0, 0, 0)
342
+ self.label_table.setCellWidget(0, 2, container_widget)
343
+
344
+ # --- LABEL ROWS ---
345
+ for i, label in enumerate(self.label_window.labels):
346
+ row = i + 1
347
+ self.label_table.insertRow(row)
348
+
349
+ # Column 0: Include Checkbox
350
+ checkbox_widget = self.create_centered_checkbox(checked=True)
351
+ self.label_table.setCellWidget(row, 0, checkbox_widget)
352
+
353
+ # Column 1: Label Name
354
+ label_item = QTableWidgetItem(label.short_label_code)
355
+ label_item.setFlags(label_item.flags() & ~Qt.ItemIsEditable)
356
+ label_item.setData(Qt.UserRole, label.short_label_code)
357
+ self.label_table.setItem(row, 1, label_item)
358
+
359
+ # Column 2: Mode-dependent widget
360
+ if self.mask_mode == 'semantic':
361
+ spinbox = QSpinBox()
362
+ spinbox.setRange(0, 255)
363
+ spinbox.setValue(i + 1)
364
+ self.label_table.setCellWidget(row, 2, spinbox)
365
+ elif self.mask_mode == 'sfm':
366
+ spinbox = QSpinBox()
367
+ spinbox.setRange(0, 255)
368
+ spinbox.setValue(255) # Default foreground value
369
+ self.label_table.setCellWidget(row, 2, spinbox)
370
+ elif self.mask_mode == 'rgb':
371
+ try:
372
+ q_color = QColor(label.color)
373
+ except Exception:
374
+ q_color = QColor("#FFFFFF") # Default to white on error
375
+
376
+ swatch = ColorSwatchWidget(q_color)
377
+ cell_widget = QWidget()
378
+ layout = QHBoxLayout(cell_widget)
379
+ layout.addWidget(swatch)
380
+ layout.setAlignment(Qt.AlignCenter)
381
+ layout.setContentsMargins(0, 0, 0, 0)
382
+ self.label_table.setCellWidget(row, 2, cell_widget)
383
+
384
+ if self.label_table.rowCount() > 0:
385
+ self.label_table.selectRow(0)
386
+ self.label_table.blockSignals(False)
387
+
388
+ def pick_background_color(self):
389
+ color = QColorDialog.getColor(self.rgb_background_color, self, "Select Background Color")
390
+ if color.isValid():
391
+ self.rgb_background_color = color
392
+ swatch_container = self.label_table.cellWidget(0, 2)
393
+ if swatch_container:
394
+ # Find the swatch inside the container
395
+ swatch = swatch_container.findChild(ClickableColorSwatchWidget)
396
+ if swatch:
397
+ swatch.setColor(color)
398
+
399
+ def create_centered_checkbox(self, checked=True):
400
+ checkbox = QCheckBox()
401
+ checkbox.setChecked(checked)
402
+ widget = QWidget()
403
+ layout = QHBoxLayout(widget)
404
+ layout.addWidget(checkbox)
405
+ layout.setAlignment(Qt.AlignCenter)
406
+ layout.setContentsMargins(0, 0, 0, 0)
407
+ return widget
408
+
234
409
  def browse_output_dir(self):
235
- """Open a file dialog to select the output directory."""
236
- options = QFileDialog.Options()
237
- directory = QFileDialog.getExistingDirectory(
238
- self, "Select Output Directory", "", options=options
239
- )
410
+ directory = QFileDialog.getExistingDirectory(self, "Select Output Directory")
240
411
  if directory:
241
412
  self.output_dir_edit.setText(directory)
242
413
 
243
- def update_label_selection_list(self):
244
- """Update the label selection list with labels from the label window."""
245
- # Clear existing checkboxes
246
- for checkbox in self.label_checkboxes:
247
- self.label_layout.removeWidget(checkbox)
248
- checkbox.deleteLater()
249
- self.label_checkboxes = []
250
-
251
- # Create a checkbox for each label
252
- for label in self.label_window.labels:
253
- checkbox = QCheckBox(label.short_label_code)
254
- checkbox.setChecked(True) # Default to checked
255
- checkbox.setProperty("label", label) # Store the label object
256
- self.label_checkboxes.append(checkbox)
257
- self.label_layout.addWidget(checkbox)
258
-
259
- def update_georef_availability(self):
260
- """Update georeferencing checkbox availability based on file format"""
261
- current_format = self.file_format_combo.currentText().lower()
262
- is_tif = '.tif' in current_format
263
-
264
- self.preserve_georef_checkbox.setEnabled(is_tif)
265
- if not is_tif:
266
- self.preserve_georef_checkbox.setChecked(False)
267
- self.georef_note.setStyleSheet("color: red; font-style: italic;")
268
- else:
269
- self.georef_note.setStyleSheet("color: #666; font-style: italic;")
270
-
271
414
  def get_selected_image_paths(self):
272
- """
273
- Get the selected image paths based on the options.
274
-
275
- :return: List of selected image paths
276
- """
277
- # Current image path showing
278
415
  current_image_path = self.annotation_window.current_image_path
279
416
  if not current_image_path:
280
417
  return []
281
418
 
282
- # Determine which images to export annotations for
283
419
  if self.apply_filtered_checkbox.isChecked():
284
420
  return self.image_window.table_model.filtered_paths
285
421
  elif self.apply_prev_checkbox.isChecked():
286
422
  if current_image_path in self.image_window.table_model.filtered_paths:
287
423
  current_index = self.image_window.table_model.get_row_for_path(current_image_path)
288
424
  return self.image_window.table_model.filtered_paths[:current_index + 1]
289
- else:
290
- return [current_image_path]
291
425
  elif self.apply_next_checkbox.isChecked():
292
426
  if current_image_path in self.image_window.table_model.filtered_paths:
293
427
  current_index = self.image_window.table_model.get_row_for_path(current_image_path)
294
428
  return self.image_window.table_model.filtered_paths[current_index:]
295
- else:
296
- return [current_image_path]
297
429
  elif self.apply_all_checkbox.isChecked():
298
430
  return self.image_window.raster_manager.image_paths
299
- else:
300
- # Only apply to the current image
301
- return [current_image_path]
431
+
432
+ return [current_image_path]
302
433
 
303
- def export_class_mapping(self, output_path):
304
- """Export the class mapping to a JSON file."""
305
- mapping_file = os.path.join(output_path, "class_mapping.json")
306
-
307
- with open(mapping_file, 'w') as f:
308
- json.dump(self.class_mapping, f, indent=4)
309
-
310
- if not os.path.exists(mapping_file):
311
- print(f"Warning: Failed to save class mapping to {mapping_file}")
312
-
313
- def export_masks(self):
314
- """Export segmentation masks based on selected annotations and labels."""
315
- # Validate inputs
434
+ def validate_inputs(self):
316
435
  if not self.output_dir_edit.text():
317
- QMessageBox.warning(self,
318
- "Missing Output Directory",
436
+ QMessageBox.warning(self,
437
+ "Missing Input",
319
438
  "Please select an output directory.")
320
- return
321
-
322
- # Check if at least one annotation type is selected
323
- if not any([self.patch_checkbox.isChecked(),
324
- self.rectangle_checkbox.isChecked(),
439
+ return False
440
+ if not any([self.patch_checkbox.isChecked(),
441
+ self.rectangle_checkbox.isChecked(),
325
442
  self.polygon_checkbox.isChecked()]):
326
- QMessageBox.warning(self,
327
- "No Annotation Type Selected",
443
+ QMessageBox.warning(self,
444
+ "Missing Input",
328
445
  "Please select at least one annotation type.")
446
+ return False
447
+ return True
448
+
449
+ def run_export_process(self):
450
+ if not self.validate_inputs():
329
451
  return
330
452
 
331
- # Check for checked items
332
- self.selected_labels = []
333
- for checkbox in self.label_checkboxes:
334
- if checkbox.isChecked():
335
- self.selected_labels.append(checkbox.property("label"))
336
-
337
- # Check if at least one label is selected
338
- if not self.selected_labels:
339
- QMessageBox.warning(self,
340
- "No Labels Selected",
341
- "Please select at least one label.")
453
+ self.labels_to_render = []
454
+ self.background_value = 0
455
+
456
+ # --- Collect data from UI based on mode ---
457
+ if self.mask_mode in ['semantic', 'sfm']:
458
+ used_mask_values = {}
459
+ for i in range(self.label_table.rowCount()):
460
+ if not self.label_table.cellWidget(i, 0).findChild(QCheckBox).isChecked():
461
+ continue
462
+
463
+ label_code = self.label_table.item(i, 1).data(Qt.UserRole)
464
+ mask_value = self.label_table.cellWidget(i, 2).value()
465
+
466
+ if mask_value not in used_mask_values:
467
+ used_mask_values[mask_value] = []
468
+ used_mask_values[mask_value].append(label_code)
469
+
470
+ if label_code == "Background":
471
+ self.background_value = mask_value
472
+ else:
473
+ label = next((l for l in self.label_window.labels if l.short_label_code == label_code), None)
474
+ if label:
475
+ self.labels_to_render.append((label, mask_value))
476
+
477
+ # Check for duplicate values
478
+ duplicate_values = {v: l for v, l in used_mask_values.items() if len(l) > 1}
479
+ if duplicate_values:
480
+ msg = "Warning: The following mask values are used by multiple labels:\n" + \
481
+ "\n".join([f"Value {v}: {', '.join(l)}" for v, l in duplicate_values.items()]) + \
482
+ "\nThis may cause unexpected behavior. Continue?"
483
+ if QMessageBox.warning(self,
484
+ "Duplicate Values",
485
+ msg,
486
+ QMessageBox.Yes | QMessageBox.No) == QMessageBox.No:
487
+ return
488
+
489
+ elif self.mask_mode == 'rgb':
490
+ self.background_value = self.rgb_background_color.getRgb()[:3] # (R, G, B) tuple
491
+ for i in range(1, self.label_table.rowCount()): # Skip background
492
+ if self.label_table.cellWidget(i, 0).findChild(QCheckBox).isChecked():
493
+ label_code = self.label_table.item(i, 1).data(Qt.UserRole)
494
+ label = next((l for l in self.label_window.labels if l.short_label_code == label_code), None)
495
+ if label:
496
+ try:
497
+ # Check if label.color is already a QColor object
498
+ if isinstance(label.color, QColor):
499
+ color_tuple = label.color.getRgb()[:3] # Extract (R,G,B) and ignore alpha
500
+ else:
501
+ # Otherwise, assume it's a string (hex code) and convert it
502
+ color_tuple = ImageColor.getrgb(label.color)
503
+ self.labels_to_render.append((label, color_tuple))
504
+ except (ValueError, TypeError) as e:
505
+ print(f"Warning: Invalid color format for label "
506
+ f"'{label.short_label_code}': {label.color}. Error: {e}. Skipping.")
507
+
508
+ # --- Check if any labels are selected to be drawn ---
509
+ if not self.labels_to_render:
510
+ QMessageBox.warning(self, "No Labels Selected", "Please select at least one label to include in the masks.")
342
511
  return
343
512
 
513
+ # --- Setup paths and progress bar ---
344
514
  output_dir = self.output_dir_edit.text()
345
515
  folder_name = self.output_name_edit.text().strip()
346
- file_format = self.file_format_combo.currentText()
347
-
348
- # Ensure file_format starts with a dot
349
- if not file_format.startswith('.'):
350
- file_format = '.' + file_format
516
+ self.file_format = self.file_format_combo.currentText()
517
+ if not self.file_format.startswith('.'):
518
+ self.file_format = '.' + self.file_format
351
519
 
352
- # Create output directory
353
520
  output_path = os.path.join(output_dir, folder_name)
354
- try:
355
- os.makedirs(output_path, exist_ok=True)
356
- except Exception as e:
357
- QMessageBox.critical(self,
358
- "Error Creating Directory",
359
- f"Failed to create output directory: {str(e)}")
360
- return
361
-
362
- # Get the list of images to process
521
+ os.makedirs(output_path, exist_ok=True)
522
+
363
523
  images = self.get_selected_image_paths()
364
524
  if not images:
365
- QMessageBox.warning(self,
366
- "No Images",
367
- "No images found in the project.")
525
+ QMessageBox.warning(self, "No Images", "No images found for processing.")
368
526
  return
369
-
370
- # Collect annotation types to include
527
+
371
528
  self.annotation_types = []
372
- if self.patch_checkbox.isChecked():
529
+ if self.patch_checkbox.isChecked():
373
530
  self.annotation_types.append(PatchAnnotation)
374
- if self.rectangle_checkbox.isChecked():
531
+ if self.rectangle_checkbox.isChecked():
375
532
  self.annotation_types.append(RectangleAnnotation)
376
533
  if self.polygon_checkbox.isChecked():
377
534
  self.annotation_types.append(PolygonAnnotation)
378
535
  self.annotation_types.append(MultiPolygonAnnotation)
379
536
 
380
- # Create class mapping
381
- self.class_mapping = {}
382
-
383
- for i, label in enumerate(self.selected_labels):
384
- # Leave 0 for background
385
- self.class_mapping[label.short_label_code] = {
386
- "label": label.to_dict(),
387
- "index": i + 1
388
- }
389
-
390
- # Make the cursor busy
537
+ # --- Run Export Loop ---
391
538
  QApplication.setOverrideCursor(Qt.WaitCursor)
392
- progress_bar = ProgressBar(self.annotation_window, "Exporting Segmentation Masks")
539
+ progress_bar = ProgressBar(self.annotation_window, "Exporting Masks")
393
540
  progress_bar.show()
394
541
  progress_bar.start_progress(len(images))
395
542
 
396
543
  try:
397
544
  for image_path in images:
398
- # Create mask for this image
399
- self.create_mask_for_image(image_path, output_path, file_format)
545
+ self.create_mask_for_image(image_path, output_path)
400
546
  progress_bar.update_progress()
401
547
 
402
- # Write the class mapping to a JSON file
403
- self.export_class_mapping(output_path)
404
-
405
- QMessageBox.information(self,
406
- "Export Complete",
407
- "Segmentation masks have been successfully exported")
548
+ self.export_metadata(output_path)
549
+ QMessageBox.information(self, "Export Complete", "Masks exported successfully.")
408
550
  self.accept()
409
-
410
551
  except Exception as e:
411
- QMessageBox.critical(self,
412
- "Error Exporting Masks",
413
- f"An error occurred: {str(e)}")
414
-
552
+ QMessageBox.critical(self, "Error", f"An error occurred during export: {e}")
415
553
  finally:
416
- # Make cursor normal again
417
554
  QApplication.restoreOverrideCursor()
418
- progress_bar.finish_progress()
419
- progress_bar.stop_progress()
420
555
  progress_bar.close()
421
556
 
422
- def get_annotations_for_image(self, image_path):
423
- """Get annotations for a specific image."""
424
- # Get the selected labels' short label codes
425
- selected_labels = [label.short_label_code for label in self.selected_labels]
426
-
427
- # Get all annotations for this image
428
- annotations = []
429
-
430
- for annotation in self.annotation_window.get_image_annotations(image_path):
431
- # Check that the annotation is of the correct type
432
- if not isinstance(annotation, tuple(self.annotation_types)):
433
- continue
434
-
435
- # Check that the annotation's label is in the selected labels
436
- if annotation.label.short_label_code not in selected_labels:
437
- continue
438
-
439
- # Add the annotation to the list based on its type, if selected
440
- if self.patch_checkbox.isChecked() and isinstance(annotation, PatchAnnotation):
441
- annotations.append(annotation)
442
-
443
- elif self.rectangle_checkbox.isChecked() and isinstance(annotation, RectangleAnnotation):
444
- annotations.append(annotation)
557
+ def create_mask_for_image(self, image_path, output_path):
558
+ height, width, has_georef, transform, crs = self.get_image_metadata(image_path, self.file_format)
559
+ if not height or not width:
560
+ print(f"Skipping {image_path}: could not determine dimensions.")
561
+ return
445
562
 
446
- elif self.polygon_checkbox.isChecked() and isinstance(annotation, PolygonAnnotation):
447
- annotations.append(annotation)
448
-
449
- elif self.polygon_checkbox.isChecked() and isinstance(annotation, MultiPolygonAnnotation):
450
- for polygon in annotation.polygons:
451
- annotations.append(polygon)
563
+ # Initialize mask based on mode
564
+ if self.mask_mode == 'rgb':
565
+ mask = np.full((height, width, 3), self.background_value, dtype=np.uint8)
566
+ else: # semantic or sfm
567
+ mask = np.full((height, width), self.background_value, dtype=np.uint8)
568
+
569
+ has_annotations = False
570
+
571
+ # Draw annotations based on mode
572
+ if self.mask_mode in ['semantic', 'sfm']:
573
+ for label, value in self.labels_to_render:
574
+ annotations = self.get_annotations_for_image(image_path, label)
575
+ if annotations:
576
+ has_annotations = True
577
+ self.draw_annotations_on_mask(mask, annotations, value)
578
+ elif self.mask_mode == 'rgb':
579
+ for label, color in self.labels_to_render:
580
+ annotations = self.get_annotations_for_image(image_path, label)
581
+ if annotations:
582
+ has_annotations = True
583
+ self.draw_annotations_on_mask(mask, annotations, color)
584
+
585
+ if not has_annotations and not self.include_negative_samples_checkbox.isChecked():
586
+ return
452
587
 
588
+ # Always save as PNG
589
+ filename = f"{os.path.splitext(os.path.basename(image_path))[0]}.png"
590
+ mask_path = os.path.join(output_path, filename)
591
+
592
+ if self.mask_mode == 'rgb':
593
+ # OpenCV expects BGR, so convert from RGB
594
+ mask = cv2.cvtColor(mask, cv2.COLOR_RGB2BGR)
595
+ cv2.imwrite(mask_path, mask)
596
+
597
+ def export_metadata(self, output_path):
598
+ if self.mask_mode == 'semantic':
599
+ class_mapping = {}
600
+ if self.label_table.cellWidget(0, 0).findChild(QCheckBox).isChecked():
601
+ background_label = "Background"
602
+ background_index = self.label_table.cellWidget(0, 2).value()
603
+ class_mapping[background_label] = {
604
+ "label": background_label,
605
+ "index": background_index
606
+ }
607
+
608
+ for label, value in self.labels_to_render:
609
+ class_mapping[label.short_label_code] = {"label": label.to_dict(), "index": value}
610
+
611
+ with open(os.path.join(output_path, "class_mapping.json"), 'w') as f:
612
+ json.dump(class_mapping, f, indent=4)
613
+
614
+ elif self.mask_mode == 'rgb':
615
+ color_legend = {}
616
+ if self.label_table.cellWidget(0, 0).findChild(QCheckBox).isChecked():
617
+ color_legend["Background"] = self.background_value
618
+
619
+ for label, color in self.labels_to_render:
620
+ color_legend[label.short_label_code] = color
621
+
622
+ with open(os.path.join(output_path, "color_legend.json"), 'w') as f:
623
+ json.dump(color_legend, f, indent=4)
624
+
625
+ # No metadata file needed for SfM mode
626
+
627
+ def get_annotations_for_image(self, image_path, label):
628
+ annotations = []
629
+ for ann in self.annotation_window.get_image_annotations(image_path):
630
+ if ann.label.short_label_code == label.short_label_code and isinstance(ann, tuple(self.annotation_types)):
631
+ if isinstance(ann, MultiPolygonAnnotation):
632
+ annotations.extend(ann.polygons)
633
+ else:
634
+ annotations.append(ann)
453
635
  return annotations
454
636
 
637
+ def draw_annotations_on_mask(self, mask, annotations, value):
638
+ for ann in annotations:
639
+ if isinstance(ann, (PatchAnnotation, RectangleAnnotation)):
640
+ p1 = (int(ann.top_left.x()), int(ann.top_left.y()))
641
+ p2 = (int(ann.bottom_right.x()), int(ann.bottom_right.y()))
642
+ cv2.rectangle(mask, p1, p2, value, -1)
643
+ elif isinstance(ann, PolygonAnnotation):
644
+ points = np.array([[p.x(), p.y()] for p in ann.points], dtype=np.int32)
645
+ cv2.fillPoly(mask, [points], value)
646
+
455
647
  def get_image_metadata(self, image_path, file_format):
456
- """Get the dimensions of the image, and check for georeferencing."""
457
- # Check if image has georeferencing that needs to be preserved
458
- transform = None
459
- crs = None
460
- has_georef = False
461
- height = None
462
- width = None
463
-
464
- # Get the raster from the raster manager
648
+ transform, crs, has_georef = None, None, False
649
+ width, height = None, None
465
650
  raster = self.image_window.raster_manager.get_raster(image_path)
466
-
467
- # Only check for georeferencing if using TIF format and checkbox is checked
468
- can_preserve_georef = self.preserve_georef_checkbox.isChecked() and file_format.lower() == '.tif'
651
+ can_preserve = self.preserve_georef_checkbox.isChecked() and file_format.lower() == '.tif'
469
652
 
470
653
  if raster and raster.rasterio_src:
471
- # Get dimensions from the raster
472
- width = raster.width
473
- height = raster.height
474
-
475
- # Check for georeferencing if needed
476
- if can_preserve_georef and hasattr(raster.rasterio_src, 'transform'):
654
+ width, height = raster.width, raster.height
655
+ if can_preserve and hasattr(raster.rasterio_src, 'transform'):
477
656
  transform = raster.rasterio_src.transform
478
657
  if transform and not transform.is_identity:
479
658
  crs = raster.rasterio_src.crs
480
659
  has_georef = True
481
660
  else:
482
- # Fallback to direct file access if raster is not available
483
661
  try:
484
- if can_preserve_georef:
662
+ if can_preserve:
485
663
  with rasterio.open(image_path) as src:
486
- if src.transform and not src.transform.is_identity:
487
- transform = src.transform
488
- crs = src.crs
489
- has_georef = True
490
664
  width, height = src.width, src.height
665
+ if src.transform and not src.transform.is_identity:
666
+ transform, crs, has_georef = src.transform, src.crs, True
491
667
  else:
492
- # Use PIL for non-georeferenced images
493
- image = Image.open(image_path)
494
- width, height = image.size
668
+ with Image.open(image_path) as img:
669
+ width, height = img.size
495
670
  except Exception as e:
496
- print(f"Error loading image {image_path}: {str(e)}")
497
-
671
+ print(f"Error reading metadata for {image_path}: {e}")
498
672
  return height, width, has_georef, transform, crs
499
673
 
500
- def draw_annotations_on_mask(self, mask, annotations):
501
- """Draw annotations on the mask."""
502
- # Draw each annotation on the mask
503
- for annotation in annotations:
504
- # Get the label index for the annotation
505
- short_label_code = annotation.label.short_label_code
506
- label_index = self.class_mapping[short_label_code]["index"]
507
-
508
- # Draw the patch annotation
509
- if isinstance(annotation, PatchAnnotation):
510
- # Draw a filled rectangle
511
- cv2.rectangle(mask,
512
- (int(annotation.center_xy.x() - annotation.annotation_size / 2),
513
- int(annotation.center_xy.y() - annotation.annotation_size / 2)),
514
- (int(annotation.center_xy.x() + annotation.annotation_size / 2),
515
- int(annotation.center_xy.y() + annotation.annotation_size / 2)),
516
- label_index, -1) # -1 means filled
517
-
518
- # Draw the rectangle annotation
519
- elif isinstance(annotation, RectangleAnnotation):
520
- # Draw a filled rectangle
521
- cv2.rectangle(mask,
522
- (int(annotation.top_left.x()), int(annotation.top_left.y())),
523
- (int(annotation.bottom_right.x()), int(annotation.bottom_right.y())),
524
- label_index, -1) # -1 means filled
525
-
526
- # Draw the polygon annotation
527
- elif isinstance(annotation, PolygonAnnotation):
528
- # Draw a filled polygon
529
- points = np.array([[p.x(), p.y()] for p in annotation.points]).astype(np.int32)
530
- cv2.fillPoly(mask, [points], label_index)
531
-
532
- # Draw the multipolygon annotation
533
- elif isinstance(annotation, MultiPolygonAnnotation):
534
- for polygon in annotation.polygons:
535
- points = np.array([[p.x(), p.y()] for p in polygon.points]).astype(np.int32)
536
- cv2.fillPoly(mask, [points], label_index)
537
-
538
- return mask
539
-
540
- def create_mask_for_image(self, image_path, output_path, file_format):
541
- """Create a segmentation mask for a single image"""
542
- # Get the annotations for this image
543
- annotations = self.get_annotations_for_image(image_path)
544
-
545
- if not annotations and not self.include_negative_samples_checkbox.isChecked():
546
- return # Skip images with no annotations if the user doesn't want negative samples
547
-
548
- # Get the image dimensions, georeferencing
549
- height, width, has_georef, transform, crs = self.get_image_metadata(image_path, file_format)
674
+ # --- Row Movement and UI Helpers ---
675
+ def move_row_up(self):
676
+ current_row = self.label_table.currentRow()
677
+ if current_row > 0:
678
+ self.swap_rows(current_row, current_row - 1)
679
+ self.label_table.selectRow(current_row - 1)
680
+
681
+ def move_row_down(self):
682
+ current_row = self.label_table.currentRow()
683
+ if 0 <= current_row < self.label_table.rowCount() - 1:
684
+ self.swap_rows(current_row, current_row + 1)
685
+ self.label_table.selectRow(current_row + 1)
686
+
687
+ def swap_rows(self, row1, row2):
688
+ # Because widgets are complex to swap, we just repopulate the table
689
+ # while preserving the core data (label order). This is simpler and more robust.
690
+
691
+ # Step 1: Extract the core data and checkbox state from the table
692
+ table_data = []
693
+ for r in range(self.label_table.rowCount()):
694
+ is_checked = self.label_table.cellWidget(r, 0).findChild(QCheckBox).isChecked()
695
+ label_code = self.label_table.item(r, 1).data(Qt.UserRole)
696
+ table_data.append({'code': label_code, 'checked': is_checked})
697
+
698
+ # Step 2: Swap the data for the two rows
699
+ table_data[row1], table_data[row2] = table_data[row2], table_data[row1]
700
+
701
+ # Step 3: Map old label order to new order
702
+ new_label_order = [data['code'] for data in table_data if data['code'] != 'Background']
703
+
704
+ def label_sort_key(x):
705
+ if x.short_label_code in new_label_order:
706
+ return new_label_order.index(x.short_label_code)
707
+ else:
708
+ return float('inf')
709
+ self.label_window.labels.sort(key=label_sort_key)
550
710
 
551
- if not height or not width:
552
- print(f"Could not get dimensions for image: {image_path}")
553
- return
711
+ # Step 4: Repopulate and restore checkbox states
712
+ self.populate_label_table()
713
+ for r, data in enumerate(table_data):
714
+ self.label_table.cellWidget(r, 0).findChild(QCheckBox).setChecked(data['checked'])
554
715
 
555
- # Create a blank mask
556
- mask = np.zeros((height, width), dtype=np.uint8)
557
-
558
- # Draw annotations on the mask
559
- mask = self.draw_annotations_on_mask(mask, annotations)
560
-
561
- # Save the mask
562
- filename = os.path.basename(image_path)
563
- name_without_ext = os.path.splitext(filename)[0]
564
- mask_filename = f"{name_without_ext}{file_format}"
565
- mask_path = os.path.join(output_path, mask_filename)
566
-
567
- # If we have georeferencing and we need to preserve it, use rasterio to save
568
- if has_georef and file_format.lower() == '.tif':
569
- with rasterio.open(
570
- mask_path,
571
- 'w',
572
- driver='GTiff',
573
- height=height,
574
- width=width,
575
- count=1,
576
- dtype=mask.dtype,
577
- crs=crs,
578
- transform=transform,
579
- compress='lzw',
580
- ) as dst:
581
- dst.write(mask, 1)
716
+ def update_georef_availability(self):
717
+ is_tif = '.tif' in self.file_format_combo.currentText().lower()
718
+ self.preserve_georef_checkbox.setEnabled(is_tif)
719
+ if not is_tif:
720
+ self.preserve_georef_checkbox.setChecked(False)
721
+ self.georef_note.setStyleSheet("color: red; font-style: italic;")
582
722
  else:
583
- # Use OpenCV as before for non-georeferenced images
584
- cv2.imwrite(mask_path, mask)
585
-
586
- if not os.path.exists(mask_path):
587
- print(f"Warning: Failed to save mask to {mask_path}")
723
+ self.georef_note.setStyleSheet("color: #666; font-style: italic;")
588
724
 
589
725
  def closeEvent(self, event):
590
- """Handle the close event."""
591
- # Clean up any resources if needed
592
726
  super().closeEvent(event)
727
+