coralnet-toolbox 0.0.73__py2.py3-none-any.whl → 0.0.75__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 (50) 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/CoralNet/QtDownload.py +2 -1
  8. coralnet_toolbox/Explorer/QtDataItem.py +52 -22
  9. coralnet_toolbox/Explorer/QtExplorer.py +293 -1614
  10. coralnet_toolbox/Explorer/QtSettingsWidgets.py +203 -85
  11. coralnet_toolbox/Explorer/QtViewers.py +1568 -0
  12. coralnet_toolbox/Explorer/transformer_models.py +59 -0
  13. coralnet_toolbox/Explorer/yolo_models.py +112 -0
  14. coralnet_toolbox/IO/QtExportTagLabAnnotations.py +30 -10
  15. coralnet_toolbox/IO/QtImportTagLabAnnotations.py +21 -15
  16. coralnet_toolbox/IO/QtOpenProject.py +46 -78
  17. coralnet_toolbox/IO/QtSaveProject.py +18 -43
  18. coralnet_toolbox/MachineLearning/ExportDataset/QtBase.py +1 -1
  19. coralnet_toolbox/MachineLearning/ImportDataset/QtBase.py +253 -141
  20. coralnet_toolbox/MachineLearning/VideoInference/QtBase.py +0 -4
  21. coralnet_toolbox/MachineLearning/VideoInference/YOLO3D/run.py +102 -16
  22. coralnet_toolbox/QtAnnotationWindow.py +16 -10
  23. coralnet_toolbox/QtEventFilter.py +11 -0
  24. coralnet_toolbox/QtImageWindow.py +120 -75
  25. coralnet_toolbox/QtLabelWindow.py +13 -1
  26. coralnet_toolbox/QtMainWindow.py +5 -27
  27. coralnet_toolbox/QtProgressBar.py +52 -27
  28. coralnet_toolbox/Rasters/RasterTableModel.py +28 -8
  29. coralnet_toolbox/SAM/QtDeployGenerator.py +1 -4
  30. coralnet_toolbox/SAM/QtDeployPredictor.py +11 -3
  31. coralnet_toolbox/SeeAnything/QtDeployGenerator.py +805 -162
  32. coralnet_toolbox/SeeAnything/QtDeployPredictor.py +130 -151
  33. coralnet_toolbox/Tools/QtCutSubTool.py +18 -2
  34. coralnet_toolbox/Tools/QtPolygonTool.py +42 -3
  35. coralnet_toolbox/Tools/QtRectangleTool.py +30 -0
  36. coralnet_toolbox/Tools/QtResizeSubTool.py +19 -2
  37. coralnet_toolbox/Tools/QtSAMTool.py +72 -50
  38. coralnet_toolbox/Tools/QtSeeAnythingTool.py +8 -5
  39. coralnet_toolbox/Tools/QtSelectTool.py +27 -3
  40. coralnet_toolbox/Tools/QtSubtractSubTool.py +66 -0
  41. coralnet_toolbox/Tools/__init__.py +2 -0
  42. coralnet_toolbox/__init__.py +1 -1
  43. coralnet_toolbox/utilities.py +158 -47
  44. coralnet_toolbox-0.0.75.dist-info/METADATA +378 -0
  45. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/RECORD +49 -44
  46. coralnet_toolbox-0.0.73.dist-info/METADATA +0 -341
  47. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/WHEEL +0 -0
  48. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/entry_points.txt +0 -0
  49. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/licenses/LICENSE.txt +0 -0
  50. {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/top_level.txt +0 -0
@@ -10,7 +10,8 @@ import ujson as json
10
10
  from PyQt5.QtCore import Qt, QPointF, QObject, QThread, pyqtSignal
11
11
  from PyQt5.QtWidgets import (QFileDialog, QApplication, QMessageBox, QVBoxLayout, QGroupBox,
12
12
  QLabel, QLineEdit, QDialog, QPushButton, QDialogButtonBox,
13
- QGridLayout)
13
+ QGridLayout, QScrollArea, QFrame, QCheckBox, QRadioButton,
14
+ QToolButton)
14
15
 
15
16
  from coralnet_toolbox.Annotations.QtPolygonAnnotation import PolygonAnnotation
16
17
  from coralnet_toolbox.Annotations.QtRectangleAnnotation import RectangleAnnotation
