YoloDatasetCustomizer 0.3.0__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 (18) hide show
  1. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.5.0}/.gitattributes +0 -0
  2. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.5.0}/.github/workflows/ci.yml +0 -0
  3. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.5.0}/.gitignore +0 -0
  4. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.5.0}/.python-version +0 -0
  5. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.5.0}/LICENSE +1 -1
  6. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.5.0}/PKG-INFO +17 -10
  7. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.5.0}/README.md +17 -11
  8. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.5.0}/pyproject.toml +59 -58
  9. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.5.0}/resources/Improvements.txt +0 -0
  10. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/__init__.py +1 -1
  11. yolodatasetcustomizer-0.5.0/src/YoloDatasetCustomizer/customizer.py +325 -0
  12. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/labels.py +0 -0
  13. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/paths.py +0 -0
  14. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/reader.py +0 -0
  15. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.5.0}/src/YoloDatasetCustomizer/writer.py +0 -0
  16. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.5.0}/test/YoloDatasetCustomizer_test.py +68 -0
  17. {yolodatasetcustomizer-0.3.0 → yolodatasetcustomizer-0.5.0}/uv.lock +1117 -1113
  18. yolodatasetcustomizer-0.3.0/src/YoloDatasetCustomizer/customizer.py +0 -174
@@ -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.3.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
@@ -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/\*
@@ -1,58 +1,59 @@
1
- [build-system]
2
- requires = ["hatchling"]
3
- build-backend = "hatchling.build"
4
-
5
- [project]
6
- name = "YoloDatasetCustomizer"
7
- version = "0.3.0"
8
- description = "Load one or multiple datasets. Reduce, mix or merge into a custom dataset."
9
- readme = "README.md"
10
- requires-python = ">=3.10"
11
- license = {text = "GPL-3.0-only"}
12
- authors = [
13
- {name = "Luca Mazzon", email = "mazzon.luca@gmail.com"},
14
- ]
15
- keywords = ["ultralytics", "yolo", "dataset", "machine-learning", "computer-vision", "customization"]
16
- classifiers = [
17
- "Development Status :: 5 - Production/Stable",
18
- "Intended Audience :: Developers",
19
- "Intended Audience :: Science/Research",
20
- "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
21
- "Programming Language :: Python :: 3",
22
- "Programming Language :: Python :: 3.10",
23
- "Programming Language :: Python :: 3.11",
24
- "Programming Language :: Python :: 3.12",
25
- "Programming Language :: Python :: 3.13",
26
- "Programming Language :: Python :: 3.14",
27
- "Topic :: Scientific/Engineering :: Artificial Intelligence",
28
- "Topic :: Scientific/Engineering :: Image Recognition",
29
- ]
30
-
31
- dependencies = [
32
- "pyyaml>=6.0.3",
33
- ]
34
-
35
- [project.urls]
36
- Homepage = "https://github.com/mLuca/YoloDatasetCustomizer"
37
- Repository = "https://github.com/mLuca/YoloDatasetCustomizer"
38
- "Issue Tracker" = "https://github.com/mLuca/YoloDatasetCustomizer/issues"
39
-
40
- [dependency-groups]
41
- dev = [
42
- "pytest>=9.1.1",
43
- "twine>=6.2.0",
44
- "ruff>=0.14.0",
45
- "mypy>=1.18.0",
46
- ]
47
-
48
- [tool.ruff]
49
- line-length = 120
50
-
51
- [tool.ruff.lint]
52
- select = ["E", "F", "W", "I"]
53
- ignore = ["E501"]
54
-
55
- [tool.mypy]
56
- python_version = "3.10"
57
- files = ["src"]
58
- ignore_missing_imports = true
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "YoloDatasetCustomizer"
7
+ version = "0.5.0"
8
+ description = "Load one or multiple datasets. Reduce, mix or merge into a custom dataset."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "GPL-3.0-only"}
12
+ authors = [
13
+ {name = "Luca Mazzon", email = "mazzon.luca@gmail.com"},
14
+ ]
15
+ keywords = ["ultralytics", "yolo", "dataset", "machine-learning", "computer-vision", "customization"]
16
+ classifiers = [
17
+ "Development Status :: 5 - Production/Stable",
18
+ "Intended Audience :: Developers",
19
+ "Intended Audience :: Science/Research",
20
+ "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
27
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
28
+ "Topic :: Scientific/Engineering :: Image Recognition",
29
+ ]
30
+
31
+ dependencies = [
32
+ "pyyaml>=6.0.3",
33
+ "rich>=13.0.0",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/mLuca/YoloDatasetCustomizer"
38
+ Repository = "https://github.com/mLuca/YoloDatasetCustomizer"
39
+ "Issue Tracker" = "https://github.com/mLuca/YoloDatasetCustomizer/issues"
40
+
41
+ [dependency-groups]
42
+ dev = [
43
+ "pytest>=9.1.1",
44
+ "twine>=6.2.0",
45
+ "ruff>=0.14.0",
46
+ "mypy>=1.18.0",
47
+ ]
48
+
49
+ [tool.ruff]
50
+ line-length = 120
51
+
52
+ [tool.ruff.lint]
53
+ select = ["E", "F", "W", "I"]
54
+ ignore = ["E501"]
55
+
56
+ [tool.mypy]
57
+ python_version = "3.10"
58
+ files = ["src"]
59
+ ignore_missing_imports = true
@@ -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.5.0"
9
9
  __author__ = "Luca Mazzon"
10
10
  __email__ = "mazzon.luca@gmail.com"
11
11
 
@@ -0,0 +1,325 @@
1
+ import glob
2
+ import os
3
+ import shutil
4
+ import sys
5
+ from concurrent.futures import ThreadPoolExecutor
6
+
7
+ import yaml
8
+ from rich.console import Console
9
+ from rich.progress import BarColumn, MofNCompleteColumn, Progress, TaskProgressColumn, TextColumn
10
+
11
+ from .labels import LabelFile
12
+ from .paths import _SPLIT_NAMES, _get_unique_path, _normalize_path
13
+ from .reader import YoloDataFileReader
14
+ from .writer import YoloDataFileWriter
15
+
16
+
17
+ class YoloDatasetCustomizer:
18
+ """Find and customize YOLO datasets across one or more directory paths."""
19
+
20
+ def __init__(self, data_set_paths: list[str]):
21
+ """Initialize with YAML dataset paths or directories to scan for YOLO datasets."""
22
+ self.__found_data_file_paths: set[str] = set()
23
+ self.__data_sets: list[YoloDataFileReader] = []
24
+
25
+ self.add_data_sets(data_set_paths)
26
+ self.SUPPORTED_IMAGE_FORMATS = [
27
+ ".avif",
28
+ ".bmp",
29
+ ".dng",
30
+ ".heic",
31
+ ".jp2",
32
+ ".jpeg",
33
+ ".jpg",
34
+ ".mpo",
35
+ ".png",
36
+ ".tif",
37
+ ".tiff",
38
+ ".webp",
39
+ ]
40
+
41
+ def add_data_sets(self, data_set_paths: list[str]):
42
+ """Discover YOLO data.yaml files from explicit paths or directory trees."""
43
+ data_set_paths = [_normalize_path(path) for path in data_set_paths]
44
+
45
+ self.__found_data_file_paths.update(
46
+ [path for path in data_set_paths if os.path.basename(path).lower() == "data.yaml"]
47
+ )
48
+
49
+ paths_without_file_ending = [
50
+ path for path in data_set_paths if os.path.basename(path).lower() != "data.yaml"
51
+ ]
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))
55
+
56
+ for path in self.__found_data_file_paths:
57
+ try:
58
+ ydfr = YoloDataFileReader(path)
59
+ self.__data_sets.append(ydfr)
60
+ except (yaml.YAMLError, TypeError, ValueError) as e:
61
+ print(f"ERROR: Issue loading YAML file at {path}: {e}")
62
+ except FileNotFoundError:
63
+ print(f"ERROR: Yaml file does not exist at {path}")
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
+
82
+ def get_found_data_file_paths(self) -> set[str]:
83
+ """Return all discovered YOLO dataset YAML file paths."""
84
+ return self.__found_data_file_paths
85
+
86
+ def get_found_class_names(self) -> set[str]:
87
+ """Return the union of all class names from discovered datasets."""
88
+ found_classes: set[str] = set()
89
+ for ydf in self.__data_sets:
90
+ found_classes.update(ydf.get_class_names())
91
+ return found_classes
92
+
93
+ def create_new_dataset_for_class_names(
94
+ self,
95
+ class_names: set[str],
96
+ dst_path: str = ".",
97
+ data_set_name: str = "new_dataset",
98
+ ignore_img_formats: bool = False,
99
+ ) -> bool:
100
+ """Create a filtered dataset containing only the requested classes."""
101
+ if len(class_names) == 0:
102
+ print("ERROR: No classes selected for new dataset.")
103
+ return False
104
+
105
+ new_dataset_path = os.path.join(_normalize_path(dst_path), data_set_name)
106
+ new_dataset_path = _get_unique_path(new_dataset_path)
107
+
108
+ new_class_indices = self.__generate_new_class_indices(class_names)
109
+ print(f"INFO: New class indices: {new_class_indices}")
110
+
111
+ # Always create every split folder so the folder structure matches what data.yaml references,
112
+ # even when no source dataset contributed images to a split.
113
+ for split_name in _SPLIT_NAMES:
114
+ os.makedirs(os.path.join(new_dataset_path, split_name, "labels"), exist_ok=True)
115
+ os.makedirs(os.path.join(new_dataset_path, split_name, "images"), exist_ok=True)
116
+
117
+ # Plan first, so every stage knows how many files it is going to copy
118
+ planned_files: dict[str, list[tuple[LabelFile, dict[str, list[str]], dict[str, str]]]] = {
119
+ split_name: [] for split_name in _SPLIT_NAMES
120
+ }
121
+ fallback_choice: str | None = None # Sticky answer: "all" or "none"
122
+ for data_set in self.__data_sets:
123
+ common_class_names = set(data_set.get_class_names()) & class_names
124
+ if not common_class_names:
125
+ continue
126
+
127
+ old_to_new_index_match: dict[str, str] = {}
128
+ for class_name in common_class_names:
129
+ old_indices = data_set.get_indices_for_names({class_name})
130
+ if not old_indices:
131
+ continue
132
+ old_index = next(iter(old_indices))
133
+ new_index = new_class_indices[class_name]
134
+ old_to_new_index_match[old_index] = new_index
135
+
136
+ for split_name in _SPLIT_NAMES:
137
+ for split_path in data_set.get_split_paths_for_split_name(split_name):
138
+ old_images_path = os.path.join(_normalize_path(data_set.get_file_dir()), split_path)
139
+ old_labels_path = self.__generate_label_path_from_img_path(old_images_path)
140
+
141
+ missing_path = next(
142
+ (path for path in (old_labels_path, old_images_path) if not os.path.exists(path)), None
143
+ )
144
+ if missing_path is not None:
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)
172
+
173
+ # Index images by exact stem so 'car.*' can't pick up e.g. 'car.v2.jpg'.
174
+ # Only regular files with an extension count, like the old 'car.*' lookup.
175
+ images_by_stem: dict[str, list[str]] = {}
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)
181
+
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:
192
+ planned_files[split_name].append((label_file, images_by_stem, old_to_new_index_match))
193
+
194
+ # One bar per stage (white, filled green), all visible from the start
195
+ with Progress(
196
+ TextColumn("{task.description}"),
197
+ BarColumn(style="white", complete_style="green", finished_style="green"),
198
+ TaskProgressColumn(),
199
+ MofNCompleteColumn(),
200
+ ) as progress:
201
+ stage_tasks = {
202
+ split_name: progress.add_task(
203
+ f"[{stage}/{len(_SPLIT_NAMES)}] {split_name:<5}", total=len(planned_files[split_name])
204
+ )
205
+ for stage, split_name in enumerate(_SPLIT_NAMES, start=1)
206
+ }
207
+ for split_name in _SPLIT_NAMES:
208
+ new_labels_path = os.path.join(new_dataset_path, split_name, "labels")
209
+ new_images_path = os.path.join(new_dataset_path, split_name, "images")
210
+ for label_file, images_by_stem, old_to_new_index_match in planned_files[split_name]:
211
+ if not self.__copy_label_and_images(
212
+ label_file,
213
+ images_by_stem,
214
+ old_to_new_index_match,
215
+ new_labels_path,
216
+ new_images_path,
217
+ ignore_img_formats,
218
+ ):
219
+ return False
220
+ progress.advance(stage_tasks[split_name])
221
+
222
+ train_split, val_split, test_split = _SPLIT_NAMES
223
+ data_file_writer = YoloDataFileWriter(
224
+ new_dataset_path,
225
+ list(new_class_indices.keys()),
226
+ train_split=train_split,
227
+ val_split=val_split,
228
+ test_split=test_split,
229
+ )
230
+ try:
231
+ data_file_writer.write()
232
+ except Exception as e:
233
+ print(f"ERROR: Could not write new data.yaml to {new_dataset_path}. Generation of {data_set_name} is incomplete!")
234
+ print(f"Exception was: {e}")
235
+ return False
236
+
237
+ print(f"SUCCESS: Dataset '{os.path.basename(new_dataset_path)}' created at: {new_dataset_path}")
238
+ return True
239
+
240
+ def __copy_label_and_images(
241
+ self,
242
+ label_file: LabelFile,
243
+ images_by_stem: dict[str, list[str]],
244
+ old_to_new_index_match: dict[str, str],
245
+ new_labels_path: str,
246
+ new_images_path: str,
247
+ ignore_img_formats: bool,
248
+ ) -> bool:
249
+ """Copy one label file with remapped indices plus its images. Returns False on error."""
250
+ new_label_file_path = label_file.copy_by_class_indices(old_to_new_index_match, new_labels_path)
251
+ if not new_label_file_path:
252
+ return True
253
+
254
+ file_name_no_ext = os.path.splitext(os.path.basename(label_file.file_path))[0]
255
+ for img in images_by_stem.get(file_name_no_ext, []):
256
+ _, ext = os.path.splitext(img)
257
+
258
+ if not ignore_img_formats and ext.lower() not in self.SUPPORTED_IMAGE_FORMATS:
259
+ print(
260
+ f"ERROR: Image format not supported by yolo: {img}\n"
261
+ f"Supported formats are: {self.SUPPORTED_IMAGE_FORMATS}"
262
+ )
263
+ return False
264
+
265
+ new_image_name, _ = os.path.splitext(os.path.basename(new_label_file_path))
266
+ destination = os.path.join(new_images_path, new_image_name + ext)
267
+ try:
268
+ shutil.copyfile(img, destination)
269
+ except OSError as e:
270
+ print(
271
+ f"ERROR: Image could not be copied:\n"
272
+ f"Source: {img}\nDestination: {destination}\n"
273
+ f"Exception was: {e}"
274
+ )
275
+ return False
276
+ return True
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
+
315
+ def __generate_new_class_indices(self, class_names: set[str]) -> dict[str, str]:
316
+ """Generate a fresh label index mapping for the selected class names."""
317
+ ret: dict[str, str] = {}
318
+ for i, name in enumerate(class_names):
319
+ ret[name] = str(i)
320
+ return ret
321
+
322
+ def __generate_label_path_from_img_path(self, img_path: str) -> str:
323
+ """Convert an images directory path into its corresponding labels directory path."""
324
+ img_path = _normalize_path(img_path)
325
+ return os.path.normpath(os.path.join(os.path.dirname(img_path), "labels"))