YoloDatasetCustomizer 0.3.0__tar.gz → 0.4.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.

Potentially problematic release.


This version of YoloDatasetCustomizer might be problematic. Click here for more details.

Files changed (17) hide show
  1. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.4.0}/.gitattributes +0 -0
  2. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.4.0}/.github/workflows/ci.yml +0 -0
  3. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.4.0}/.gitignore +0 -0
  4. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.4.0}/.python-version +0 -0
  5. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.4.0}/LICENSE +0 -0
  6. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.4.0}/PKG-INFO +17 -10
  7. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.4.0}/README.md +17 -11
  8. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.4.0}/pyproject.toml +2 -1
  9. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.4.0}/resources/Improvements.txt +0 -0
  10. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.4.0}/src/YoloDatasetCustomizer/__init__.py +1 -1
  11. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.4.0}/src/YoloDatasetCustomizer/customizer.py +86 -37
  12. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.4.0}/src/YoloDatasetCustomizer/labels.py +0 -0
  13. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.4.0}/src/YoloDatasetCustomizer/paths.py +0 -0
  14. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.4.0}/src/YoloDatasetCustomizer/reader.py +0 -0
  15. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.4.0}/src/YoloDatasetCustomizer/writer.py +0 -0
  16. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.4.0}/test/YoloDatasetCustomizer_test.py +21 -0
  17. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.4.0}/uv.lock +8 -4
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: YoloDatasetCustomizer
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -23,6 +23,7 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
23
23
  Classifier: Topic :: Scientific/Engineering :: Image Recognition
24
24
  Requires-Python: >=3.10
25
25
  Requires-Dist: pyyaml>=6.0.3
26
+ Requires-Dist: rich>=13.0.0
26
27
  Description-Content-Type: text/markdown
27
28
 
28
29
  # Intro
@@ -42,15 +43,21 @@ If a path ends on _data.yaml_ it will take that file into account.
42
43
  If a path doesn't end on _/data.yaml_ it will recursevly look for all _data.yaml_ files from that directory downards.
43
44
  e.g.
44
45
 
45
- from YoloDatasetCustomizer import YoloDatasetCustomizer
46
+ ```python
47
+ from YoloDatasetCustomizer import YoloDatasetCustomizer
46
48
 
47
- ydc = YoloDatasetCustomizer(list_to_existing_datasets)
48
- ydc.add([one_more_existing_dataset])
49
+ ydc = YoloDatasetCustomizer(list_to_existing_datasets)
50
+ ydc.add_data_sets([one_more_existing_dataset])
49
51
 
50
- if ydc.create_new_dataset_for_class_names(set_of_wanted_class_names, optional_custom_path, optional_custom_name):
51
- print("All good")
52
- else:
53
- print("Oh no.")
52
+ if ydc.create_new_dataset_for_class_names(
53
+ class_names=<set_of_wanted_class_names>,
54
+ dst_path=<optional_custom_path>,
55
+ data_set_name=<optional_custom_name>,
56
+ ):
57
+ print("All good")
58
+ else:
59
+ print("Oh no.")
60
+ ```
54
61
 
55
62
  # Run tests
56
63
 
@@ -65,11 +72,11 @@ The project uses uv. Install dependencies and run tests with:
65
72
 
66
73
  # Build project
67
74
 
68
- Build the package
75
+ Build the package:
69
76
 
70
77
  uv build
71
78
 
72
- Publish to PyPI (still need twine)
79
+ Publish to PyPI (still need twine):
73
80
 
74
81
  uv pip install twine
75
82
  uv run twine upload dist/\*
@@ -15,15 +15,21 @@ If a path ends on _data.yaml_ it will take that file into account.
15
15
  If a path doesn't end on _/data.yaml_ it will recursevly look for all _data.yaml_ files from that directory downards.
16
16
  e.g.
17
17
 
18
- from YoloDatasetCustomizer import YoloDatasetCustomizer
19
-
20
- ydc = YoloDatasetCustomizer(list_to_existing_datasets)
21
- ydc.add([one_more_existing_dataset])
22
-
23
- if ydc.create_new_dataset_for_class_names(set_of_wanted_class_names, optional_custom_path, optional_custom_name):
24
- print("All good")
25
- else:
26
- print("Oh no.")
18
+ ```python
19
+ from YoloDatasetCustomizer import YoloDatasetCustomizer
20
+
21
+ ydc = YoloDatasetCustomizer(list_to_existing_datasets)
22
+ ydc.add_data_sets([one_more_existing_dataset])
23
+
24
+ if ydc.create_new_dataset_for_class_names(
25
+ class_names=<set_of_wanted_class_names>,
26
+ dst_path=<optional_custom_path>,
27
+ data_set_name=<optional_custom_name>,
28
+ ):
29
+ print("All good")
30
+ else:
31
+ print("Oh no.")
32
+ ```
27
33
 