@@ -35,38 +36,47 @@ class DatasetProcessor(QObject):
35
36
  """
36
37
  status_changed = pyqtSignal(str, int)
37
38
  progress_updated = pyqtSignal(int)
38
- processing_complete = pyqtSignal(list, list)
39
+ processing_complete = pyqtSignal(list, list, list)
39
40
  error = pyqtSignal(str)
40
41
  finished = pyqtSignal()
41
42
 
42
- def __init__(self, yaml_path, output_folder, task, import_as, rename_on_conflict=False, parent=None):
43
+ def __init__(self, yaml_path, output_folder, task, import_as, rename_on_conflict=False,
44
+ excluded_classes=None, image_import_policy='annotated_only', parent=None):
43
45
  super().__init__(parent)
44
46
  self.yaml_path = yaml_path
45
47
  self.output_folder = output_folder
46
48
  self.task = task # 'detect' or 'segment' (source format)
47
49
  self.import_as = import_as # 'rectangle' or 'polygon' (target format)
48
50
  self.rename_on_conflict = rename_on_conflict
51
+ self.excluded_classes = excluded_classes if excluded_classes is not None else set()
52
+ self.image_import_policy = image_import_policy
49
53
  self.is_running = True
54
+ self.parsing_errors = [] # To collect errors instead of printing
50
55
 
51
56
  def stop(self):
52
57
  self.is_running = False
53
58
 
59
+ # In class DatasetProcessor, update this method:
60
+
54
61
  def run(self):
55
62
  """Main processing method executed in the thread."""
56
63
  try:
57
64
  # Step 1: Read YAML and discover files
58
- self.status_changed.emit("Discovering and copying files...", 0)
59
65
  with open(self.yaml_path, 'r') as file:
60
66
  data = yaml.safe_load(file)
61
67
  class_names = data.get('names', [])
62
68
 
63
- # Step 2: Find and copy image/label pairs to output folder
64
- image_label_paths = self._find_and_copy_files(self.output_folder)
65
- if not image_label_paths:
69
+ source_image_label_map = self._find_source_files()
70
+
71
+ if not source_image_label_map:
66
72
  self.error.emit("No valid image/label pairs found in the dataset.")
67
73
  self.finished.emit()
68
74
  return
69
75
 
76
+ # --- Step 2: Copy files with progress reporting ---
77
+ self.status_changed.emit("Copying image files...", len(source_image_label_map))
78
+ image_label_paths = self._copy_files_with_progress(source_image_label_map)
79
+
70
80
  if not self.is_running:
71
81
  self.finished.emit()
72
82
  return
@@ -79,57 +89,67 @@ class DatasetProcessor(QObject):
79
89
  self.finished.emit()
80
90
  return
81
91
 
82
- # Step 4: Emit results for GUI to consume
92
+ # Step 4 (REMOVED): The JSON export is no longer done here.
93
+
94
+ # Step 5: Emit results for GUI to consume
83
95
  image_paths = list(image_label_paths.keys())
84
- self.processing_complete.emit(raw_annotations, image_paths)
96
+ self.processing_complete.emit(raw_annotations, image_paths, self.parsing_errors)
85
97
 
86
98
  except Exception as e:
87
- # Catch-all for any error during processing
88
99
  self.error.emit(f"An error occurred during processing: {str(e)}")
89
-
90
100
  finally:
91
- # Always emit finished signal
92
101
  self.finished.emit()
93
102
 
94
- def _find_and_copy_files(self, output_folder):
95
- """
96
- Finds, copies, and optionally renames image files.
97
- Returns a mapping of output image paths to original label paths.
98
- """
99
- img_out_dir = os.path.join(output_folder, "images")
100
- os.makedirs(img_out_dir, exist_ok=True)
101
-
103
+ def _find_source_files(self):
104
+ """Finds all source image and label paths based on the import policy."""
102
105
  dir_path = os.path.dirname(self.yaml_path)
103
- # Find all images and labels recursively
104
106
  image_paths = glob.glob(f"{dir_path}/**/images/*.*", recursive=True)
105
107
  label_paths = glob.glob(f"{dir_path}/**/labels/*.txt", recursive=True)
106
- # Map from base name (no extension) to image path
107
- image_basenames_map = {os.path.splitext(os.path.basename(p))[0]: p for p in image_paths}
108
108
 
109
- image_label_map = {}
110
- for label_path in label_paths:
109
+ source_map = {}
110
+ if self.image_import_policy == 'all':
111
+ # Policy: Find all images, and match labels to them if they exist
112
+ label_basenames_map = {os.path.splitext(os.path.basename(p))[0]: p for p in label_paths}
113
+ for img_path in image_paths:
114
+ img_basename = os.path.splitext(os.path.basename(img_path))[0]
115
+ label_path = label_basenames_map.get(img_basename, None) # Label can be None
116
+ source_map[img_path] = label_path
117
+ else:
118
+ # Policy: Only find images that have a corresponding label file
119
+ image_basenames_map = {os.path.splitext(os.path.basename(p))[0]: p for p in image_paths}
120
+ for label_path in label_paths:
121
+ label_basename_no_ext = os.path.splitext(os.path.basename(label_path))[0]
122
+ if label_basename_no_ext in image_basenames_map:
123
+ src_image_path = image_basenames_map[label_basename_no_ext]
124
+ source_map[src_image_path] = label_path
125
+ return source_map
126
+
127
+ def _copy_files_with_progress(self, source_image_label_map):
128
+ """Copies files and reports progress for each file."""
129
+ img_out_dir = os.path.join(self.output_folder, "images")
130
+ os.makedirs(img_out_dir, exist_ok=True)
131
+
132
+ dest_label_map = {}
133
+ for i, (src_image_path, label_path) in enumerate(source_image_label_map.items()):
111
134
  if not self.is_running:
112
135
  break
113
- label_basename_no_ext = os.path.splitext(
114
- os.path.basename(label_path))[0]
115
- if label_basename_no_ext in image_basenames_map:
116
- src_image_path = image_basenames_map[label_basename_no_ext]
117
- original_img_basename = os.path.basename(src_image_path)
118
-
119
- # Optionally rename to avoid conflicts
120
- if self.rename_on_conflict:
121
- base, ext = os.path.splitext(original_img_basename)
122
- unique_id = str(uuid.uuid4())[:8]
123
- new_img_basename = f"{base}_{unique_id}{ext}"
124
- else:
125
- new_img_basename = original_img_basename
126
-
127
- dest_image_path = os.path.join(img_out_dir, new_img_basename)
128
- shutil.copy(src_image_path, dest_image_path)
129
- # Store mapping with normalized (forward slash) paths - output image path to original label path
130
- image_label_map[dest_image_path.replace("\\", "/")] = label_path.replace("\\", "/")
131
-
132
- return image_label_map
136
+
137
+ original_img_basename = os.path.basename(src_image_path)
138
+
139
+ if self.rename_on_conflict:
140
+ base, ext = os.path.splitext(original_img_basename)
141
+ unique_id = str(uuid.uuid4())[:8]
142
+ new_img_basename = f"{base}_{unique_id}{ext}"
143
+ else:
144
+ new_img_basename = original_img_basename
145
+
146
+ dest_image_path = os.path.join(img_out_dir, new_img_basename)
147
+ shutil.copy(src_image_path, dest_image_path)
148
+
149
+ dest_label_map[dest_image_path.replace("\\", "/")] = label_path
150
+ self.progress_updated.emit(i + 1)
151
+
152
+ return dest_label_map
133
153
 
134
154
  def _create_raw_annotations(self, image_label_paths, class_names):
135
155
  """
