File size: 8,727 Bytes
95848bc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
"""
Utility functions for dataset handling, splitting, and subset creation.
"""

import os
import zipfile
from collections import Counter
from glob import glob

import numpy as np
import torch
from sklearn.model_selection import train_test_split


def ensure_dataset_extracted(path):
    """
    Ensures that if the path points to a zip file or a directory containing a zip file,
    it is extracted. Returns the path to the directory containing the actual data.
    """
    target_path = path

    # Case 1: path is a zip file
    if os.path.isfile(path) and path.lower().endswith(".zip"):
        extract_dir = os.path.splitext(path)[0]
        if not os.path.exists(extract_dir):
            print(f"Extracting {path}...")
            with zipfile.ZipFile(path, "r") as zip_ref:
                zip_ref.extractall(extract_dir)
        target_path = extract_dir

    # Case 2: path is a directory that might contain one or more zip parts
    elif os.path.isdir(path):
        # Check if it already looks like a dataset (has 'color' folder)
        if os.path.exists(os.path.join(path, "color")):
            return path

        # Look for zip files (including chunked parts)
        files = os.listdir(path)
        zip_files = sorted([f for f in files if f.lower().endswith(".zip")])

        if zip_files:
            # Use the base name of the first zip part for extraction folder
            base_name = os.path.splitext(zip_files[0])[0].replace("_part_001", "")
            extract_dir = os.path.join(path, base_name)

            if not os.path.exists(extract_dir):
                os.makedirs(extract_dir, exist_ok=True)
                print(f"Extracting {len(zip_files)} zip parts to {extract_dir}...")
                for zip_file in zip_files:
                    zip_path = os.path.join(path, zip_file)
                    print(f"  Extracting {zip_file}...")
                    with zipfile.ZipFile(zip_path, "r") as zip_ref:
                        zip_ref.extractall(extract_dir)
            target_path = extract_dir

    # After extraction, check if the data is nested (e.g. extracted_folder/dataset_name/color)
    # We look for the 'color' folder
    for root, dirs, files in os.walk(target_path):
        if "color" in dirs:
            return root

    return target_path


def build_class_mapping(data_dir, modality="color"):
    """
    Build a mapping from class names to integer IDs.

    Args:
        data_dir: root directory containing modality subfolders
        modality: which modality folder to scan for class names (default: "color")

    Returns:
        tuple: (class_names, class_to_idx)
            - class_names: sorted list of class names
            - class_to_idx: dict mapping class name to integer ID
    """
    modality_path = os.path.join(data_dir, modality)
    class_names = sorted(next(os.walk(modality_path))[1])
    class_to_idx = {cls: i for i, cls in enumerate(class_names)}

    return class_names, class_to_idx


def gather_samples(data_dir, modalities, class_to_idx):
    """
    Gather all image samples from the dataset.

    Args:
        data_dir: root directory containing modality subfolders
        modalities: list of modality names (e.g., ["color", "grayscale", "segmented"])
        class_to_idx: dict mapping class name to integer ID

    Returns:
        list: samples as (img_path, label_id, modality_name) tuples
    """
    samples = []

    for modality in modalities:
        for cls, idx in class_to_idx.items():
            folder = os.path.join(data_dir, modality, cls)

            seen_paths = set()

            # Handle both .jpg and .JPG extensions
            for pattern in ["*.jpg", "*.JPG"]:
                for img_path in glob(os.path.join(folder, pattern)):
                    if img_path in seen_paths:
                        continue
                    seen_paths.add(img_path)
                    samples.append((img_path, idx, modality))

    return samples


