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.
Files changed (17) hide show
  1. {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/LICENSE +1 -1
  2. {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/PKG-INFO +1 -1
  3. {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/pyproject.toml +1 -1
  4. {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/__init__.py +1 -1
  5. {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/customizer.py +103 -13
  6. {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/test/YoloDatasetCustomizer_test.py +25 -0
  7. {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/uv.lock +1 -1
  8. {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/.gitattributes +0 -0
  9. {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/.github/workflows/ci.yml +0 -0
  10. {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/.gitignore +0 -0
  11. {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/.python-version +0 -0
  12. {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/README.md +0 -0
  13. {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/resources/Improvements.txt +0 -0
  14. {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/labels.py +0 -0
  15. {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/paths.py +0 -0
  16. {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/reader.py +0 -0
  17. {yolodatasetcustomizer-0.4.1 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/writer.py +0 -0
@@ -1,7 +1,7 @@
1
1
  GNU GENERAL PUBLIC LICENSE
2
2
  Version 3, 29 June 2007
3
3
 
4
- Copyright (C) 2024 Luca Mazzon
4
+ Copyright (C) 2026 Luca Mazzon
5
5
 
6
6
  This program is free software: you can redistribute it and/or modify
7
7
  it under the terms of the GNU General Public License as published by
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: YoloDatasetCustomizer
3
- Version: 0.4.1
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "YoloDatasetCustomizer"
7
- version = "0.4.1"
7
+ version = "0.5.0"
8
8
  description = "Load one or multiple datasets. Reduce, mix or merge into a custom dataset."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -5,7 +5,7 @@ from .labels import LabelFile
5
5
  from .reader import YoloDataFileReader
6
6
  from .writer import YoloDataFileWriter
7
7
 
8
- __version__ = "0.4.1"
8
+ __version__ = "0.5.0"
9
9
  __author__ = "Luca Mazzon"
10
10
  __email__ = "mazzon.luca@gmail.com"
11
11
 
@@ -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 path in paths_without_file_ending:
50
- self.__found_data_file_paths.update(glob.glob(os.path.join(path, "**", "data.yaml"), recursive=True))
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
- print(f"WARNING: Path '{missing_path}' does not exist! Skipping it.")
124
- continue
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
- for image_name in os.listdir(old_images_path):
130
- stem, ext = os.path.splitext(image_name)
131
- image_path = os.path.join(old_images_path, image_name)
132
- if not ext or not os.path.isfile(image_path):
133
- continue
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
- for original_label_file_path in glob.glob(os.path.join(old_labels_path, "*.txt")):
137
- label_file = LabelFile(original_label_file_path)
138
- if label_file.get_class_indices() & set(old_to_new_index_match):
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] = {}
@@ -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")
@@ -1078,7 +1078,7 @@ wheels = [
1078
1078
 
1079
1079
  [[package]]
1080
1080
  name = "yolodatasetcustomizer"
1081
- version = "0.4.1"
1081
+ version = "0.5.0"
1082
1082
  source = { editable = "." }
1083
1083
  dependencies = [
1084
1084
  { name = "pyyaml" },