@@ -141,18 +161,26 @@ class DatasetProcessor(QObject):
141
161
  if not self.is_running:
142
162
  break
143
163
 
144
- # Get image dimensions for denormalizing coordinates
164
+ # If there's no label file for this image (e.g., 'import all' policy), skip to progress update
165
+ if not label_path:
166
+ self.progress_updated.emit(i + 1)
167
+ continue
168
+
145
169
  image_height, image_width = rasterio_open(image_path).shape
146
170
  with open(label_path, 'r') as file:
147
171
  lines = file.readlines()
148
172
 
149
- for line in lines:
173
+ for line_num, line in enumerate(lines):
150
174
  try:
151
175
  parts = list(map(float, line.split()))
152
176
  class_id = int(parts[0])
153
- raw_ann_data = {"image_path": image_path, "class_name": class_names[class_id]}
154
177
 
155
- # --- Step 1: Parse the source data based on the original task type ---
178
+ class_name = class_names[class_id]
179
+ if class_name in self.excluded_classes:
180
+ continue # Skip this annotation if its class was unchecked
181
+
182
+ raw_ann_data = {"image_path": image_path, "class_name": class_name}
183
+
156
184
  parsed_data = {}
157
185
  if self.task == 'detect': # Source is bbox: class, x_c, y_c, w, h
