YoloDatasetCustomizer 0.4.1__tar.gz → 0.5.0__tar.gz
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.
- {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/LICENSE +1 -1
- {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/PKG-INFO +1 -1
- {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/pyproject.toml +1 -1
- {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/__init__.py +1 -1
- {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/customizer.py +103 -13
- {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/test/YoloDatasetCustomizer_test.py +25 -0
- {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/uv.lock +1 -1
- {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/.gitattributes +0 -0
- {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/.github/workflows/ci.yml +0 -0
- {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/.gitignore +0 -0
- {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/.python-version +0 -0
- {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/README.md +0 -0
- {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/resources/Improvements.txt +0 -0
- {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/labels.py +0 -0
- {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/paths.py +0 -0
- {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/reader.py +0 -0
- {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/writer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: YoloDatasetCustomizer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Load one or multiple datasets. Reduce, mix or merge into a custom dataset.
|
|
5
5
|
Project-URL: Homepage, https://github.com/mLuca/YoloDatasetCustomizer
|
|
6
6
|
Project-URL: Repository, https://github.com/mLuca/YoloDatasetCustomizer
|
{yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/customizer.py
RENAMED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import glob
|
|
2
2
|
import os
|
|
3
3
|
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
4
6
|
|
|
5
7
|
import yaml
|
|
8
|
+
from rich.console import Console
|
|
6
9
|
from rich.progress import BarColumn, MofNCompleteColumn, Progress, TaskProgressColumn, TextColumn
|
|
7
10
|
|
|
8
11
|
from .labels import LabelFile
|
|
@@ -46,8 +49,9 @@ class YoloDatasetCustomizer:
|
|
|
46
49
|
paths_without_file_ending = [
|
|
47
50
|
path for path in data_set_paths if os.path.basename(path).lower() != "data.yaml"
|
|
48
51
|
]
|
|
49
|
-
for
|
|
50
|
-
|
|
52
|
+
with Console().status("Searching for data.yaml files..."):
|
|
53
|
+
for path in paths_without_file_ending:
|
|
54
|
+
self.__found_data_file_paths.update(glob.glob(os.path.join(path, "**", "data.yaml"), recursive=True))
|
|
51
55
|
|
|
52
56
|
for path in self.__found_data_file_paths:
|
|
53
57
|
try:
|
|
@@ -58,6 +62,23 @@ class YoloDatasetCustomizer:
|
|
|
58
62
|
except FileNotFoundError:
|
|
59
63
|
print(f"ERROR: Yaml file does not exist at {path}")
|
|
60
64
|
|
|
65
|
+
@staticmethod
|
|
66
|
+
def __read_label_files(label_paths: list[str], description: str) -> list[LabelFile]:
|
|
67
|
+
"""Parse label files in parallel (I/O bound) while showing a progress bar."""
|
|
68
|
+
label_files: list[LabelFile] = []
|
|
69
|
+
with Progress(
|
|
70
|
+
TextColumn("{task.description}"),
|
|
71
|
+
BarColumn(),
|
|
72
|
+
MofNCompleteColumn(),
|
|
73
|
+
transient=True,
|
|
74
|
+
) as progress:
|
|
75
|
+
task = progress.add_task(description, total=len(label_paths))
|
|
76
|
+
with ThreadPoolExecutor(max_workers=16) as pool:
|
|
77
|
+
for label_file in pool.map(LabelFile, label_paths):
|
|
78
|
+
label_files.append(label_file)
|
|
79
|
+
progress.advance(task)
|
|
80
|
+
return label_files
|
|
81
|
+
|
|
61
82
|
def get_found_data_file_paths(self) -> set[str]:
|
|
62
83
|
"""Return all discovered YOLO dataset YAML file paths."""
|
|
63
84
|
return self.__found_data_file_paths
|
|
@@ -97,6 +118,7 @@ class YoloDatasetCustomizer:
|
|
|
97
118
|
planned_files: dict[str, list[tuple[LabelFile, dict[str, list[str]], dict[str, str]]]] = {
|
|
98
119
|
split_name: [] for split_name in _SPLIT_NAMES
|
|
99
120
|
}
|
|
121
|
+
fallback_choice: str | None = None # Sticky answer: "all" or "none"
|
|
100
122
|
for data_set in self.__data_sets:
|
|
101
123
|
common_class_names = set(data_set.get_class_names()) & class_names
|
|
102
124
|
if not common_class_names:
|
|
@@ -120,22 +142,53 @@ class YoloDatasetCustomizer:
|
|
|
120
142
|
(path for path in (old_labels_path, old_images_path) if not os.path.exists(path)), None
|
|
121
143
|
)
|
|
122
144
|
if missing_path is not None:
|
|
123
|
-
|
|
124
|
-
|
|
145
|
+
fallback_images_path = self.__find_fallback_images_path(data_set, split_path)
|
|
146
|
+
if fallback_images_path is None:
|
|
147
|
+
print(
|
|
148
|
+
f"WARNING: Path '{missing_path}' from '{data_set.get_file_path()}' "
|
|
149
|
+
f"does not exist! Skipping it."
|
|
150
|
+
)
|
|
151
|
+
continue
|
|
152
|
+
try:
|
|
153
|
+
choice = fallback_choice or self.__ask_use_fallback(
|
|
154
|
+
data_set.get_file_path(), split_path, fallback_images_path
|
|
155
|
+
)
|
|
156
|
+
except EOFError:
|
|
157
|
+
print(
|
|
158
|
+
"ERROR: Cannot prompt for Roboflow fallback because standard input is closed. "
|
|
159
|
+
"Dataset creation aborted."
|
|
160
|
+
)
|
|
161
|
+
return False
|
|
162
|
+
if choice in ("all", "none"):
|
|
163
|
+
fallback_choice = choice
|
|
164
|
+
if choice == "abort":
|
|
165
|
+
print("INFO: Aborted by user.")
|
|
166
|
+
return False
|
|
167
|
+
if choice in ("no", "none"):
|
|
168
|
+
print(f"WARNING: Skipping '{split_path}' of '{data_set.get_file_path()}'.")
|
|
169
|
+
continue
|
|
170
|
+
old_images_path = fallback_images_path
|
|
171
|
+
old_labels_path = self.__generate_label_path_from_img_path(old_images_path)
|
|
125
172
|
|
|
126
173
|
# Index images by exact stem so 'car.*' can't pick up e.g. 'car.v2.jpg'.
|
|
127
174
|
# Only regular files with an extension count, like the old 'car.*' lookup.
|
|
128
175
|
images_by_stem: dict[str, list[str]] = {}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
images_by_stem.setdefault(stem, []).append(image_path)
|
|
176
|
+
with os.scandir(old_images_path) as entries:
|
|
177
|
+
for entry in entries:
|
|
178
|
+
stem, ext = os.path.splitext(entry.name)
|
|
179
|
+
if ext and entry.is_file():
|
|
180
|
+
images_by_stem.setdefault(stem, []).append(entry.path)
|
|
135
181
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
182
|
+
with os.scandir(old_labels_path) as entries:
|
|
183
|
+
label_paths = [
|
|
184
|
+
e.path for e in entries
|
|
185
|
+
if os.path.normcase(e.name).endswith(".txt") and e.is_file()
|
|
186
|
+
]
|
|
187
|
+
wanted_indices = set(old_to_new_index_match)
|
|
188
|
+
for label_file in self.__read_label_files(
|
|
189
|
+
label_paths, f"Scanning {split_name:<5} labels of {data_set.get_file_path()}"
|
|
190
|
+
):
|
|
191
|
+
if label_file.get_class_indices() & wanted_indices:
|
|
139
192
|
planned_files[split_name].append((label_file, images_by_stem, old_to_new_index_match))
|
|
140
193
|
|
|
141
194
|
# One bar per stage (white, filled green), all visible from the start
|
|
@@ -222,6 +275,43 @@ class YoloDatasetCustomizer:
|
|
|
222
275
|
return False
|
|
223
276
|
return True
|
|
224
277
|
|
|
278
|
+
def __find_fallback_images_path(self, data_set: YoloDataFileReader, split_path: str) -> str | None:
|
|
279
|
+
"""Return the images path with leading '../' removed (legacy Roboflow style) if it and its labels exist."""
|
|
280
|
+
stripped = split_path.replace("\\", "/")
|
|
281
|
+
while stripped.startswith("../"):
|
|
282
|
+
stripped = stripped[3:]
|
|
283
|
+
if stripped == split_path.replace("\\", "/"):
|
|
284
|
+
return None
|
|
285
|
+
images_path = os.path.join(_normalize_path(data_set.get_file_dir()), stripped)
|
|
286
|
+
labels_path = self.__generate_label_path_from_img_path(images_path)
|
|
287
|
+
if os.path.isdir(images_path) and os.path.isdir(labels_path):
|
|
288
|
+
return os.path.normpath(images_path)
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
def __ask_use_fallback(yaml_path: str, split_path: str, fallback_path: str) -> str:
|
|
293
|
+
"""Ask the user whether to use the fallback folder. Returns yes/all/no/none/abort."""
|
|
294
|
+
options = {"1": "yes", "2": "all", "3": "no", "4": "none", "5": "abort"}
|
|
295
|
+
print(
|
|
296
|
+
f"'{split_path}' in '{yaml_path}' does not exist, but was found at:\n {fallback_path}\n"
|
|
297
|
+
f"Use it? [1] Yes [2] All [3] No [4] None [5] Abort: ",
|
|
298
|
+
end="",
|
|
299
|
+
flush=True,
|
|
300
|
+
)
|
|
301
|
+
while True:
|
|
302
|
+
# getwch() bypasses stdin, so only use it for an interactive console
|
|
303
|
+
if sys.platform == "win32" and sys.stdin is not None and sys.stdin.isatty():
|
|
304
|
+
import msvcrt
|
|
305
|
+
|
|
306
|
+
key = msvcrt.getwch()
|
|
307
|
+
if key == "\x03":
|
|
308
|
+
raise KeyboardInterrupt
|
|
309
|
+
else:
|
|
310
|
+
key = (input() or " ")[0]
|
|
311
|
+
if key in options:
|
|
312
|
+
print(key)
|
|
313
|
+
return options[key]
|
|
314
|
+
|
|
225
315
|
def __generate_new_class_indices(self, class_names: set[str]) -> dict[str, str]:
|
|
226
316
|
"""Generate a fresh label index mapping for the selected class names."""
|
|
227
317
|
ret: dict[str, str] = {}
|
{yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/test/YoloDatasetCustomizer_test.py
RENAMED
|
@@ -3,6 +3,8 @@ import shutil
|
|
|
3
3
|
import sys
|
|
4
4
|
import tempfile
|
|
5
5
|
import unittest
|
|
6
|
+
from contextlib import redirect_stdout
|
|
7
|
+
from io import StringIO
|
|
6
8
|
from unittest.mock import patch
|
|
7
9
|
|
|
8
10
|
import yaml
|
|
@@ -316,6 +318,29 @@ nc: 2
|
|
|
316
318
|
)
|
|
317
319
|
self.assertFalse(result)
|
|
318
320
|
|
|
321
|
+
def test_create_new_dataset_reports_closed_stdin_for_roboflow_fallback(self):
|
|
322
|
+
with tempfile.TemporaryDirectory(prefix="yolodatasetcustomizer_roboflow_") as tmp:
|
|
323
|
+
dataset_dir = os.path.join(tmp, "dataset")
|
|
324
|
+
for split in ("train", "valid", "test"):
|
|
325
|
+
for kind in ("images", "labels"):
|
|
326
|
+
os.makedirs(os.path.join(dataset_dir, split, kind), exist_ok=True)
|
|
327
|
+
|
|
328
|
+
self._write_text_file(
|
|
329
|
+
os.path.join(dataset_dir, "data.yaml"),
|
|
330
|
+
"train: ../train/images\nval: valid/images\ntest: test/images\nnames: [person]\nnc: 1\n",
|
|
331
|
+
)
|
|
332
|
+
customizer = YoloDatasetCustomizer([dataset_dir])
|
|
333
|
+
output = StringIO()
|
|
334
|
+
with patch("builtins.input", side_effect=EOFError), redirect_stdout(output):
|
|
335
|
+
result = customizer.create_new_dataset_for_class_names(
|
|
336
|
+
{"person"}, dst_path=tmp, data_set_name="output"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
self.assertFalse(result)
|
|
340
|
+
self.assertIn("standard input is closed", output.getvalue())
|
|
341
|
+
self.assertIn("Dataset creation aborted.", output.getvalue())
|
|
342
|
+
self.assertNotIn("Aborted by user.", output.getvalue())
|
|
343
|
+
|
|
319
344
|
def test_yolo_dataset_customizer_creates_filtered_dataset(self):
|
|
320
345
|
customizer = YoloDatasetCustomizer([self.good_dataset_dir])
|
|
321
346
|
success = customizer.create_new_dataset_for_class_names({"car"}, dst_path=self.workspace_root, data_set_name="filtered_car_dataset")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/labels.py
RENAMED
|
File without changes
|
{yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/paths.py
RENAMED
|
File without changes
|
{yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/reader.py
RENAMED
|
File without changes
|
{yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/writer.py
RENAMED
|
File without changes
|