def split_dataset(samples, test_size=0.15, val_size=0.18, random_state=42):
    """
    Split dataset into train, validation, and test sets.

    Args:
        samples: list of (img_path, label_id, modality_name) tuples
        test_size: proportion of data for test set (default: 0.15 = 15%)
        val_size: proportion of remaining data for validation (default: 0.18 ≈ 15% of total)
        random_state: random seed for reproducibility

    Returns:
        tuple: (train_samples, val_samples, test_samples)

    Note:
        With default values: ~70% train, ~15% val, ~15% test
    """
    # First split: separate test set
    train_val, test = train_test_split(
        samples,
        test_size=test_size,
        shuffle=True,
        stratify=[s[1] for s in samples],
        random_state=random_state,
    )

    # Second split: separate train and validation
    train, val = train_test_split(
        train_val,
        test_size=val_size,
        shuffle=True,
        stratify=[s[1] for s in train_val],
        random_state=random_state,
    )

    return train, val, test


def make_subset(samples, ratio, seed=42):
    """
    Create a stratified subset of samples.

    Useful for quick prototyping or hyperparameter tuning on smaller data.

    Args:
        samples: list of (img_path, label_id, modality_name) tuples
        ratio: proportion of samples to keep (e.g., 0.05 = 5%, 0.3 = 30%)
        seed: random seed for reproducibility

    Returns:
        list: subset of samples maintaining class distribution
    """
    subset, _ = train_test_split(
        samples, train_size=ratio, stratify=[s[1] for s in samples], random_state=seed
    )

    return subset


def get_class_distribution(samples):
    """
    Get the distribution of classes in the dataset.

    Args:
        samples: list of (img_path, label_id, modality_name) tuples

    Returns:
        Counter: class_id -> count mapping
    """
    labels = [s[1] for s in samples]
    return Counter(labels)


def balance_dataset_uniform(samples, seed=42):
    """
    Balance dataset by uniform sampling - take same number from each class.

    Uses the size of the smallest class as the target count for all classes.

    Args:
        samples: list of (img_path, label_id, modality_name) tuples
        seed: random seed for reproducibility

    Returns:
        list: balanced samples with equal number per class
    """
    np.random.seed(seed)

    # Group samples by class
    class_samples = {}
    for sample in samples:
        label = sample[1]
        if label not in class_samples:
            class_samples[label] = []
        class_samples[label].append(sample)

    # Find minimum class size
    min_count = min(len(samples_list) for samples_list in class_samples.values())

    # Randomly sample min_count from each class
    balanced = []
    for label, samples_list in class_samples.items():
        sampled = np.random.choice(len(samples_list), size=min_count, replace=False)
        balanced.extend([samples_list[i] for i in sampled])

    # Shuffle the balanced dataset
    np.random.shuffle(balanced)

    return balanced


def calculate_class_weights(samples, num_classes=None):
    """
    Calculate class weights for imbalanced datasets.

    Uses inverse frequency weighting: weight = 1 / frequency
    Normalized so the weights sum to num_classes.

    Args:
        samples: list of (img_path, label_id, modality_name) tuples
        num_classes: total number of classes (if None, inferred from samples)

    Returns:
        torch.Tensor: weight for each class (length = num_classes)
    """
    labels = [s[1] for s in samples]

    if num_classes is None:
        num_classes = max(labels) + 1

    # Count samples per class
    class_counts = Counter(labels)

    # Calculate weights: inverse frequency
    weights = torch.zeros(num_classes)
    for class_id in range(num_classes):
        count = class_counts.get(class_id, 0)
        if count > 0:
            weights[class_id] = 1.0 / count
        else:
            weights[class_id] = 0.0

    # Normalize weights so they sum to num_classes
    weights = weights / weights.sum() * num_classes

    return weights


def get_sample_weights(samples):
    """
    Calculate per-sample weights for WeightedRandomSampler.

    Each sample gets weight = 1 / (count of its class)

    Args:
        samples: list of (img_path, label_id, modality_name) tuples

    Returns:
        torch.Tensor: weight for each sample (length = len(samples))
    """
    labels = [s[1] for s in samples]
    class_counts = Counter(labels)

    # Assign weight to each sample based on its class frequency
    sample_weights = torch.tensor([1.0 / class_counts[label] for label in labels])

    return sample_weights