158
186
  _, x_c, y_c, w, h = parts
@@ -164,44 +192,45 @@ class DatasetProcessor(QObject):
164
192
  parsed_data['bottom_right'] = (x + width / 2, y + height / 2)
165
193
  else: # Source is polygon: class, x1, y1, x2, y2, ...
166
194
  points_norm = parts[1:]
195
+ # Convert normalized coordinates to pixel coordinates
196
+ # Extract x and y coordinates from the flattened list
197
+ x_coords = points_norm[::2] # Every even index (0, 2, 4...)
198
+ y_coords = points_norm[1::2] # Every odd index (1, 3, 5...)
199
+
200
+ # Scale coordinates by image dimensions
167
201
  points = []
168
- for x, y in zip(points_norm[::2], points_norm[1::2]):
169
- points.append((x * image_width, y * image_height))
202
+ for x, y in zip(x_coords, y_coords):
203
+ pixel_x = x * image_width
204
+ pixel_y = y * image_height
205
+ points.append((pixel_x, pixel_y))
170
206
  parsed_data['points'] = points
171
207
 
172
- # --- Step 2: Convert to the target format if necessary ---
173
208
  if self.import_as == 'rectangle':
174
209
  raw_ann_data["type"] = "RectangleAnnotation"
175
- if 'top_left' in parsed_data: # Already a rectangle
210
+ if 'top_left' in parsed_data:
176
211
  raw_ann_data.update(parsed_data)
177
- else: # Convert polygon to rectangle (bounding box)
212
+ else:
178
213
  points = parsed_data['points']
179
214
  x_coords = [p[0] for p in points]
180
215
  y_coords = [p[1] for p in points]
181
216
  raw_ann_data["top_left"] = (min(x_coords), min(y_coords))
182
217
  raw_ann_data["bottom_right"] = (max(x_coords), max(y_coords))
183
-
184
218
  elif self.import_as == 'polygon':
185
219
  raw_ann_data["type"] = "PolygonAnnotation"
186
- if 'points' in parsed_data: # Already a polygon
220
+ if 'points' in parsed_data:
187
221
  raw_ann_data.update(parsed_data)
188
- else: # Convert rectangle to polygon
189
- tl = parsed_data['top_left']
190
- br = parsed_data['bottom_right']
191
- raw_ann_data["points"] = [
192
- (tl[0], tl[1]), (br[0], tl[1]),
193
- (br[0], br[1]), (tl[0], br[1])
194
- ]
195
-
222
+ else:
223
+ tl, br = parsed_data['top_left'], parsed_data['bottom_right']
224
+ raw_ann_data["points"] = [(tl[0], tl[1]), (br[0], tl[1]), (br[0], br[1]), (tl[0], br[1])]
196
225
  all_raw_annotations.append(raw_ann_data)
197
226
  except (ValueError, IndexError) as e:
198
- # Skip malformed lines and print a warning
199
- print(f"Skipping malformed line in {label_path}: {line.strip()} ({e})")
227
+ error_msg = (f"In file '{os.path.basename(label_path)}' on line {line_num + 1}:\n"
228
+ f"Skipped malformed content: '{line.strip()}'\nReason: {e}\n")
229
+ self.parsing_errors.append(error_msg)
200
230
 
201
- # Update progress after each image
202
231
  self.progress_updated.emit(i + 1)
203
232
  return all_raw_annotations
204
-
233
+
205
234
 
206
235
  # ----------------------------------------------------------------------------------------------------------------------
207
236
  # Dialog Classes
@@ -217,13 +246,14 @@ class Base(QDialog):
217
246
 
218
247
  self.setWindowIcon(get_icon("coral.png"))
219
248
  self.setWindowTitle("Import Dataset")
220
- self.resize(500, 200) # Increased height for new widget
249
+ self.resize(500, 350)
221
250
 
222
251
  self.task = None
223
252
  self.progress_bar = None
224
253
  self.thread = None
225
254
  self.worker = None
226
255
  self.output_folder = None
256
+ self.class_checkboxes = []
227
257
 
228
258
  self.layout = QVBoxLayout(self)
229
259
  self.setup_info_layout()
@@ -231,12 +261,14 @@ class Base(QDialog):
231
261
  self.setup_output_layout()
232
262
  self.setup_buttons_layout()
