coralnet-toolbox 0.0.74__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.
- coralnet_toolbox/Explorer/QtDataItem.py +52 -22
- coralnet_toolbox/Explorer/QtExplorer.py +277 -1600
- coralnet_toolbox/Explorer/QtSettingsWidgets.py +101 -15
- coralnet_toolbox/Explorer/QtViewers.py +1568 -0
- coralnet_toolbox/Explorer/transformer_models.py +59 -0
- coralnet_toolbox/Explorer/yolo_models.py +112 -0
- coralnet_toolbox/MachineLearning/ImportDataset/QtBase.py +239 -147
- coralnet_toolbox/MachineLearning/VideoInference/YOLO3D/run.py +102 -16
- coralnet_toolbox/QtAnnotationWindow.py +16 -10
- coralnet_toolbox/QtImageWindow.py +3 -7
- coralnet_toolbox/Rasters/RasterTableModel.py +20 -0
- coralnet_toolbox/SAM/QtDeployGenerator.py +1 -4
- coralnet_toolbox/SAM/QtDeployPredictor.py +1 -3
- coralnet_toolbox/SeeAnything/QtDeployGenerator.py +131 -106
- coralnet_toolbox/SeeAnything/QtDeployPredictor.py +45 -3
- coralnet_toolbox/Tools/QtPolygonTool.py +42 -3
- coralnet_toolbox/Tools/QtRectangleTool.py +30 -0
- coralnet_toolbox/__init__.py +1 -1
- coralnet_toolbox/utilities.py +21 -0
- {coralnet_toolbox-0.0.74.dist-info → coralnet_toolbox-0.0.75.dist-info}/METADATA +6 -3
- {coralnet_toolbox-0.0.74.dist-info → coralnet_toolbox-0.0.75.dist-info}/RECORD +25 -22
- {coralnet_toolbox-0.0.74.dist-info → coralnet_toolbox-0.0.75.dist-info}/WHEEL +0 -0
- {coralnet_toolbox-0.0.74.dist-info → coralnet_toolbox-0.0.75.dist-info}/entry_points.txt +0 -0
- {coralnet_toolbox-0.0.74.dist-info → coralnet_toolbox-0.0.75.dist-info}/licenses/LICENSE.txt +0 -0
- {coralnet_toolbox-0.0.74.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
|
@@ -39,35 +40,43 @@ class DatasetProcessor(QObject):
|
|
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,
|
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
|
50
54
|
self.parsing_errors = [] # To collect errors instead of printing
|
51
55
|
|
52
56
|
def stop(self):
|
53
57
|
self.is_running = False
|
54
58
|
|
59
|
+
# In class DatasetProcessor, update this method:
|
60
|
+
|
55
61
|
def run(self):
|
56
62
|
"""Main processing method executed in the thread."""
|
57
63
|
try:
|
58
64
|
# Step 1: Read YAML and discover files
|
59
|
-
self.status_changed.emit("Discovering and copying files...", 0)
|
60
65
|
with open(self.yaml_path, 'r') as file:
|
61
66
|
data = yaml.safe_load(file)
|
62
67
|
class_names = data.get('names', [])
|
63
68
|
|
64
|
-
|
65
|
-
|
66
|
-
if not
|
69
|
+
source_image_label_map = self._find_source_files()
|
70
|
+
|
71
|
+
if not source_image_label_map:
|
67
72
|
self.error.emit("No valid image/label pairs found in the dataset.")
|
68
73
|
self.finished.emit()
|
69
74
|
return
|
70
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
|
+
|
71
80
|
if not self.is_running:
|
72
81
|
self.finished.emit()
|
73
82
|
return
|
@@ -80,57 +89,67 @@ class DatasetProcessor(QObject):
|
|
80
89
|
self.finished.emit()
|
81
90
|
return
|
82
91
|
|
83
|
-
# Step 4:
|
92
|
+
# Step 4 (REMOVED): The JSON export is no longer done here.
|
93
|
+
|
94
|
+
# Step 5: Emit results for GUI to consume
|
84
95
|
image_paths = list(image_label_paths.keys())
|
85
96
|
self.processing_complete.emit(raw_annotations, image_paths, self.parsing_errors)
|
86
97
|
|
87
98
|
except Exception as e:
|
88
|
-
# Catch-all for any error during processing
|
89
99
|
self.error.emit(f"An error occurred during processing: {str(e)}")
|
90
|
-
|
91
100
|
finally:
|
92
|
-
# Always emit finished signal
|
93
101
|
self.finished.emit()
|
94
102
|
|
95
|
-
def
|
96
|
-
"""
|
97
|
-
Finds, copies, and optionally renames image files.
|
98
|
-
Returns a mapping of output image paths to original label paths.
|
99
|
-
"""
|
100
|
-
img_out_dir = os.path.join(output_folder, "images")
|
101
|
-
os.makedirs(img_out_dir, exist_ok=True)
|
102
|
-
|
103
|
+
def _find_source_files(self):
|
104
|
+
"""Finds all source image and label paths based on the import policy."""
|
103
105
|
dir_path = os.path.dirname(self.yaml_path)
|
104
|
-
# Find all images and labels recursively
|
105
106
|
image_paths = glob.glob(f"{dir_path}/**/images/*.*", recursive=True)
|
106
107
|
label_paths = glob.glob(f"{dir_path}/**/labels/*.txt", recursive=True)
|
107
|
-
# Map from base name (no extension) to image path
|
108
|
-
image_basenames_map = {os.path.splitext(os.path.basename(p))[0]: p for p in image_paths}
|
109
108
|
|
110
|
-
|
111
|
-
|
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()):
|
112
134
|
if not self.is_running:
|
113
135
|
break
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
image_label_map[dest_image_path.replace("\\", "/")] = label_path.replace("\\", "/")
|
132
|
-
|
133
|
-
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
|
134
153
|
|
135
154
|
def _create_raw_annotations(self, image_label_paths, class_names):
|
136
155
|
"""
|
@@ -142,7 +161,11 @@ class DatasetProcessor(QObject):
|
|
142
161
|
if not self.is_running:
|
143
162
|
break
|
144
163
|
|
145
|
-
#
|
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
|
+
|
146
169
|
image_height, image_width = rasterio_open(image_path).shape
|
147
170
|
with open(label_path, 'r') as file:
|
148
171
|
lines = file.readlines()
|
@@ -151,9 +174,13 @@ class DatasetProcessor(QObject):
|
|
151
174
|
try:
|
152
175
|
parts = list(map(float, line.split()))
|
153
176
|
class_id = int(parts[0])
|
154
|
-
raw_ann_data = {"image_path": image_path, "class_name": class_names[class_id]}
|
155
177
|
|
156
|
-
|
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
|
+
|
157
184
|
parsed_data = {}
|
158
185
|
if self.task == 'detect': # Source is bbox: class, x_c, y_c, w, h
|
159
186
|
_, x_c, y_c, w, h = parts
|
@@ -165,47 +192,45 @@ class DatasetProcessor(QObject):
|
|
165
192
|
parsed_data['bottom_right'] = (x + width / 2, y + height / 2)
|
166
193
|
else: # Source is polygon: class, x1, y1, x2, y2, ...
|
167
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
|
168
201
|
points = []
|
169
|
-
for x, y in zip(
|
170
|
-
|
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))
|
171
206
|
parsed_data['points'] = points
|
172
207
|
|
173
|
-
# --- Step 2: Convert to the target format if necessary ---
|
174
208
|
if self.import_as == 'rectangle':
|
175
209
|
raw_ann_data["type"] = "RectangleAnnotation"
|
176
|
-
if 'top_left' in parsed_data:
|
210
|
+
if 'top_left' in parsed_data:
|
177
211
|
raw_ann_data.update(parsed_data)
|
178
|
-
else:
|
212
|
+
else:
|
179
213
|
points = parsed_data['points']
|
180
214
|
x_coords = [p[0] for p in points]
|
181
215
|
y_coords = [p[1] for p in points]
|
182
216
|
raw_ann_data["top_left"] = (min(x_coords), min(y_coords))
|
183
217
|
raw_ann_data["bottom_right"] = (max(x_coords), max(y_coords))
|
184
|
-
|
185
218
|
elif self.import_as == 'polygon':
|
186
219
|
raw_ann_data["type"] = "PolygonAnnotation"
|
187
|
-
if 'points' in parsed_data:
|
220
|
+
if 'points' in parsed_data:
|
188
221
|
raw_ann_data.update(parsed_data)
|
189
|
-
else:
|
190
|
-
tl = parsed_data['top_left']
|
191
|
-
|
192
|
-
raw_ann_data["points"] = [
|
193
|
-
(tl[0], tl[1]), (br[0], tl[1]),
|
194
|
-
(br[0], br[1]), (tl[0], br[1])
|
195
|
-
]
|
196
|
-
|
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])]
|
197
225
|
all_raw_annotations.append(raw_ann_data)
|
198
226
|
except (ValueError, IndexError) as e:
|
199
|
-
# Log the malformed line error instead of printing
|
200
227
|
error_msg = (f"In file '{os.path.basename(label_path)}' on line {line_num + 1}:\n"
|
201
228
|
f"Skipped malformed content: '{line.strip()}'\nReason: {e}\n")
|
202
229
|
self.parsing_errors.append(error_msg)
|
203
230
|
|
204
|
-
|
205
|
-
# Update progress after each image
|
206
231
|
self.progress_updated.emit(i + 1)
|
207
232
|
return all_raw_annotations
|
208
|
-
|
233
|
+
|
209
234
|
|
210
235
|
# ----------------------------------------------------------------------------------------------------------------------
|
211
236
|
# Dialog Classes
|
@@ -221,13 +246,14 @@ class Base(QDialog):
|
|
221
246
|
|
222
247
|
self.setWindowIcon(get_icon("coral.png"))
|
223
248
|
self.setWindowTitle("Import Dataset")
|
224
|
-
self.resize(500,
|
249
|
+
self.resize(500, 350)
|
225
250
|
|
226
251
|
self.task = None
|
227
252
|
self.progress_bar = None
|
228
253
|
self.thread = None
|
229
254
|
self.worker = None
|
230
255
|
self.output_folder = None
|
256
|
+
self.class_checkboxes = []
|
231
257
|
|
232
258
|
self.layout = QVBoxLayout(self)
|
233
259
|
self.setup_info_layout()
|
@@ -235,12 +261,14 @@ class Base(QDialog):
|
|
235
261
|
self.setup_output_layout()
|
236
262
|
self.setup_buttons_layout()
|
237
263
|
|
264
|
+
self.advanced_options_toggle.setEnabled(False)
|
265
|
+
self.advanced_options_frame.setVisible(False)
|
266
|
+
|
238
267
|
def setup_info_layout(self):
|
239
268
|
raise NotImplementedError("Subclasses must implement method.")
|
240
269
|
|
241
270
|
def setup_yaml_layout(self):
|
242
271
|
"""Set up the layout for selecting the data YAML file."""
|
243
|
-
# Group box for YAML file selection
|
244
272
|
group_box = QGroupBox("Data YAML File")
|
245
273
|
layout = QGridLayout()
|
246
274
|
layout.addWidget(QLabel("File:"), 0, 0)
|
@@ -255,8 +283,7 @@ class Base(QDialog):
|
|
255
283
|
self.layout.addWidget(group_box)
|
256
284
|
|
257
285
|
def setup_output_layout(self):
|
258
|
-
"""Set up the layout for output directory and
|
259
|
-
# Group box for output directory and folder name
|
286
|
+
"""Set up the layout for output directory and the advanced options accordion."""
|
260
287
|
group_box = QGroupBox("Output Settings")
|
261
288
|
layout = QGridLayout()
|
262
289
|
layout.addWidget(QLabel("Directory:"), 0, 0)
|
@@ -273,41 +300,94 @@ class Base(QDialog):
|
|
273
300
|
group_box.setLayout(layout)
|
274
301
|
self.layout.addWidget(group_box)
|
275
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
|
+
|
276
342
|
def setup_buttons_layout(self):
|
277
343
|
"""Set up the OK/Cancel button box."""
|
278
|
-
# Dialog button box for OK and Cancel actions
|
279
344
|
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
280
345
|
self.button_box.accepted.connect(self.start_processing)
|
281
346
|
self.button_box.rejected.connect(self.reject)
|
282
347
|
self.layout.addWidget(self.button_box)
|
283
348
|
|
284
349
|
def browse_data_yaml(self):
|
285
|
-
"""Open a file dialog to select the data YAML file."""
|
286
|
-
# File dialog for selecting YAML file
|
350
|
+
"""Open a file dialog to select the data YAML file and populate advanced options."""
|
287
351
|
options = QFileDialog.Options()
|
288
352
|
file_path, _ = QFileDialog.getOpenFileName(
|
289
|
-
self,
|
290
|
-
"Select Data YAML",
|
291
|
-
"",
|
292
|
-
"YAML Files (*.yaml);;All Files (*)",
|
293
|
-
options=options
|
353
|
+
self, "Select Data YAML", "", "YAML Files (*.yaml);;All Files (*)", options=options
|
294
354
|
)
|
295
|
-
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
|
+
|
296
366
|
self.yaml_path_label.setText(file_path)
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
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)
|
305
387
|
|
306
388
|
def browse_output_dir(self):
|
307
389
|
"""Open a dialog to select the output directory."""
|
308
|
-
|
309
|
-
dir_path = QFileDialog.getExistingDirectory(
|
310
|
-
self, "Select Output Directory")
|
390
|
+
dir_path = QFileDialog.getExistingDirectory(self, "Select Output Directory")
|
311
391
|
if dir_path:
|
312
392
|
self.output_dir_label.setText(dir_path)
|
313
393
|
|
@@ -316,28 +396,22 @@ class Base(QDialog):
|
|
316
396
|
if not all([self.yaml_path_label.text(), self.output_dir_label.text(), self.output_folder_name.text()]):
|
317
397
|
QMessageBox.warning(self, "Error", "Please fill in all fields.")
|
318
398
|
return
|
319
|
-
|
320
|
-
# This check for existing output is still relevant
|
399
|
+
|
321
400
|
self.output_folder = os.path.join(self.output_dir_label.text(), self.output_folder_name.text())
|
322
401
|
if os.path.exists(self.output_folder) and os.listdir(self.output_folder):
|
323
|
-
reply = QMessageBox.question(self,
|
324
|
-
'Directory Not Empty',
|
325
|
-
f"The directory '{self.output_folder}' is not empty. Continue?",
|
402
|
+
reply = QMessageBox.question(self,
|
403
|
+
'Directory Not Empty',
|
404
|
+
f"The directory '{self.output_folder}' is not empty. Continue?",
|
326
405
|
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
|
327
|
-
if reply == QMessageBox.No:
|
328
|
-
return
|
406
|
+
if reply == QMessageBox.No: return
|
329
407
|
|
330
|
-
# Pre-scan for duplicates
|
331
|
-
yaml_path = self.yaml_path_label.text()
|
332
|
-
dir_path = os.path.dirname(yaml_path)
|
333
408
|
QApplication.setOverrideCursor(Qt.WaitCursor)
|
334
409
|
try:
|
335
|
-
image_paths = glob.glob(f"{
|
410
|
+
image_paths = glob.glob(f"{os.path.dirname(self.yaml_path_label.text())}/**/images/*.*", recursive=True)
|
336
411
|
finally:
|
337
412
|
QApplication.restoreOverrideCursor()
|
338
413
|
|
339
|
-
basenames = set()
|
340
|
-
duplicates_exist = False
|
414
|
+
basenames, duplicates_exist = set(), False
|
341
415
|
for path in image_paths:
|
342
416
|
basename_no_ext = os.path.splitext(os.path.basename(path))[0]
|
343
417
|
if basename_no_ext in basenames:
|
@@ -345,7 +419,6 @@ class Base(QDialog):
|
|
345
419
|
break
|
346
420
|
basenames.add(basename_no_ext)
|
347
421
|
|
348
|
-
# Default behavior is to overwrite, but we will confirm with the user if conflicts exist.
|
349
422
|
rename_files = False
|
350
423
|
if duplicates_exist:
|
351
424
|
msg_box = QMessageBox(self)
|
@@ -357,51 +430,63 @@ class Base(QDialog):
|
|
357
430
|
)
|
358
431
|
msg_box.setInformativeText("How would you like to handle these conflicts?")
|
359
432
|
|
360
|
-
# Add
|
361
|
-
rename_button = msg_box.addButton(
|
362
|
-
|
363
|
-
|
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
|
+
)
|
364
446
|
|
365
447
|
msg_box.setDefaultButton(rename_button)
|
366
448
|
msg_box.exec_()
|
367
449
|
|
368
450
|
clicked_button = msg_box.clickedButton()
|
369
|
-
|
370
451
|
if clicked_button == cancel_button:
|
371
|
-
return
|
452
|
+
return
|
372
453
|
elif clicked_button == rename_button:
|
373
454
|
rename_files = True
|
374
455
|
elif clicked_button == overwrite_button:
|
375
456
|
rename_files = False
|
376
|
-
else:
|
457
|
+
else:
|
377
458
|
return
|
378
|
-
|
459
|
+
|
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'
|
466
|
+
|
379
467
|
self.button_box.setEnabled(False)
|
380
468
|
QApplication.setOverrideCursor(Qt.WaitCursor)
|
381
|
-
|
382
469
|
self.progress_bar = ProgressBar(self, title="Preparing to Import...")
|
383
470
|
self.progress_bar.show()
|
384
471
|
|
385
|
-
|
386
|
-
import_as_text = self.import_as_combo.currentText()
|
387
|
-
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'
|
388
473
|
|
389
474
|
self.thread = QThread()
|
390
475
|
self.worker = DatasetProcessor(
|
391
476
|
yaml_path=self.yaml_path_label.text(),
|
392
477
|
output_folder=self.output_folder,
|
393
478
|
task=self.task,
|
394
|
-
import_as=import_as,
|
395
|
-
rename_on_conflict=rename_files
|
479
|
+
import_as=import_as,
|
480
|
+
rename_on_conflict=rename_files,
|
481
|
+
excluded_classes=excluded_classes,
|
482
|
+
image_import_policy=image_import_policy
|
396
483
|
)
|
397
484
|
self.worker.moveToThread(self.thread)
|
398
|
-
|
399
485
|
self.thread.started.connect(self.worker.run)
|
400
486
|
self.worker.finished.connect(self.on_worker_finished)
|
401
487
|
self.worker.error.connect(self.on_error)
|
402
488
|
self.worker.status_changed.connect(self.on_status_changed)
|
403
489
|
self.worker.progress_updated.connect(self.on_progress_update)
|
404
|
-
# Connect to the updated signal
|
405
490
|
self.worker.processing_complete.connect(self.on_processing_complete)
|
406
491
|
self.thread.start()
|
407
492
|
|
@@ -413,65 +498,72 @@ class Base(QDialog):
|
|
413
498
|
self.progress_bar.set_value(value)
|
414
499
|
|
415
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
|
+
|
416
504
|
added_paths = []
|
505
|
+
progress_bar.set_title(f"Adding {len(image_paths)} images...")
|
506
|
+
progress_bar.start_progress(len(image_paths))
|
417
507
|
for path in image_paths:
|
418
508
|
if self.image_window.add_image(path):
|
419
509
|
added_paths.append(path)
|
510
|
+
progress_bar.update_progress()
|
420
511
|
|
421
512
|
newly_created_annotations = []
|
513
|
+
progress_bar.set_title(f"Adding {len(raw_annotations)} annotations...")
|
514
|
+
progress_bar.start_progress(len(raw_annotations))
|
422
515
|
for raw_ann in raw_annotations:
|
423
|
-
label = self.main_window.label_window.add_label_if_not_exists(
|
424
|
-
raw_ann["class_name"])
|
516
|
+
label = self.main_window.label_window.add_label_if_not_exists(raw_ann["class_name"])
|
425
517
|
if raw_ann["type"] == "RectangleAnnotation":
|
426
518
|
tl, br = raw_ann["top_left"], raw_ann["bottom_right"]
|
427
519
|
annotation = RectangleAnnotation(QPointF(tl[0], tl[1]),
|
428
|
-
QPointF(br[0], br[1]),
|
520
|
+
QPointF(br[0], br[1]),
|
429
521
|
label.short_label_code,
|
430
522
|
label.long_label_code,
|
431
|
-
label.color,
|
523
|
+
label.color,
|
432
524
|
raw_ann["image_path"],
|
433
525
|
label.id,
|
434
526
|
self.main_window.get_transparency_value())
|
435
|
-
else:
|
527
|
+
else:
|
436
528
|
points = [QPointF(p[0], p[1]) for p in raw_ann["points"]]
|
437
|
-
annotation = PolygonAnnotation(points,
|
438
|
-
label.short_label_code,
|
439
|
-
label.long_label_code,
|
440
|
-
label.color,
|
529
|
+
annotation = PolygonAnnotation(points,
|
530
|
+
label.short_label_code,
|
531
|
+
label.long_label_code,
|
532
|
+
label.color,
|
441
533
|
raw_ann["image_path"],
|
442
534
|
label.id,
|
443
535
|
self.main_window.get_transparency_value())
|
444
|
-
|
536
|
+
|
445
537
|
self.annotation_window.add_annotation_to_dict(annotation)
|
446
|
-
newly_created_annotations.append(annotation) #
|
538
|
+
newly_created_annotations.append(annotation) # Collect created objects
|
539
|
+
|
540
|
+
progress_bar.update_progress()
|
447
541
|
|
448
|
-
# ---
|
449
|
-
|
450
|
-
self.
|
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()
|
451
549
|
|
452
550
|
self.image_window.filter_images()
|
453
|
-
|
454
551
|
if added_paths:
|
455
552
|
self.image_window.load_image_by_path(added_paths[-1])
|
456
553
|
self.image_window.update_image_annotations(added_paths[-1])
|
457
554
|
self.annotation_window.load_annotations()
|
458
555
|
|
459
|
-
# --- Display a summary message, including any parsing errors ---
|
460
556
|
summary_message = "Dataset has been successfully imported."
|
461
557
|
if parsing_errors:
|
462
|
-
# If there were errors, show a more detailed dialog
|
463
558
|
QMessageBox.warning(self,
|
464
|
-
"Import Complete with Warnings",
|
465
|
-
f"{summary_message}\n\nHowever, {len(parsing_errors)} issue(s) were found "
|
466
|
-
"
|
559
|
+
"Import Complete with Warnings",
|
560
|
+
f"{summary_message}\n\nHowever, {len(parsing_errors)} issue(s) were found. "
|
561
|
+
"Please review them below.",
|
467
562
|
details='\n'.join(parsing_errors))
|
468
563
|
else:
|
469
|
-
|
470
|
-
|
471
|
-
|
472
|
-
summary_message)
|
473
|
-
|
474
|
-
def export_annotations_to_json(self, annotations_list, output_dir):
|
564
|
+
QMessageBox.information(self, "Dataset Imported", summary_message)
|
565
|
+
|
566
|
+
def _export_annotations_to_json(self, annotations_list, output_dir):
|
475
567
|
"""
|
476
568
|
Merges the list of annotation objects into an existing annotations.json file,
|
477
569
|
or creates a new one if it doesn't exist.
|
@@ -527,7 +619,7 @@ class Base(QDialog):
|
|
527
619
|
file.flush()
|
528
620
|
except Exception as e:
|
529
621
|
QMessageBox.critical(self, "Export Error", f"Failed to write annotations.json:\n{e}")
|
530
|
-
|
622
|
+
|
531
623
|
def on_error(self, message):
|
532
624
|
QMessageBox.warning(self, "Error", message)
|
533
625
|
|
@@ -554,4 +646,4 @@ class Base(QDialog):
|
|
554
646
|
|
555
647
|
def closeEvent(self, event):
|
556
648
|
self.reject()
|
557
|
-
super().closeEvent(event)
|
649
|
+
super().closeEvent(event)
|