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.
- coralnet_toolbox/Annotations/QtAnnotation.py +28 -69
- coralnet_toolbox/Annotations/QtMaskAnnotation.py +408 -0
- coralnet_toolbox/Annotations/QtMultiPolygonAnnotation.py +72 -56
- coralnet_toolbox/Annotations/QtPatchAnnotation.py +165 -216
- coralnet_toolbox/Annotations/QtPolygonAnnotation.py +497 -353
- coralnet_toolbox/Annotations/QtRectangleAnnotation.py +126 -116
- coralnet_toolbox/CoralNet/QtDownload.py +2 -1
- coralnet_toolbox/Explorer/QtDataItem.py +52 -22
- coralnet_toolbox/Explorer/QtExplorer.py +293 -1614
- coralnet_toolbox/Explorer/QtSettingsWidgets.py +203 -85
- 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/IO/QtExportTagLabAnnotations.py +30 -10
- coralnet_toolbox/IO/QtImportTagLabAnnotations.py +21 -15
- coralnet_toolbox/IO/QtOpenProject.py +46 -78
- coralnet_toolbox/IO/QtSaveProject.py +18 -43
- coralnet_toolbox/MachineLearning/ExportDataset/QtBase.py +1 -1
- coralnet_toolbox/MachineLearning/ImportDataset/QtBase.py +253 -141
- coralnet_toolbox/MachineLearning/VideoInference/QtBase.py +0 -4
- coralnet_toolbox/MachineLearning/VideoInference/YOLO3D/run.py +102 -16
- coralnet_toolbox/QtAnnotationWindow.py +16 -10
- coralnet_toolbox/QtEventFilter.py +11 -0
- coralnet_toolbox/QtImageWindow.py +120 -75
- coralnet_toolbox/QtLabelWindow.py +13 -1
- coralnet_toolbox/QtMainWindow.py +5 -27
- coralnet_toolbox/QtProgressBar.py +52 -27
- coralnet_toolbox/Rasters/RasterTableModel.py +28 -8
- coralnet_toolbox/SAM/QtDeployGenerator.py +1 -4
- coralnet_toolbox/SAM/QtDeployPredictor.py +11 -3
- coralnet_toolbox/SeeAnything/QtDeployGenerator.py +805 -162
- coralnet_toolbox/SeeAnything/QtDeployPredictor.py +130 -151
- coralnet_toolbox/Tools/QtCutSubTool.py +18 -2
- coralnet_toolbox/Tools/QtPolygonTool.py +42 -3
- coralnet_toolbox/Tools/QtRectangleTool.py +30 -0
- coralnet_toolbox/Tools/QtResizeSubTool.py +19 -2
- coralnet_toolbox/Tools/QtSAMTool.py +72 -50
- coralnet_toolbox/Tools/QtSeeAnythingTool.py +8 -5
- coralnet_toolbox/Tools/QtSelectTool.py +27 -3
- coralnet_toolbox/Tools/QtSubtractSubTool.py +66 -0
- coralnet_toolbox/Tools/__init__.py +2 -0
- coralnet_toolbox/__init__.py +1 -1
- coralnet_toolbox/utilities.py +158 -47
- coralnet_toolbox-0.0.75.dist-info/METADATA +378 -0
- {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/RECORD +49 -44
- coralnet_toolbox-0.0.73.dist-info/METADATA +0 -341
- {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/WHEEL +0 -0
- {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/entry_points.txt +0 -0
- {coralnet_toolbox-0.0.73.dist-info → coralnet_toolbox-0.0.75.dist-info}/licenses/LICENSE.txt +0 -0
- {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,
|
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
|
-
|
64
|
-
|
65
|
-
if not
|
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:
|
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
|
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
|
-
|
110
|
-
|
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
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
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
|
-
#
|
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
|
-
|
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(
|
169
|
-
|
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:
|
210
|
+
if 'top_left' in parsed_data:
|
176
211
|
raw_ann_data.update(parsed_data)
|
177
|
-
else:
|
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:
|
220
|
+
if 'points' in parsed_data:
|
187
221
|
raw_ann_data.update(parsed_data)
|
188
|
-
else:
|
189
|
-
tl = parsed_data['top_left']
|
190
|
-
|
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
|
-
|
199
|
-
|
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,
|
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
|
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
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
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
|
-
|
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
|
-
|
314
|
-
|
315
|
-
|
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"{
|
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
|
344
|
-
rename_button = msg_box.addButton(
|
345
|
-
|
346
|
-
|
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
|
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:
|
457
|
+
else:
|
360
458
|
return
|
361
459
|
|
362
|
-
|
363
|
-
if
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
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
|
-
|
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,
|
387
|
-
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
|
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:
|
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) #
|
538
|
+
newly_created_annotations.append(annotation) # Collect created objects
|
539
|
+
|
540
|
+
progress_bar.update_progress()
|
438
541
|
|
439
|
-
# ---
|
440
|
-
|
441
|
-
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()
|
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
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
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)."""
|