233
263
 
264
+ self.advanced_options_toggle.setEnabled(False)
265
+ self.advanced_options_frame.setVisible(False)
266
+
234
267
  def setup_info_layout(self):
235
268
  raise NotImplementedError("Subclasses must implement method.")
236
269
 
237
270
  def setup_yaml_layout(self):
238
271
  """Set up the layout for selecting the data YAML file."""
239
- # Group box for YAML file selection
240
272
  group_box = QGroupBox("Data YAML File")
241
273
  layout = QGridLayout()
242
274
  layout.addWidget(QLabel("File:"), 0, 0)
@@ -251,8 +283,7 @@ class Base(QDialog):
251
283
  self.layout.addWidget(group_box)
252
284
 
253
285
  def setup_output_layout(self):
254
- """Set up the layout for output directory and folder name selection."""
255
- # Group box for output directory and folder name
286
+ """Set up the layout for output directory and the advanced options accordion."""
256
287
  group_box = QGroupBox("Output Settings")
257
288
  layout = QGridLayout()
258
289
  layout.addWidget(QLabel("Directory:"), 0, 0)
@@ -269,38 +300,94 @@ class Base(QDialog):
269
300
  group_box.setLayout(layout)
270
301
  self.layout.addWidget(group_box)
271
302
 
303
+ self.advanced_options_toggle = QToolButton()
304
+ self.advanced_options_toggle.setText("Advanced Options")
305
+ self.advanced_options_toggle.setCheckable(True)
306
+ self.advanced_options_toggle.setStyleSheet("QToolButton { border: none; }")
307
+ self.advanced_options_toggle.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
308
+ self.advanced_options_toggle.setArrowType(Qt.RightArrow)
309
+ self.advanced_options_toggle.toggled.connect(self.toggle_advanced_options)
310
+ self.layout.addWidget(self.advanced_options_toggle)
311
+
312
+ self.advanced_options_frame = QFrame()
313
+ self.advanced_options_frame.setFrameShape(QFrame.StyledPanel)
314
+ advanced_layout = QVBoxLayout(self.advanced_options_frame)
315
+
316
+ image_rule_box = QGroupBox("Image Import Rule")
317
+ image_rule_layout = QVBoxLayout()
318
+ self.import_annotated_images_radio = QRadioButton("Import only images with annotations")
319
+ self.import_all_images_radio = QRadioButton("Import all images found in dataset")
320
+ self.import_annotated_images_radio.setChecked(True)
321
+ image_rule_layout.addWidget(self.import_annotated_images_radio)
322
+ image_rule_layout.addWidget(self.import_all_images_radio)
323
+ image_rule_box.setLayout(image_rule_layout)
324
+ advanced_layout.addWidget(image_rule_box)
325
+
326
+ class_filter_box = QGroupBox("Classes to Import")
327
+ class_filter_layout = QVBoxLayout()
328
+ self.class_scroll_area = QScrollArea()
329
+ self.class_scroll_area.setWidgetResizable(True)
330
+ self.class_widget = QFrame()
331
+ self.class_layout = QVBoxLayout(self.class_widget)
332
+ self.class_scroll_area.setWidget(self.class_widget)
333
+ class_filter_layout.addWidget(self.class_scroll_area)
334
+ class_filter_box.setLayout(class_filter_layout)
335
+ advanced_layout.addWidget(class_filter_box)
336
+ self.layout.addWidget(self.advanced_options_frame)
337
+
338
+ def toggle_advanced_options(self, checked):
339
+ self.advanced_options_toggle.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
340
+ self.advanced_options_frame.setVisible(checked)
341
+
272
342
  def setup_buttons_layout(self):
273
343
  """Set up the OK/Cancel button box."""
274
- # Dialog button box for OK and Cancel actions
275
344
  self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
276
345
  self.button_box.accepted.connect(self.start_processing)
277
346
  self.button_box.rejected.connect(self.reject)
278
347
  self.layout.addWidget(self.button_box)
279
348
 
280
349
  def browse_data_yaml(self):
281
- """Open a file dialog to select the data YAML file."""
282
- # File dialog for selecting YAML file
350
+ """Open a file dialog to select the data YAML file and populate advanced options."""
283
351
  options = QFileDialog.Options()