28
34
  # Run tests
29
35
 
@@ -38,11 +44,11 @@ The project uses uv. Install dependencies and run tests with:
38
44
 
39
45
  # Build project
40
46
 
41
- Build the package
47
+ Build the package:
42
48
 
43
49
  uv build
44
50
 
45
- Publish to PyPI (still need twine)
51
+ Publish to PyPI (still need twine):
46
52
 
47
53
  uv pip install twine
48
54
  uv run twine upload dist/\*
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "YoloDatasetCustomizer"
7
- version = "0.3.0"
7
+ version = "0.4.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"
@@ -30,6 +30,7 @@ classifiers = [
30
30
 
31
31
  dependencies = [
32
32
  "pyyaml>=6.0.3",
33
+ "rich>=13.0.0",
33
34
  ]
34
35
 
35
36
  [project.urls]
@@ -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.3.0"
8
+ __version__ = "0.4.0"
9
9
  __author__ = "Luca Mazzon"
10
10
  __email__ = "mazzon.luca@gmail.com"
11
11
 
@@ -3,6 +3,7 @@ import os
3
3
  import shutil
4
4
 
5
5
  import yaml
6
+ from rich.progress import BarColumn, MofNCompleteColumn, Progress, TaskProgressColumn, TextColumn
6
7
 
7
8
  from .labels import LabelFile
8
9
  from .paths import _SPLIT_NAMES, _get_unique_path, _normalize_path
@@ -86,6 +87,16 @@ class YoloDatasetCustomizer:
86
87
  new_class_indices = self.__generate_new_class_indices(class_names)
87
88
  print(f"INFO: New class indices: {new_class_indices}")
88
89
 
90
+ # Always create every split folder so the folder structure matches what data.yaml references,
91
+ # even when no source dataset contributed images to a split.
92
+ for split_name in _SPLIT_NAMES:
93
+ os.makedirs(os.path.join(new_dataset_path, split_name, "labels"), exist_ok=True)
94
+ os.makedirs(os.path.join(new_dataset_path, split_name, "images"), exist_ok=True)
95
+
96
+ # Plan first, so every stage knows how many files it is going to copy
97
+ planned_files: dict[str, list[tuple[LabelFile, dict[str, list[str]], dict[str, str]]]] = {
98
+ split_name: [] for split_name in _SPLIT_NAMES
99
+ }
89
100
  for data_set in self.__data_sets:
90
101
  common_class_names = set(data_set.get_class_names()) & class_names
91
102
  if not common_class_names:
@@ -112,46 +123,46 @@ class YoloDatasetCustomizer:
112
123
  print(f"WARNING: Path '{missing_path}' does not exist! Skipping it.")
113
124
  continue
114
125
 
115
- new_labels_path = os.path.join(new_dataset_path, split_name, "labels")
116
- new_images_path = os.path.join(new_dataset_path, split_name, "images")
117
- os.makedirs(new_labels_path, exist_ok=True)
118
- os.makedirs(new_images_path, exist_ok=True)
126
+ # Index images by exact stem so 'car.*' can't pick up e.g. 'car.v2.jpg'
127
+ images_by_stem: dict[str, list[str]] = {}
128
+ for image_name in os.listdir(old_images_path):
129
+ stem = os.path.splitext(image_name)[0]
130
+ images_by_stem.setdefault(stem, []).append(os.path.join(old_images_path, image_name))
119
131
 
120
132
  for original_label_file_path in glob.glob(os.path.join(old_labels_path, "*.txt")):
121
133
  label_file = LabelFile(original_label_file_path)
122
- new_label_file_path = label_file.copy_by_class_indices(old_to_new_index_match, new_labels_path)
123
- if new_label_file_path:
124
- file_name = os.path.basename(original_label_file_path)
125
- file_name_no_ext = file_name[: file_name.rfind(".")]
126
- corresponding_image_files = glob.glob(os.path.join(old_images_path, f"{file_name_no_ext}.*"))
127
- for img in corresponding_image_files:
128
- try:
129
- _, ext = os.path.splitext(img)
130
- except Exception:
131
- print(f"ERROR: Not a valid image, extension missing for: {img}")
132
- return False
133
-
134
- if not ignore_img_formats and ext.lower() not in self.SUPPORTED_IMAGE_FORMATS:
135
- print(
136
- f"ERROR: Image format not supported by yolo: {img}\n"
137
- f"Supported formats are: {self.SUPPORTED_IMAGE_FORMATS}"
138
- )
139
- return False
140
-
141
- new_image_name, _ = os.path.splitext(os.path.basename(new_label_file_path))
142
- new_image_name = new_image_name + ext
143
- destination = os.path.join(new_images_path, new_image_name)
144
- try:
145
- shutil.copyfile(img, destination)
146
- except OSError as e:
147
- print(
148
- f"ERROR: Image could not be copied:\n"
149
- f"Source: {img}\nDestination: {destination}\n"
150
- f"Exception was: {e}"
151
- )
152
- return False
153
-
154
- data_file_writer = YoloDataFileWriter(new_dataset_path, list(new_class_indices.keys()))
134
+ if label_file.get_class_indices() & set(old_to_new_index_match):
135
+ planned_files[split_name].append((label_file, images_by_stem, old_to_new_index_match))
136
+
137
+ # One bar per stage (white, filled green), all visible from the start
138
+ with Progress(
139
+ TextColumn("{task.description}"),
140
+ BarColumn(style="white", complete_style="green", finished_style="green"),
141
+ TaskProgressColumn(),
142
+ MofNCompleteColumn(),
143
+ ) as progress:
144
+ stage_tasks = {
145
+ split_name: progress.add_task(f"[{stage}/{len(_SPLIT_NAMES)}] {split_name:<5}", total=len(planned_files[split_name]))
146
+ for stage, split_name in enumerate(_SPLIT_NAMES, start=1)
147
+ }
148
+ for split_name in _SPLIT_NAMES:
149
+ new_labels_path = os.path.join(new_dataset_path, split_name, "labels")
150
+ new_images_path = os.path.join(new_dataset_path, split_name, "images")
151
+ for label_file, images_by_stem, old_to_new_index_match in planned_files[split_name]:
152
+ if not self.__copy_label_and_images(
153
+ label_file, images_by_stem, old_to_new_index_match, new_labels_path, new_images_path, ignore_img_formats
154
+ ):
155
+ return False
156
+ progress.advance(stage_tasks[split_name])
157
+
158
+ # Pass the split folder names explicitly so data.yaml points at the folders created above
159
+ data_file_writer = YoloDataFileWriter(
160
+ new_dataset_path,
161
+ list(new_class_indices.keys()),
162
+ train_split="train",
163
+ val_split="val",
164
+ test_split="test",
165
+ )
155
166
  try:
156
167
  data_file_writer.write()
157
168
  except Exception as e:
@@ -161,6 +172,44 @@ class YoloDatasetCustomizer:
161
172
 
162
173
  return True
163
174
 
175
+ def __copy_label_and_images(
176
+ self,
177
+ label_file: LabelFile,
178
+ images_by_stem: dict[str, list[str]],
179
+ old_to_new_index_match: dict[str, str],
180
+ new_labels_path: str,
181
+ new_images_path: str,
182
+ ignore_img_formats: bool,
183
+ ) -> bool:
184
+ """Copy one label file with remapped indices plus its images. Returns False on error."""
185
+ new_label_file_path = label_file.copy_by_class_indices(old_to_new_index_match, new_labels_path)
186
+ if not new_label_file_path:
187
+ return True
188
+
189
+ file_name_no_ext = os.path.splitext(os.path.basename(label_file.file_path))[0]
190
+ for img in images_by_stem.get(file_name_no_ext, []):
191
+ _, ext = os.path.splitext(img)
192
+
193
+ if not ignore_img_formats and ext.lower() not in self.SUPPORTED_IMAGE_FORMATS:
194
+ print(
195
+ f"\nERROR: Image format not supported by yolo: {img}\n"
196
+ f"Supported formats are: {self.SUPPORTED_IMAGE_FORMATS}"
197
+ )
198
+ return False
199
+
200
+ new_image_name, _ = os.path.splitext(os.path.basename(new_label_file_path))
201
+ destination = os.path.join(new_images_path, new_image_name + ext)
202
+ try:
203
+ shutil.copyfile(img, destination)
204
+ except OSError as e:
205
+ print(
206
+ f"\nERROR: Image could not be copied:\n"
207
+ f"Source: {img}\nDestination: {destination}\n"
208
+ f"Exception was: {e}"
209
+ )
210
+ return False
211
+ return True
212
+
164
213
  def __generate_new_class_indices(self, class_names: set[str]) -> dict[str, str]:
165
214
  """Generate a fresh label index mapping for the selected class names."""
166
215
  ret: dict[str, str] = {}
@@ -340,6 +340,27 @@ nc: 2
340
340
  new_image_path = os.path.join(new_dataset_path, "train", "images", "person_car.jpg")
341
341
  self.assertTrue(os.path.exists(new_image_path))
342
342
 
343
+ def test_merge_keeps_splits_separate_and_yaml_matches_folders(self):
344
+ customizer = YoloDatasetCustomizer([self.good_dataset_dir, self.reordered_names_dataset_dir])
345
+ name = "merged_splits_dataset"
346
+ self.assertTrue(
347
+ customizer.create_new_dataset_for_class_names({"person", "car"}, dst_path=self.workspace_root, data_set_name=name)
348
+ )
349
+ new_dataset_path = os.path.join(self.workspace_root, name)
350
+ with open(os.path.join(new_dataset_path, "data.yaml"), "r", encoding="utf-8") as f:
351
+ new_data = yaml.safe_load(f)
352
+
353
+ for key in ("train", "val", "test"):
354
+ self.assertTrue(os.path.isdir(os.path.join(new_dataset_path, new_data[key])), f"{key} -> {new_data[key]}")
355
+
356
+ # good_dataset: person_car in train, car in valid, person in test
357
+ def stems(split: str) -> set[str]:
358
+ return {os.path.splitext(n)[0].split("_")[0] for n in os.listdir(os.path.join(new_dataset_path, split, "labels"))}
359
+
360
+ self.assertIn("person", stems("test"))
361
+ self.assertNotIn("car", stems("test"))
362
+ self.assertNotIn("person", stems("val"))
363
+
343
364
  def test_yolo_dataset_customizer_returns_false_for_empty_class_names(self):
344
365
  customizer = YoloDatasetCustomizer([self.good_dataset_dir])
345
366
  self.assertFalse(customizer.create_new_dataset_for_class_names(set(), dst_path=self.workspace_root, data_set_name="empty_selection"))
@@ -372,7 +372,7 @@ name = "exceptiongroup"
372
372
  version = "1.3.1"
373
373
  source = { registry = "https://pypi.org/simple" }
374
374
  dependencies = [
375
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
375
+ { name = "typing-extensions" },
376
376
  ]
377
377
  sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
378
378
  wheels = [
@@ -405,7 +405,7 @@ name = "importlib-metadata"
405
405
  version = "9.0.0"
406
406
  source = { registry = "https://pypi.org/simple" }
407
407
  dependencies = [
408
- { name = "zipp", marker = "python_full_version < '3.15'" },
408
+ { name = "zipp" },
409
409
  ]
410
410
  sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" }
411
411
  wheels = [
@@ -1078,10 +1078,11 @@ wheels = [
1078
1078
 
1079
1079
  [[package]]
1080
1080
  name = "yolodatasetcustomizer"
1081
- version = "0.3.0"
1081
+ version = "0.4.0"
1082
1082
  source = { editable = "." }
1083
1083
  dependencies = [
1084
1084
  { name = "pyyaml" },
1085
+ { name = "rich" },
1085
1086
  ]
1086
1087
 
1087
1088
  [package.dev-dependencies]
@@ -1093,7 +1094,10 @@ dev = [
1093
1094
  ]
1094
1095
 
1095
1096
  [package.metadata]
1096
- requires-dist = [{ name = "pyyaml", specifier = ">=6.0.3" }]
1097
+ requires-dist = [
1098
+ { name = "pyyaml", specifier = ">=6.0.3" },
1099
+ { name = "rich", specifier = ">=13.0.0" },
1100
+ ]
1097
1101
 
1098
1102
  [package.metadata.requires-dev]
1099
1103
  dev = [