File size: 5,015 Bytes
2ea8a2d
 
 
 
 
 
 
 
 
fa0ce5b
2ea8a2d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa0ce5b
 
2ea8a2d
 
 
 
 
 
 
fa0ce5b
 
2ea8a2d
 
 
 
 
fa0ce5b
 
 
2ea8a2d
 
 
 
 
 
 
 
 
fa0ce5b
2ea8a2d
fa0ce5b
 
2ea8a2d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa0ce5b
 
 
 
 
2ea8a2d
 
 
 
 
 
 
 
fa0ce5b
 
 
 
 
 
 
 
 
 
2ea8a2d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# filename: src/data/preprocessor.py
# purpose:  YOLO annotation validation, data.yaml generation, dataset structure verification
# version:  1.0

import logging
from pathlib import Path

import yaml

from config import CLASSES, NUM_CLASSES, DATA_PROCESSED_DIR

logger = logging.getLogger(__name__)


def validate_yolo_annotations(labels_dir: Path) -> None:
    """
    Assert all YOLO label files have valid normalized values.
    Raises AssertionError immediately on the first invalid line.
    Call this before every YOLO training run.
    """
    labels_dir = Path(labels_dir)
    if not labels_dir.exists():
        logger.warning(f"Labels dir not found: {labels_dir}")
        return

    files_checked = 0
    lines_checked = 0

    for txt_file in labels_dir.glob("*.txt"):
        content = txt_file.read_text(encoding="utf-8").strip()
        if not content:
            continue
        for line in content.splitlines():
            parts = line.split()
            if len(parts) < 5:
                continue
            class_id = int(parts[0])
            x_c = float(parts[1])
            y_c = float(parts[2])
            w = float(parts[3])
            h = float(parts[4])

            assert 0 <= class_id < NUM_CLASSES, (
                f"class_id {class_id} out of range [0, {NUM_CLASSES}) in {txt_file}"
            )
            # Use <= 1.0 — bbox_to_yolo clamps to min(1.0,...) so boundary values are valid
            assert 0.0 < x_c <= 1.0, f"x_center {x_c} not in (0,1] in {txt_file}"
            assert 0.0 < y_c <= 1.0, f"y_center {y_c} not in (0,1] in {txt_file}"
            assert 0.0 < w <= 1.0, f"width {w} not in (0,1] in {txt_file}"
            assert 0.0 < h <= 1.0, f"height {h} not in (0,1] in {txt_file}"
            # Catch clearly wrong values (negative or >1)
            assert x_c >= 0 and y_c >= 0, f"Negative center in {txt_file}"
            lines_checked += 1
        files_checked += 1

    print(
        f"YOLO annotations valid: {files_checked} files, {lines_checked} annotations in {labels_dir.name}/"
    )


def create_yolo_data_yaml(detection_dir: Path) -> Path:
    """
    Generate data.yaml with absolute paths — works identically in terminal and Colab.
    Always regenerate before YOLO training (Colab paths differ from local paths).
    """
    detection_dir = Path(detection_dir)
    config = {
        "path": str(detection_dir.absolute()),
        "train": "images/train",
        "val": "images/val",
        "nc": NUM_CLASSES,
        "names": {i: cls for i, cls in enumerate(CLASSES)},
    }
    yaml_path = detection_dir / "data.yaml"
    with open(yaml_path, "w", encoding="utf-8") as f:
        yaml.dump(config, f, default_flow_style=False, allow_unicode=True)

    # Verify it loads cleanly
    with open(yaml_path, encoding="utf-8") as f:
        loaded = yaml.safe_load(f)
    assert loaded["nc"] == NUM_CLASSES
    assert len(loaded["names"]) == NUM_CLASSES

    logger.info(f"data.yaml written: {yaml_path}")
    print(f"data.yaml OK: nc={loaded['nc']}, path={loaded['path']}")
    return yaml_path


def verify_dataset_structure() -> dict:
    """
    Count images in every split/class folder and return a summary dict.
    Confirms the dataset was built correctly before preprocessing.
    """

    cls_dir = DATA_PROCESSED_DIR / "classification"
    det_dir = DATA_PROCESSED_DIR / "detection"

    result: dict = {
        "classification": {},
        "detection": {},
        "issues": [],
    }

    # Classification counts
    for split in ["train", "val", "test"]:
        split_total = 0
        per_class: dict[str, int] = {}
        for cls in CLASSES:
            count = (
                len(list((cls_dir / split / cls).glob("*.jpg")))
                if (cls_dir / split / cls).exists()
                else 0
            )
            per_class[cls] = count
            split_total += count
            if count == 0:
                result["issues"].append(f"classification/{split}/{cls}: 0 images")
        result["classification"][split] = {"total": split_total, "per_class": per_class}

    # Detection counts
    for split in ["train", "val"]:
        imgs = (
            len(list((det_dir / "images" / split).glob("*.jpg")))
            if (det_dir / "images" / split).exists()
            else 0
        )
        lbls = (
            len(list((det_dir / "labels" / split).glob("*.txt")))
            if (det_dir / "labels" / split).exists()
            else 0
        )
        result["detection"][split] = {"images": imgs, "labels": lbls}
        if imgs != lbls:
            result["issues"].append(
                f"detection/{split}: images({imgs}) != labels({lbls})"
            )

    # Expected totals
    total_cls = sum(
        result["classification"][s]["total"] for s in ["train", "val", "test"]
    )
    result["classification"]["total"] = total_cls
    result["detection"]["total_images"] = sum(
        result["detection"][s]["images"] for s in ["train", "val"]
    )

    return result