284
352
  file_path, _ = QFileDialog.getOpenFileName(
285
- self,
286
- "Select Data YAML",
287
- "",
288
- "YAML Files (*.yaml);;All Files (*)",
289
- options=options
353
+ self, "Select Data YAML", "", "YAML Files (*.yaml);;All Files (*)", options=options
290
354
  )
291
- if file_path:
355
+ if not file_path:
356
+ return
357
+
358
+ try:
359
+ with open(file_path, 'r') as file:
360
+ data = yaml.safe_load(file)
361
+ class_names = data.get('names', [])
362
+ if not class_names:
363
+ QMessageBox.warning(self, "Warning", "Could not find a 'names' list in the selected YAML file.")
364
+ return
365
+
292
366
  self.yaml_path_label.setText(file_path)
293
- # Auto-fill output directory and folder name if not set
294
- if not self.output_dir_label.text():
295
- self.output_dir_label.setText(os.path.dirname(file_path))
296
- if not self.output_folder_name.text():
297
- self.output_folder_name.setText("project")
367
+ yaml_dir = os.path.dirname(file_path)
368
+ self.output_dir_label.setText(yaml_dir)
369
+ self.output_folder_name.setText("data")
370
+
371
+ for checkbox in self.class_checkboxes:
372
+ self.class_layout.removeWidget(checkbox)
373
+ checkbox.deleteLater()
374
+ self.class_checkboxes.clear()
375
+
376
+ for name in class_names:
377
+ checkbox = QCheckBox(name)
378
+ checkbox.setChecked(True)
379
+ self.class_layout.addWidget(checkbox)
380
+ self.class_checkboxes.append(checkbox)
381
+
382
+ self.advanced_options_toggle.setEnabled(True)
383
+
384
+ except Exception as e:
385
+ QMessageBox.critical(self, "Error", f"Failed to read or parse YAML file:\n{e}")
386
+ self.advanced_options_toggle.setEnabled(False)
298
387
 
299
388
  def browse_output_dir(self):
300
389
  """Open a dialog to select the output directory."""
301
- # Directory dialog for selecting output directory
302
- dir_path = QFileDialog.getExistingDirectory(
303
- self, "Select Output Directory")
390
+ dir_path = QFileDialog.getExistingDirectory(self, "Select Output Directory")
304
391
  if dir_path:
305
392
  self.output_dir_label.setText(dir_path)
306
393
 
@@ -310,17 +397,21 @@ class Base(QDialog):
310
397
  QMessageBox.warning(self, "Error", "Please fill in all fields.")
311
398
  return
312
399
 
313
- # Pre-scan for duplicates
314
- yaml_path = self.yaml_path_label.text()
315
- dir_path = os.path.dirname(yaml_path)
400
+ self.output_folder = os.path.join(self.output_dir_label.text(), self.output_folder_name.text())
401
+ if os.path.exists(self.output_folder) and os.listdir(self.output_folder):
402
+ reply = QMessageBox.question(self,
403
+ 'Directory Not Empty',
404
+ f"The directory '{self.output_folder}' is not empty. Continue?",
405
+ QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
406
+ if reply == QMessageBox.No: return
407
+
316
408
  QApplication.setOverrideCursor(Qt.WaitCursor)
317
409
  try:
318
- image_paths = glob.glob(f"{dir_path}/**/images/*.*", recursive=True)
410
+ image_paths = glob.glob(f"{os.path.dirname(self.yaml_path_label.text())}/**/images/*.*", recursive=True)
319
411
  finally:
320
412
  QApplication.restoreOverrideCursor()
321
413
 
322
- basenames = set()
323
- duplicates_exist = False
414
+ basenames, duplicates_exist = set(), False
324
415
  for path in image_paths:
325
416
  basename_no_ext = os.path.splitext(os.path.basename(path))[0]
326
417
  if basename_no_ext in basenames:
@@ -328,7 +419,6 @@ class Base(QDialog):
328
419
  break
329
420
  basenames.add(basename_no_ext)
330
421
 
331
- # Default behavior is to overwrite, but we will confirm with the user if conflicts exist.
332
422
  rename_files = False
333
423
  if duplicates_exist:
334
424
  msg_box = QMessageBox(self)
@@ -340,54 +430,58 @@ class Base(QDialog):
340
430
  )
341
431
  msg_box.setInformativeText("How would you like to handle these conflicts?")
342
432
 
