YoloDatasetCustomizer 0.2.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.
- yolodatasetcustomizer-0.4.0/.github/workflows/ci.yml +35 -0
- {yolodatasetcustomizer-0.2.0 → yolodatasetcustomizer-0.4.0}/PKG-INFO +22 -10
- {yolodatasetcustomizer-0.2.0 → yolodatasetcustomizer-0.4.0}/README.md +22 -11
- {yolodatasetcustomizer-0.2.0 → yolodatasetcustomizer-0.4.0}/pyproject.toml +16 -1
- yolodatasetcustomizer-0.4.0/resources/Improvements.txt +57 -0
- {yolodatasetcustomizer-0.2.0 → yolodatasetcustomizer-0.4.0}/src/YoloDatasetCustomizer/__init__.py +1 -1
- yolodatasetcustomizer-0.4.0/src/YoloDatasetCustomizer/customizer.py +223 -0
- {yolodatasetcustomizer-0.2.0 → yolodatasetcustomizer-0.4.0}/src/YoloDatasetCustomizer/labels.py +71 -71
- {yolodatasetcustomizer-0.2.0 → yolodatasetcustomizer-0.4.0}/src/YoloDatasetCustomizer/paths.py +20 -20
- {yolodatasetcustomizer-0.2.0 → yolodatasetcustomizer-0.4.0}/src/YoloDatasetCustomizer/reader.py +128 -90
- {yolodatasetcustomizer-0.2.0 → yolodatasetcustomizer-0.4.0}/src/YoloDatasetCustomizer/writer.py +41 -41
- {yolodatasetcustomizer-0.2.0 → yolodatasetcustomizer-0.4.0}/test/YoloDatasetCustomizer_test.py +145 -7
- {yolodatasetcustomizer-0.2.0 → yolodatasetcustomizer-0.4.0}/uv.lock +312 -3
- yolodatasetcustomizer-0.2.0/resources/Improvements.txt +0 -33
- yolodatasetcustomizer-0.2.0/src/YoloDatasetCustomizer/customizer.py +0 -166
- {yolodatasetcustomizer-0.2.0 → yolodatasetcustomizer-0.4.0}/.gitattributes +0 -0
- {yolodatasetcustomizer-0.2.0 → yolodatasetcustomizer-0.4.0}/.gitignore +0 -0
- {yolodatasetcustomizer-0.2.0 → yolodatasetcustomizer-0.4.0}/.python-version +0 -0
- {yolodatasetcustomizer-0.2.0 → yolodatasetcustomizer-0.4.0}/LICENSE +0 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
# "3" resolves to the latest released stable CPython 3.x, so this
|
|
15
|
+
# tracks new releases automatically without needing manual bumps
|
|
16
|
+
python-version: ["3.10", "3"]
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- name: Install uv
|
|
21
|
+
uses: astral-sh/setup-uv@v6
|
|
22
|
+
with:
|
|
23
|
+
python-version: ${{ matrix.python-version }}
|
|
24
|
+
|
|
25
|
+
- name: Install dependencies
|
|
26
|
+
run: uv sync --group dev
|
|
27
|
+
|
|
28
|
+
- name: Lint (ruff)
|
|
29
|
+
run: uv run ruff check src test
|
|
30
|
+
|
|
31
|
+
- name: Type-check (mypy)
|
|
32
|
+
run: uv run mypy
|
|
33
|
+
|
|
34
|
+
- name: Test (pytest)
|
|
35
|
+
run: uv run pytest -q
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: YoloDatasetCustomizer
|
|
3
|
-
Version: 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
|
-
|
|
46
|
+
```python
|
|
47
|
+
from YoloDatasetCustomizer import YoloDatasetCustomizer
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
ydc = YoloDatasetCustomizer(list_to_existing_datasets)
|
|
50
|
+
ydc.add_data_sets([one_more_existing_dataset])
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
|
@@ -58,13 +65,18 @@ The project uses uv. Install dependencies and run tests with:
|
|
|
58
65
|
|
|
59
66
|
uv run pytest
|
|
60
67
|
|
|
68
|
+
# Lint & type-check
|
|
69
|
+
|
|
70
|
+
uv run ruff check src test
|
|
71
|
+
uv run mypy
|
|
72
|
+
|
|
61
73
|
# Build project
|
|
62
74
|
|
|
63
|
-
Build the package
|
|
75
|
+
Build the package:
|
|
64
76
|
|
|
65
77
|
uv build
|
|
66
78
|
|
|
67
|
-
Publish to PyPI (still need twine)
|
|
79
|
+
Publish to PyPI (still need twine):
|
|
68
80
|
|
|
69
81
|
uv pip install twine
|
|
70
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
|
@@ -31,13 +37,18 @@ The project uses uv. Install dependencies and run tests with:
|
|
|
31
37
|
|
|
32
38
|
uv run pytest
|
|
33
39
|
|
|
40
|
+
# Lint & type-check
|
|
41
|
+
|
|
42
|
+
uv run ruff check src test
|
|
43
|
+
uv run mypy
|
|
44
|
+
|
|
34
45
|
# Build project
|
|
35
46
|
|
|
36
|
-
Build the package
|
|
47
|
+
Build the package:
|
|
37
48
|
|
|
38
49
|
uv build
|
|
39
50
|
|
|
40
|
-
Publish to PyPI (still need twine)
|
|
51
|
+
Publish to PyPI (still need twine):
|
|
41
52
|
|
|
42
53
|
uv pip install twine
|
|
43
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.
|
|
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]
|
|
@@ -41,4 +42,18 @@ Repository = "https://github.com/mLuca/YoloDatasetCustomizer"
|
|
|
41
42
|
dev = [
|
|
42
43
|
"pytest>=9.1.1",
|
|
43
44
|
"twine>=6.2.0",
|
|
45
|
+
"ruff>=0.14.0",
|
|
46
|
+
"mypy>=1.18.0",
|
|
44
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
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Priority-ordered improvement list (last reviewed 2026-08-14)
|
|
2
|
+
|
|
3
|
+
FIXED 1. Fix brittle path and OS handling
|
|
4
|
+
Why: The code constructs file paths with string concatenation and assumes Unix-style separators, which can break on Windows and in nested dataset layouts. Since this tool operates on filesystem paths, cross-platform reliability is a core requirement.
|
|
5
|
+
|
|
6
|
+
FIXED 2. Remove mutable default arguments
|
|
7
|
+
Why: class_names: list[str] = [] creates shared state across instances. This is a Python bug pattern that can cause hard-to-trace behavior and incorrect dataset generation when objects are reused.
|
|
8
|
+
|
|
9
|
+
FIXED 3. Fix broken internal state and debug output
|
|
10
|
+
Why: __repr__ references attributes that do not exist, which signals stale or inconsistent internal state. This is a maintainability problem and a sign that code may be harder to trust during debugging.
|
|
11
|
+
|
|
12
|
+
FIXED 4. Refactor the monolithic implementation into smaller modules
|
|
13
|
+
Why: The main file handles reading YAML, writing YAML, copying labels, scanning directories, and building datasets all at once. Splitting responsibilities would reduce complexity, improve testability, and make future features safer to add.
|
|
14
|
+
|
|
15
|
+
FIXED 1. Fix broken copy-failure error handling in customizer.py
|
|
16
|
+
Why: `if not shutil.copyfile(img, destination):` is dead code — copyfile returns a truthy destination path on success and raises on failure, so this branch never triggers and a real copy error (permissions, disk full, locked file) crashes the whole run with an unhandled exception instead of returning False with a clear message.
|
|
17
|
+
|
|
18
|
+
FIXED 2. Fix the path-existence skip logic in create_new_dataset_for_class_names
|
|
19
|
+
Why: `for path in [old_labels_path, old_images_path]: if not os.path.exists(path): ...continue` only continues that 2-item inner loop, not the split-processing loop it's meant to guard. A missing labels/images directory is not actually skipped as intended, so processing silently proceeds against a non-existent path (glob just returns nothing, masking the real problem).
|
|
20
|
+
|
|
21
|
+
FIXED 3. Standardize class-index typing and conversion
|
|
22
|
+
Why: Indices are handled as strings in some places and as numeric concepts in others. This inconsistency increases the chance of subtle label remapping bugs that are especially dangerous in object-detection datasets.
|
|
23
|
+
|
|
24
|
+
4. Tighten validation and error handling generally
|
|
25
|
+
Why: add_data_sets() prints a warning and continues on bad YAML instead of surfacing a clear, actionable failure or strict mode. Silently skipping broken YAML or missing directories can produce partially valid outputs and make debugging much harder.
|
|
26
|
+
|
|
27
|
+
5. Stop leaking mutable internal state from getters
|
|
28
|
+
Why: get_found_data_file_paths() and get_class_names() return the live internal set/list rather than a copy. Callers can accidentally mutate the object's internal state from outside, breaking encapsulation.
|
|
29
|
+
|
|
30
|
+
FIXED 6. Add CI (GitHub Actions) for tests, lint, and type-checking
|
|
31
|
+
Why: The package classifies itself as "Production/Stable" and is published to PyPI, but nothing runs tests or checks automatically on push/PR. Bugs like items 1 and 2 above would have been easy to catch with basic CI.
|
|
32
|
+
|
|
33
|
+
FIXED 7. Add lint/type-check tooling (ruff + mypy) to the dev dependency group
|
|
34
|
+
Why: There is currently no configured static analysis. Tooling like this would have flagged the always-true/dead `if not shutil.copyfile(...)` check and similar issues automatically.
|
|
35
|
+
|
|
36
|
+
8. Replace print()-based warnings/errors with the `logging` module
|
|
37
|
+
Why: A library shouldn't write directly to stdout. Using `logging` lets consumers control verbosity, redirect output, and integrate the tool into larger pipelines.
|
|
38
|
+
|
|
39
|
+
FIXED 9. Clean up naming and spelling inconsistencies
|
|
40
|
+
Why: The misspelling "indeces" is used in some names (copy_by_class_indeces, __generate_new_class_indeces) while the correct "indices" is used elsewhere (get_class_indices, get_indices_for_names) in the same codebase. This is not just cosmetic; it slows down maintenance and increases the chance of mistakes during refactors.
|
|
41
|
+
|
|
42
|
+
10. Remove or clarify the directory-handling branch in LabelFile.__init__
|
|
43
|
+
Why: `_get_unique_path(file_path) if os.path.isdir(file_path) else ...` produces a non-existent path (by design of _get_unique_path) whenever a directory is passed, which then immediately raises FileNotFoundError. This looks like dead or misleading code with no clear purpose.
|
|
44
|
+
|
|
45
|
+
11. Make SUPPORTED_IMAGE_FORMATS a class-level constant
|
|
46
|
+
Why: It's currently rebuilt as a public mutable list on every instance in __init__. A class-level tuple/frozenset avoids repeated allocation and accidental external mutation.
|
|
47
|
+
|
|
48
|
+
12. Add a py.typed marker file
|
|
49
|
+
Why: The package ships inline type hints and is installable from PyPI, but without py.typed, downstream type checkers (mypy/pyright) will ignore those hints for consumers of the published package.
|
|
50
|
+
|
|
51
|
+
13. Improve test coverage around edge cases
|
|
52
|
+
Why: The current tests cover the happy path well, plus a few negative cases, but not the bugs found above (missing labels/images dir, copy failures) or other realistic failures such as duplicate file names, empty split folders, and unexpected dataset layouts.
|
|
53
|
+
|
|
54
|
+
14. Clarify and formally validate documented limitations
|
|
55
|
+
Why: The README already explains assumptions like fixed dataset naming and ignored YAML fields (`path`, `script`). Those constraints should be treated as explicit, validated API requirements (and paired with clearer usage examples) so users understand exactly what is supported and what is not.
|
|
56
|
+
|
|
57
|
+
Highest priority overall: items 1 and 2 are real correctness bugs in the core dataset-generation path that can silently produce wrong, incomplete, or crashing output today. Fix those first, then invest in CI and lint tooling (items 6-7) so regressions like this are caught automatically going forward.
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
from rich.progress import BarColumn, MofNCompleteColumn, Progress, TaskProgressColumn, TextColumn
|
|
7
|
+
|
|
8
|
+
from .labels import LabelFile
|
|
9
|
+
from .paths import _SPLIT_NAMES, _get_unique_path, _normalize_path
|
|
10
|
+
from .reader import YoloDataFileReader
|
|
11
|
+
from .writer import YoloDataFileWriter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class YoloDatasetCustomizer:
|
|
15
|
+
"""Find and customize YOLO datasets across one or more directory paths."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, data_set_paths: list[str]):
|
|
18
|
+
"""Initialize with YAML dataset paths or directories to scan for YOLO datasets."""
|
|
19
|
+
self.__found_data_file_paths: set[str] = set()
|
|
20
|
+
self.__data_sets: list[YoloDataFileReader] = []
|
|
21
|
+
|
|
22
|
+
self.add_data_sets(data_set_paths)
|
|
23
|
+
self.SUPPORTED_IMAGE_FORMATS = [
|
|
24
|
+
".avif",
|
|
25
|
+
".bmp",
|
|
26
|
+
".dng",
|
|
27
|
+
".heic",
|
|
28
|
+
".jp2",
|
|
29
|
+
".jpeg",
|
|
30
|
+
".jpg",
|
|
31
|
+
".mpo",
|
|
32
|
+
".png",
|
|
33
|
+
".tif",
|
|
34
|
+
".tiff",
|
|
35
|
+
".webp",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
def add_data_sets(self, data_set_paths: list[str]):
|
|
39
|
+
"""Discover YOLO data.yaml files from explicit paths or directory trees."""
|
|
40
|
+
data_set_paths = [_normalize_path(path) for path in data_set_paths]
|
|
41
|
+
|
|
42
|
+
self.__found_data_file_paths.update(
|
|
43
|
+
[path for path in data_set_paths if os.path.basename(path).lower() == "data.yaml"]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
paths_without_file_ending = [
|
|
47
|
+
path for path in data_set_paths if os.path.basename(path).lower() != "data.yaml"
|
|
48
|
+
]
|
|
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))
|
|
51
|
+
|
|
52
|
+
for path in self.__found_data_file_paths:
|
|
53
|
+
try:
|
|
54
|
+
ydfr = YoloDataFileReader(path)
|
|
55
|
+
self.__data_sets.append(ydfr)
|
|
56
|
+
except (yaml.YAMLError, TypeError, ValueError) as e:
|
|
57
|
+
print(f"ERROR: Issue loading YAML file at {path}: {e}")
|
|
58
|
+
except FileNotFoundError:
|
|
59
|
+
print(f"ERROR: Yaml file does not exist at {path}")
|
|
60
|
+
|
|
61
|
+
def get_found_data_file_paths(self) -> set[str]:
|
|
62
|
+
"""Return all discovered YOLO dataset YAML file paths."""
|
|
63
|
+
return self.__found_data_file_paths
|
|
64
|
+
|
|
65
|
+
def get_found_class_names(self) -> set[str]:
|
|
66
|
+
"""Return the union of all class names from discovered datasets."""
|
|
67
|
+
found_classes: set[str] = set()
|
|
68
|
+
for ydf in self.__data_sets:
|
|
69
|
+
found_classes.update(ydf.get_class_names())
|
|
70
|
+
return found_classes
|
|
71
|
+
|
|
72
|
+
def create_new_dataset_for_class_names(
|
|
73
|
+
self,
|
|
74
|
+
class_names: set[str],
|
|
75
|
+
dst_path: str = ".",
|
|
76
|
+
data_set_name: str = "new_dataset",
|
|
77
|
+
ignore_img_formats: bool = False,
|
|
78
|
+
) -> bool:
|
|
79
|
+
"""Create a filtered dataset containing only the requested classes."""
|
|
80
|
+
if len(class_names) == 0:
|
|
81
|
+
print("ERROR: No classes selected for new dataset.")
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
new_dataset_path = os.path.join(_normalize_path(dst_path), data_set_name)
|
|
85
|
+
new_dataset_path = _get_unique_path(new_dataset_path)
|
|
86
|
+
|
|
87
|
+
new_class_indices = self.__generate_new_class_indices(class_names)
|
|
88
|
+
print(f"INFO: New class indices: {new_class_indices}")
|
|
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
|
+
}
|
|
100
|
+
for data_set in self.__data_sets:
|
|
101
|
+
common_class_names = set(data_set.get_class_names()) & class_names
|
|
102
|
+
if not common_class_names:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
old_to_new_index_match: dict[str, str] = {}
|
|
106
|
+
for class_name in common_class_names:
|
|
107
|
+
old_indices = data_set.get_indices_for_names({class_name})
|
|
108
|
+
if not old_indices:
|
|
109
|
+
continue
|
|
110
|
+
old_index = next(iter(old_indices))
|
|
111
|
+
new_index = new_class_indices[class_name]
|
|
112
|
+
old_to_new_index_match[old_index] = new_index
|
|
113
|
+
|
|
114
|
+
for split_name in _SPLIT_NAMES:
|
|
115
|
+
for split_path in data_set.get_split_paths_for_split_name(split_name):
|
|
116
|
+
old_images_path = os.path.join(_normalize_path(data_set.get_file_dir()), split_path)
|
|
117
|
+
old_labels_path = self.__generate_label_path_from_img_path(old_images_path)
|
|
118
|
+
|
|
119
|
+
missing_path = next(
|
|
120
|
+
(path for path in (old_labels_path, old_images_path) if not os.path.exists(path)), None
|
|
121
|
+
)
|
|
122
|
+
if missing_path is not None:
|
|
123
|
+
print(f"WARNING: Path '{missing_path}' does not exist! Skipping it.")
|
|
124
|
+
continue
|
|
125
|
+
|
|
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))
|
|
131
|
+
|
|
132
|
+
for original_label_file_path in glob.glob(os.path.join(old_labels_path, "*.txt")):
|
|
133
|
+
label_file = LabelFile(original_label_file_path)
|
|
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
|
+
)
|
|
166
|
+
try:
|
|
167
|
+
data_file_writer.write()
|
|
168
|
+
except Exception as e:
|
|
169
|
+
print(f"ERROR: Could not write new data.yaml to {new_dataset_path}. Generation of {data_set_name} is incomplete!")
|
|
170
|
+
print(f"Exception was: {e}")
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
return True
|
|
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
|
+
|
|
213
|
+
def __generate_new_class_indices(self, class_names: set[str]) -> dict[str, str]:
|
|
214
|
+
"""Generate a fresh label index mapping for the selected class names."""
|
|
215
|
+
ret: dict[str, str] = {}
|
|
216
|
+
for i, name in enumerate(class_names):
|
|
217
|
+
ret[name] = str(i)
|
|
218
|
+
return ret
|
|
219
|
+
|
|
220
|
+
def __generate_label_path_from_img_path(self, img_path: str) -> str:
|
|
221
|
+
"""Convert an images directory path into its corresponding labels directory path."""
|
|
222
|
+
img_path = _normalize_path(img_path)
|
|
223
|
+
return os.path.normpath(os.path.join(os.path.dirname(img_path), "labels"))
|
{yolodatasetcustomizer-0.2.0 → yolodatasetcustomizer-0.4.0}/src/YoloDatasetCustomizer/labels.py
RENAMED
|
@@ -1,71 +1,71 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import re
|
|
3
|
-
|
|
4
|
-
from .paths import _get_unique_path
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class LabelFile:
|
|
8
|
-
"""Read YOLO label files and copy label lines with remapped class indices."""
|
|
9
|
-
|
|
10
|
-
def __init__(self, file_path: str):
|
|
11
|
-
"""
|
|
12
|
-
Args:
|
|
13
|
-
file_path: Valid path to the label file
|
|
14
|
-
Raises:
|
|
15
|
-
FileNotFoundError: When the label file does not exist
|
|
16
|
-
"""
|
|
17
|
-
self.
|
|
18
|
-
self.file_path = _get_unique_path(file_path) if os.path.isdir(file_path) else os.path.abspath(file_path)
|
|
19
|
-
if not os.path.exists(self.file_path) or not os.path.isfile(self.file_path):
|
|
20
|
-
raise FileNotFoundError(f"No label file found at: {self.file_path}")
|
|
21
|
-
|
|
22
|
-
self.read_label_file()
|
|
23
|
-
|
|
24
|
-
def read_label_file(self):
|
|
25
|
-
"""Parse the label file and collect all class indices referenced in it."""
|
|
26
|
-
with open(self.file_path, encoding="utf-8") as f:
|
|
27
|
-
for i, line in enumerate(f):
|
|
28
|
-
match = re.match(r"^(\d+) ", line)
|
|
29
|
-
if not match:
|
|
30
|
-
print(f"WARNING: No class found on line {i} in label file {self.file_path}")
|
|
31
|
-
else:
|
|
32
|
-
self.
|
|
33
|
-
|
|
34
|
-
def get_class_indices(self) -> set[str]:
|
|
35
|
-
"""Return the set of class indices in the loaded label file."""
|
|
36
|
-
return self.
|
|
37
|
-
|
|
38
|
-
def
|
|
39
|
-
"""Copy the label file to the destination with indices remapped for the new dataset."""
|
|
40
|
-
|
|
41
|
-
if not
|
|
42
|
-
return None
|
|
43
|
-
|
|
44
|
-
dst_path = os.path.abspath(dst_path)
|
|
45
|
-
if os.path.basename(dst_path) != "labels":
|
|
46
|
-
dst_path = os.path.join(dst_path, "labels")
|
|
47
|
-
|
|
48
|
-
if os.path.exists(dst_path) and os.path.isdir(dst_path):
|
|
49
|
-
new_content: list[str] = []
|
|
50
|
-
with open(self.file_path, encoding="utf-8") as f:
|
|
51
|
-
for line in f:
|
|
52
|
-
match = re.match(r"^(\d+) ", line)
|
|
53
|
-
if not match:
|
|
54
|
-
continue
|
|
55
|
-
elif match.group(1) in
|
|
56
|
-
old_index = match.group(1)
|
|
57
|
-
new_index = old_to_new_index_match[old_index]
|
|
58
|
-
if new_index != old_index:
|
|
59
|
-
line = line.replace(old_index, new_index, 1)
|
|
60
|
-
new_content.append(line)
|
|
61
|
-
|
|
62
|
-
if file_name == "":
|
|
63
|
-
file_name = os.path.basename(self.file_path)
|
|
64
|
-
dst_file_path = _get_unique_path(os.path.join(dst_path, file_name))
|
|
65
|
-
|
|
66
|
-
with open(dst_file_path, "w", encoding="utf-8") as f:
|
|
67
|
-
f.writelines(new_content)
|
|
68
|
-
else:
|
|
69
|
-
raise FileNotFoundError(f"Directory not found: {dst_path}")
|
|
70
|
-
|
|
71
|
-
return dst_file_path
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from .paths import _get_unique_path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LabelFile:
|
|
8
|
+
"""Read YOLO label files and copy label lines with remapped class indices."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, file_path: str):
|
|
11
|
+
"""
|
|
12
|
+
Args:
|
|
13
|
+
file_path: Valid path to the label file
|
|
14
|
+
Raises:
|
|
15
|
+
FileNotFoundError: When the label file does not exist
|
|
16
|
+
"""
|
|
17
|
+
self.__class_indices: set[str] = set()
|
|
18
|
+
self.file_path = _get_unique_path(file_path) if os.path.isdir(file_path) else os.path.abspath(file_path)
|
|
19
|
+
if not os.path.exists(self.file_path) or not os.path.isfile(self.file_path):
|
|
20
|
+
raise FileNotFoundError(f"No label file found at: {self.file_path}")
|
|
21
|
+
|
|
22
|
+
self.read_label_file()
|
|
23
|
+
|
|
24
|
+
def read_label_file(self):
|
|
25
|
+
"""Parse the label file and collect all class indices referenced in it."""
|
|
26
|
+
with open(self.file_path, encoding="utf-8") as f:
|
|
27
|
+
for i, line in enumerate(f):
|
|
28
|
+
match = re.match(r"^(\d+) ", line)
|
|
29
|
+
if not match:
|
|
30
|
+
print(f"WARNING: No class found on line {i} in label file {self.file_path}")
|
|
31
|
+
else:
|
|
32
|
+
self.__class_indices.add(match.group(1))
|
|
33
|
+
|
|
34
|
+
def get_class_indices(self) -> set[str]:
|
|
35
|
+
"""Return the set of class indices in the loaded label file."""
|
|
36
|
+
return self.__class_indices
|
|
37
|
+
|
|
38
|
+
def copy_by_class_indices(self, old_to_new_index_match: dict[str, str], dst_path: str, file_name: str = "") -> str | None:
|
|
39
|
+
"""Copy the label file to the destination with indices remapped for the new dataset."""
|
|
40
|
+
common_class_indices = self.__class_indices & set(old_to_new_index_match.keys())
|
|
41
|
+
if not common_class_indices:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
dst_path = os.path.abspath(dst_path)
|
|
45
|
+
if os.path.basename(dst_path) != "labels":
|
|
46
|
+
dst_path = os.path.join(dst_path, "labels")
|
|
47
|
+
|
|
48
|
+
if os.path.exists(dst_path) and os.path.isdir(dst_path):
|
|
49
|
+
new_content: list[str] = []
|
|
50
|
+
with open(self.file_path, encoding="utf-8") as f:
|
|
51
|
+
for line in f:
|
|
52
|
+
match = re.match(r"^(\d+) ", line)
|
|
53
|
+
if not match:
|
|
54
|
+
continue
|
|
55
|
+
elif match.group(1) in common_class_indices:
|
|
56
|
+
old_index = match.group(1)
|
|
57
|
+
new_index = old_to_new_index_match[old_index]
|
|
58
|
+
if new_index != old_index:
|
|
59
|
+
line = line.replace(old_index, new_index, 1)
|
|
60
|
+
new_content.append(line)
|
|
61
|
+
|
|
62
|
+
if file_name == "":
|
|
63
|
+
file_name = os.path.basename(self.file_path)
|
|
64
|
+
dst_file_path = _get_unique_path(os.path.join(dst_path, file_name))
|
|
65
|
+
|
|
66
|
+
with open(dst_file_path, "w", encoding="utf-8") as f:
|
|
67
|
+
f.writelines(new_content)
|
|
68
|
+
else:
|
|
69
|
+
raise FileNotFoundError(f"Directory not found: {dst_path}")
|
|
70
|
+
|
|
71
|
+
return dst_file_path
|
{yolodatasetcustomizer-0.2.0 → yolodatasetcustomizer-0.4.0}/src/YoloDatasetCustomizer/paths.py
RENAMED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import os
|
|
2
|
-
|
|
3
|
-
_SPLIT_NAMES = ["train", "val", "test"]
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def _normalize_path(path: str) -> str:
|
|
7
|
-
"""Normalize path separators and return an absolute path for cross-platform use."""
|
|
8
|
-
return os.path.abspath(str(path).replace("\\", os.sep))
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def _get_unique_path(path: str) -> str:
|
|
12
|
-
"""Return an unused file path by appending a numeric suffix when needed."""
|
|
13
|
-
path = _normalize_path(path)
|
|
14
|
-
base, ext = os.path.splitext(path)
|
|
15
|
-
unique_path = path
|
|
16
|
-
index = 1
|
|
17
|
-
while os.path.exists(unique_path):
|
|
18
|
-
unique_path = f"{base}_{index}{ext}"
|
|
19
|
-
index += 1
|
|
20
|
-
return unique_path
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
_SPLIT_NAMES = ["train", "val", "test"]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _normalize_path(path: str) -> str:
|
|
7
|
+
"""Normalize path separators and return an absolute path for cross-platform use."""
|
|
8
|
+
return os.path.abspath(str(path).replace("\\", os.sep))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_unique_path(path: str) -> str:
|
|
12
|
+
"""Return an unused file path by appending a numeric suffix when needed."""
|
|
13
|
+
path = _normalize_path(path)
|
|
14
|
+
base, ext = os.path.splitext(path)
|
|
15
|
+
unique_path = path
|
|
16
|
+
index = 1
|
|
17
|
+
while os.path.exists(unique_path):
|
|
18
|
+
unique_path = f"{base}_{index}{ext}"
|
|
19
|
+
index += 1
|
|
20
|
+
return unique_path
|