343
- # Add custom buttons for each action
344
- rename_button = msg_box.addButton("Rename Files (Safe)", QMessageBox.AcceptRole)
345
- overwrite_button = msg_box.addButton("Overwrite", QMessageBox.DestructiveRole)
346
- cancel_button = msg_box.addButton("Cancel", QMessageBox.RejectRole)
433
+ # Add buttons with proper line breaks
434
+ rename_button = msg_box.addButton(
435
+ "Rename Files (Safe)",
436
+ QMessageBox.AcceptRole
437
+ )
438
+ overwrite_button = msg_box.addButton(
439
+ "Overwrite",
440
+ QMessageBox.DestructiveRole
441
+ )
442
+ cancel_button = msg_box.addButton(
443
+ "Cancel",
444
+ QMessageBox.RejectRole
445
+ )
347
446
 
348
447
  msg_box.setDefaultButton(rename_button)
349
448
  msg_box.exec_()
350
449
 
351
450
  clicked_button = msg_box.clickedButton()
352
-
353
451
  if clicked_button == cancel_button:
354
- return # Stop the process
452
+ return
355
453
  elif clicked_button == rename_button:
356
454
  rename_files = True
357
455
  elif clicked_button == overwrite_button:
358
456
  rename_files = False
359
- else: # User closed the dialog
457
+ else:
360
458
  return
361
459
 
362
- self.output_folder = os.path.join(self.output_dir_label.text(), self.output_folder_name.text())
363
- if os.path.exists(self.output_folder) and os.listdir(self.output_folder):
364
- reply = QMessageBox.question(self,
365
- 'Directory Not Empty',
366
- f"The directory '{self.output_folder}' is not empty. Continue?",
367
- QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
368
- if reply == QMessageBox.No:
369
- return
460
+ excluded_classes = set()
461
+ if self.advanced_options_toggle.isEnabled():
462
+ for checkbox in self.class_checkboxes:
463
+ if not checkbox.isChecked():
464
+ excluded_classes.add(checkbox.text())
465
+ image_import_policy = 'all' if self.import_all_images_radio.isChecked() else 'annotated_only'
370
466
 
371
467
  self.button_box.setEnabled(False)
372
468
  QApplication.setOverrideCursor(Qt.WaitCursor)
373
-
374
469
  self.progress_bar = ProgressBar(self, title="Preparing to Import...")
375
470
  self.progress_bar.show()
376
471
 
377
- # Get the selected import format from the combobox
378
- import_as_text = self.import_as_combo.currentText()
379
- import_as = 'polygon' if 'Polygon' in import_as_text else 'rectangle'
472
+ import_as = 'polygon' if 'Polygon' in self.import_as_combo.currentText() else 'rectangle'
380
473
 
381
474
  self.thread = QThread()
382
475
  self.worker = DatasetProcessor(
383
476
  yaml_path=self.yaml_path_label.text(),
384
477
  output_folder=self.output_folder,
385
478
  task=self.task,
386
- import_as=import_as, # Pass the user's choice
387
- rename_on_conflict=rename_files # Pass the user's final decision
479
+ import_as=import_as,
480
+ rename_on_conflict=rename_files,
481
+ excluded_classes=excluded_classes,
482
+ image_import_policy=image_import_policy
388
483
  )
389
484
  self.worker.moveToThread(self.thread)
390
-
391
485
  self.thread.started.connect(self.worker.run)
392
486
  self.worker.finished.connect(self.on_worker_finished)
393
487
  self.worker.error.connect(self.on_error)
@@ -403,55 +497,73 @@ class Base(QDialog):
403
497
  def on_progress_update(self, value):
404
498
  self.progress_bar.set_value(value)
405
499
 
406
- def on_processing_complete(self, raw_annotations, image_paths):
500
+ def on_processing_complete(self, raw_annotations, image_paths, parsing_errors):
501
+ progress_bar = ProgressBar(self, title="Adding Data to Project...")
502
+ progress_bar.show()
503
+
407
504
  added_paths = []
505
+ progress_bar.set_title(f"Adding {len(image_paths)} images...")
506
+ progress_bar.start_progress(len(image_paths))
408
507
  for path in image_paths:
409
508
  if self.image_window.add_image(path):
410
509
  added_paths.append(path)
510
+ progress_bar.update_progress()
411
511
 
412
512
  newly_created_annotations = []
513
+ progress_bar.set_title(f"Adding {len(raw_annotations)} annotations...")
514
+ progress_bar.start_progress(len(raw_annotations))
413
515
  for raw_ann in raw_annotations:
414
- label = self.main_window.label_window.add_label_if_not_exists(
415
- raw_ann["class_name"])
516
+ label = self.main_window.label_window.add_label_if_not_exists(raw_ann["class_name"])
416
517
  if raw_ann["type"] == "RectangleAnnotation":
417
518
  tl, br = raw_ann["top_left"], raw_ann["bottom_right"]
418
519
  annotation = RectangleAnnotation(QPointF(tl[0], tl[1]),
419
- QPointF(br[0], br[1]),
520
+ QPointF(br[0], br[1]),
420
521
  label.short_label_code,
421
522
  label.long_label_code,
422
- label.color,
523
+ label.color,
423
524
  raw_ann["image_path"],
424
525
  label.id,
425
526
  self.main_window.get_transparency_value())
426
- else: # PolygonAnnotation
527
+ else:
427
528
  points = [QPointF(p[0], p[1]) for p in raw_ann["points"]]
428
- annotation = PolygonAnnotation(points,
429
- label.short_label_code,
430
- label.long_label_code,
431
- label.color,
529
+ annotation = PolygonAnnotation(points,
530
+ label.short_label_code,
531
+ label.long_label_code,
532
+ label.color,
432
533
  raw_ann["image_path"],
433
534
  label.id,
434
535
  self.main_window.get_transparency_value())
435
-
536
+
436
537
  self.annotation_window.add_annotation_to_dict(annotation)
437
- newly_created_annotations.append(annotation) # Add to our list
538
+ newly_created_annotations.append(annotation) # Collect created objects
539
+
540
+ progress_bar.update_progress()
438
541
 
439
- # --- Now, export the fully created objects to JSON ---
440
- self.progress_bar.set_title("Exporting to annotations.json...")
441
- self.export_annotations_to_json(newly_created_annotations, self.output_folder)
542
+ # --- Call the restored, correct export function ---
543
+ progress_bar.set_title("Exporting annotations.json...")
544
+ self._export_annotations_to_json(newly_created_annotations, self.output_folder)
545
+
546
+ progress_bar.finish_progress()
547
+ progress_bar.stop_progress()
548
+ progress_bar.close()
442
549
 
443
550
  self.image_window.filter_images()
444
-
445
551
  if added_paths:
446
552
  self.image_window.load_image_by_path(added_paths[-1])
447
553
  self.image_window.update_image_annotations(added_paths[-1])
448
554
  self.annotation_window.load_annotations()
449
555
 
450
- QMessageBox.information(self,
451
- "Dataset Imported",
452
- "Dataset has been successfully imported.")
453
-
454
- def export_annotations_to_json(self, annotations_list, output_dir):
556
+ summary_message = "Dataset has been successfully imported."
557
+ if parsing_errors:
558
+ QMessageBox.warning(self,
559
+ "Import Complete with Warnings",
560
+ f"{summary_message}\n\nHowever, {len(parsing_errors)} issue(s) were found. "
561
+ "Please review them below.",
562
+ details='\n'.join(parsing_errors))
563
+ else:
564
+ QMessageBox.information(self, "Dataset Imported", summary_message)
565
+
566
+ def _export_annotations_to_json(self, annotations_list, output_dir):
455
567
  """
456
568
  Merges the list of annotation objects into an existing annotations.json file,
457
569
  or creates a new one if it doesn't exist.
@@ -507,7 +619,7 @@ class Base(QDialog):
507
619
  file.flush()
508
620
  except Exception as e:
509
621
  QMessageBox.critical(self, "Export Error", f"Failed to write annotations.json:\n{e}")
510
-
622
+
511
623
  def on_error(self, message):
512
624
  QMessageBox.warning(self, "Error", message)
513
625
 
@@ -534,4 +646,4 @@ class Base(QDialog):
534
646
 
535
647
  def closeEvent(self, event):
536
648
  self.reject()
537
- super().closeEvent(event)
649
+ super().closeEvent(event)
@@ -320,10 +320,6 @@ class Base(QDialog):
320
320
  # If video already loaded, update output dir for widget
321
321
  if self.video_path:
322
322
  self.video_region_widget.load_video(self.video_path, dir_name)
323
- else:
324
- self.update_record_buttons()
325
- else:
326
- self.update_record_buttons()
327
323
 
328
324
  def browse_model(self):
329
325
  """Open file dialog to select model file (filtered to .pt, .pth)."""