OmUniyal commited on
Commit
69ce2b7
·
1 Parent(s): 8f0fde8

feat: phase 1 - data pipeline (utils, transforms, dataset)

Browse files
Files changed (3) hide show
  1. src/data/dataset.py +162 -0
  2. src/data/transforms.py +162 -0
  3. src/data/utils.py +137 -0
src/data/dataset.py CHANGED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Optional, Tuple, List
4
+ from PIL import Image
5
+ import torch
6
+ from torch.utils.data import Dataset
7
+
8
+ from src.data.utils import (
9
+ parse_voc_xml,
10
+ get_primary_object,
11
+ load_image_ids,
12
+ CLASS_TO_IDX,
13
+ NUM_CLASSES,
14
+ )
15
+ from src.data.transforms import get_train_transforms, get_val_transforms, Compose
16
+
17
+
18
+ class VOCMultiTaskDataset(Dataset):
19
+ """
20
+ PASCAL VOC 2012 dataset for multi-task learning.
21
+
22
+ Each sample returns:
23
+ image : Tensor [3, H, W] — normalized
24
+ label : int — primary object class index
25
+ bbox : List[float] — [x_min, y_min, x_max, y_max] normalized [0, 1]
26
+ image_id: str — VOC image ID (e.g. '2007_000032')
27
+
28
+ Primary object selection: largest non-difficult bounding box per image.
29
+ Images with no valid objects are skipped (returns None, filtered by collate_fn).
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ voc_root: str,
35
+ split: str = "train",
36
+ transforms: Optional[Compose] = None,
37
+ max_samples: Optional[int] = None,
38
+ ):
39
+ """
40
+ Args:
41
+ voc_root : path to VOCdevkit/VOC2012/
42
+ split : 'train', 'val', or 'trainval'
43
+ transforms : Compose instance (defaults to split-appropriate transforms)
44
+ max_samples: cap dataset size — useful for local CPU dev runs
45
+ """
46
+ self.voc_root = Path(voc_root)
47
+ self.split = split
48
+ self.image_dir = self.voc_root / "JPEGImages"
49
+ self.annotation_dir = self.voc_root / "Annotations"
50
+
51
+ self.image_ids = load_image_ids(str(self.voc_root), split)
52
+ if max_samples is not None:
53
+ self.image_ids = self.image_ids[:max_samples]
54
+
55
+ if transforms is not None:
56
+ self.transforms = transforms
57
+ elif split == "train":
58
+ self.transforms = get_train_transforms()
59
+ else:
60
+ self.transforms = get_val_transforms()
61
+
62
+ self._skipped = 0
63
+
64
+ def __len__(self) -> int:
65
+ return len(self.image_ids)
66
+
67
+ def __getitem__(self, idx: int):
68
+ image_id = self.image_ids[idx]
69
+
70
+ # --- load annotation ---
71
+ xml_path = self.annotation_dir / f"{image_id}.xml"
72
+ try:
73
+ parsed = parse_voc_xml(str(xml_path))
74
+ except Exception as e:
75
+ self._skipped += 1
76
+ return None
77
+
78
+ primary = get_primary_object(parsed)
79
+ if primary is None:
80
+ self._skipped += 1
81
+ return None
82
+
83
+ label = primary["label_idx"]
84
+ bbox = primary["bbox"]
85
+
86
+ # --- load image ---
87
+ img_path = self.image_dir / f"{parsed['filename']}"
88
+ if not img_path.exists():
89
+ # some VOC filenames lack extension
90
+ img_path = self.image_dir / f"{image_id}.jpg"
91
+ try:
92
+ image = Image.open(img_path).convert("RGB")
93
+ except Exception:
94
+ self._skipped += 1
95
+ return None
96
+
97
+ # --- apply transforms ---
98
+ if self.transforms is not None:
99
+ image, bbox = self.transforms(image, bbox)
100
+
101
+ return image, label, bbox, image_id
102
+
103
+ def get_class_name(self, idx: int) -> str:
104
+ from src.data.utils import IDX_TO_CLASS
105
+ return IDX_TO_CLASS.get(idx, "unknown")
106
+
107
+ def class_distribution(self) -> dict:
108
+ """
109
+ Iterate all annotations and count primary object per image.
110
+ Useful for EDA. Slow — don't call during training.
111
+ """
112
+ from collections import Counter
113
+ from src.data.utils import IDX_TO_CLASS
114
+ counter = Counter()
115
+ for image_id in self.image_ids:
116
+ xml_path = self.annotation_dir / f"{image_id}.xml"
117
+ try:
118
+ parsed = parse_voc_xml(str(xml_path))
119
+ primary = get_primary_object(parsed)
120
+ if primary:
121
+ counter[primary["name"]] += 1
122
+ except Exception:
123
+ continue
124
+ return dict(counter)
125
+
126
+
127
+ def build_dataloaders(
128
+ voc_root: str,
129
+ batch_size: int = 32,
130
+ num_workers: int = 0,
131
+ max_train_samples: Optional[int] = None,
132
+ max_val_samples: Optional[int] = None,
133
+ ) -> Tuple[torch.utils.data.DataLoader, torch.utils.data.DataLoader]:
134
+ """
135
+ Build train and val DataLoaders.
136
+ num_workers=0 is default for Windows (multiprocessing issues with >0).
137
+ """
138
+ from src.data.utils import collate_fn
139
+
140
+ train_ds = VOCMultiTaskDataset(
141
+ voc_root, split="train", max_samples=max_train_samples
142
+ )
143
+ val_ds = VOCMultiTaskDataset(
144
+ voc_root, split="val", max_samples=max_val_samples
145
+ )
146
+
147
+ train_loader = torch.utils.data.DataLoader(
148
+ train_ds,
149
+ batch_size=batch_size,
150
+ shuffle=True,
151
+ num_workers=num_workers,
152
+ collate_fn=collate_fn,
153
+ )
154
+ val_loader = torch.utils.data.DataLoader(
155
+ val_ds,
156
+ batch_size=batch_size,
157
+ shuffle=False,
158
+ num_workers=num_workers,
159
+ collate_fn=collate_fn,
160
+ )
161
+
162
+ return train_loader, val_ds
src/data/transforms.py CHANGED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torchvision.transforms.functional as TF
3
+ from torchvision import transforms
4
+ from typing import Tuple, List
5
+ import random
6
+
7
+
8
+ class Compose:
9
+ """Apply a sequence of transforms to both image and bbox."""
10
+ def __init__(self, transforms_list):
11
+ self.transforms = transforms_list
12
+
13
+ def __call__(self, image, bbox):
14
+ for t in self.transforms:
15
+ image, bbox = t(image, bbox)
16
+ return image, bbox
17
+
18
+
19
+ class ToTensor:
20
+ """Convert PIL image to tensor. Bbox is already a list, pass through."""
21
+ def __call__(self, image, bbox):
22
+ return TF.to_tensor(image), bbox
23
+
24
+
25
+ class Normalize:
26
+ """Normalize image tensor. Bbox unchanged."""
27
+ def __init__(self, mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)):
28
+ self.mean = mean
29
+ self.std = std
30
+
31
+ def __call__(self, image, bbox):
32
+ return TF.normalize(image, self.mean, self.std), bbox
33
+
34
+
35
+ class Resize:
36
+ """
37
+ Resize image to (size, size).
38
+ Bbox is normalized [0,1] so no change needed.
39
+ """
40
+ def __init__(self, size: int = 224):
41
+ self.size = size
42
+
43
+ def __call__(self, image, bbox):
44
+ image = TF.resize(image, [self.size, self.size])
45
+ return image, bbox
46
+
47
+
48
+ class RandomHorizontalFlip:
49
+ """
50
+ Flip image horizontally with probability p.
51
+ Bbox x-coords must be mirrored: x_min' = 1 - x_max, x_max' = 1 - x_min.
52
+ """
53
+ def __init__(self, p: float = 0.5):
54
+ self.p = p
55
+
56
+ def __call__(self, image, bbox):
57
+ if random.random() < self.p:
58
+ image = TF.hflip(image)
59
+ x_min, y_min, x_max, y_max = bbox
60
+ bbox = [1.0 - x_max, y_min, 1.0 - x_min, y_max]
61
+ return image, bbox
62
+
63
+
64
+ class RandomColorJitter:
65
+ """Color jitter on image only. Bbox unchanged."""
66
+ def __init__(self, brightness=0.3, contrast=0.3, saturation=0.3, hue=0.1):
67
+ self.jitter = transforms.ColorJitter(
68
+ brightness=brightness,
69
+ contrast=contrast,
70
+ saturation=saturation,
71
+ hue=hue,
72
+ )
73
+
74
+ def __call__(self, image, bbox):
75
+ return self.jitter(image), bbox
76
+
77
+
78
+ class RandomCrop:
79
+ """
80
+ Crop a random region of the image, keeping at least min_overlap
81
+ of the primary bbox inside the crop. Falls back to center crop
82
+ if a valid crop isn't found within max_attempts.
83
+
84
+ Bbox is recalculated relative to the cropped region.
85
+ """
86
+ def __init__(self, size: int = 224, min_overlap: float = 0.7, max_attempts: int = 10):
87
+ self.size = size
88
+ self.min_overlap = min_overlap
89
+ self.max_attempts = max_attempts
90
+
91
+ def __call__(self, image, bbox):
92
+ w, h = image.size # PIL: (width, height)
93
+ x_min, y_min, x_max, y_max = bbox
94
+
95
+ # convert normalized bbox to pixel coords
96
+ bx1, by1 = x_min * w, y_min * h
97
+ bx2, by2 = x_max * w, y_max * h
98
+
99
+ crop_w = min(self.size, w)
100
+ crop_h = min(self.size, h)
101
+
102
+ for _ in range(self.max_attempts):
103
+ left = random.randint(0, max(0, w - crop_w))
104
+ top = random.randint(0, max(0, h - crop_h))
105
+ right = left + crop_w
106
+ bottom = top + crop_h
107
+
108
+ # intersection with bbox
109
+ ix1 = max(bx1, left)
110
+ iy1 = max(by1, top)
111
+ ix2 = min(bx2, right)
112
+ iy2 = min(by2, bottom)
113
+
114
+ if ix2 > ix1 and iy2 > iy1:
115
+ inter_area = (ix2 - ix1) * (iy2 - iy1)
116
+ bbox_area = (bx2 - bx1) * (by2 - by1)
117
+ if bbox_area > 0 and (inter_area / bbox_area) >= self.min_overlap:
118
+ image = TF.crop(image, top, left, crop_h, crop_w)
119
+ image = TF.resize(image, [self.size, self.size])
120
+
121
+ # recalculate bbox relative to crop, re-normalize
122
+ new_bbox = [
123
+ (bx1 - left) / crop_w,
124
+ (by1 - top) / crop_h,
125
+ (bx2 - left) / crop_w,
126
+ (by2 - top) / crop_h,
127
+ ]
128
+ new_bbox = [max(0.0, min(1.0, v)) for v in new_bbox]
129
+ return image, new_bbox
130
+
131
+ # fallback: center crop
132
+ left = (w - crop_w) // 2
133
+ top = (h - crop_h) // 2
134
+ image = TF.crop(image, top, left, crop_h, crop_w)
135
+ image = TF.resize(image, [self.size, self.size])
136
+ new_bbox = [
137
+ (bx1 - left) / crop_w,
138
+ (by1 - top) / crop_h,
139
+ (bx2 - left) / crop_w,
140
+ (by2 - top) / crop_h,
141
+ ]
142
+ new_bbox = [max(0.0, min(1.0, v)) for v in new_bbox]
143
+ return image, new_bbox
144
+
145
+
146
+ def get_train_transforms(size: int = 224) -> Compose:
147
+ return Compose([
148
+ Resize(size + 32), # resize slightly larger first
149
+ RandomCrop(size), # then random crop to target size
150
+ RandomHorizontalFlip(p=0.5),
151
+ RandomColorJitter(),
152
+ ToTensor(),
153
+ Normalize(),
154
+ ])
155
+
156
+
157
+ def get_val_transforms(size: int = 224) -> Compose:
158
+ return Compose([
159
+ Resize(size),
160
+ ToTensor(),
161
+ Normalize(),
162
+ ])
src/data/utils.py CHANGED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import xml.etree.ElementTree as ET
3
+ from pathlib import Path
4
+ from typing import Dict, List, Tuple, Optional
5
+ import torch
6
+ from torch.utils.data import DataLoader
7
+
8
+
9
+ VOC_CLASSES = [
10
+ "aeroplane", "bicycle", "bird", "boat", "bottle",
11
+ "bus", "car", "cat", "chair", "cow",
12
+ "diningtable", "dog", "horse", "motorbike", "person",
13
+ "pottedplant", "sheep", "sofa", "train", "tvmonitor"
14
+ ]
15
+
16
+ CLASS_TO_IDX: Dict[str, int] = {cls: idx for idx, cls in enumerate(VOC_CLASSES)}
17
+ IDX_TO_CLASS: Dict[int, str] = {idx: cls for cls, idx in CLASS_TO_IDX.items()}
18
+ NUM_CLASSES = len(VOC_CLASSES)
19
+
20
+
21
+ def parse_voc_xml(xml_path: str) -> Dict:
22
+ """
23
+ Parse a single PASCAL VOC annotation XML file.
24
+
25
+ Returns a dict with:
26
+ - image_path: str
27
+ - width: int
28
+ - height: int
29
+ - objects: List of dicts, each with:
30
+ - name: str (class label)
31
+ - label_idx: int
32
+ - bbox: [x_min, y_min, x_max, y_max] normalized to [0, 1]
33
+ - difficult: bool
34
+ """
35
+ tree = ET.parse(xml_path)
36
+ root = tree.getroot()
37
+
38
+ folder = root.findtext("folder", default="VOC2012")
39
+ filename = root.findtext("filename")
40
+
41
+ size = root.find("size")
42
+ width = int(size.findtext("width"))
43
+ height = int(size.findtext("height"))
44
+
45
+ objects = []
46
+ for obj in root.findall("object"):
47
+ name = obj.findtext("name")
48
+ if name not in CLASS_TO_IDX:
49
+ continue
50
+
51
+ difficult = bool(int(obj.findtext("difficult", default="0")))
52
+
53
+ bndbox = obj.find("bndbox")
54
+ x_min = float(bndbox.findtext("xmin"))
55
+ y_min = float(bndbox.findtext("ymin"))
56
+ x_max = float(bndbox.findtext("xmax"))
57
+ y_max = float(bndbox.findtext("ymax"))
58
+
59
+ # normalize to [0, 1]
60
+ bbox_norm = [
61
+ x_min / width,
62
+ y_min / height,
63
+ x_max / width,
64
+ y_max / height,
65
+ ]
66
+ # clamp to valid range
67
+ bbox_norm = [max(0.0, min(1.0, v)) for v in bbox_norm]
68
+
69
+ objects.append({
70
+ "name": name,
71
+ "label_idx": CLASS_TO_IDX[name],
72
+ "bbox": bbox_norm,
73
+ "difficult": difficult,
74
+ })
75
+
76
+ return {
77
+ "filename": filename,
78
+ "width": width,
79
+ "height": height,
80
+ "objects": objects,
81
+ }
82
+
83
+
84
+ def get_primary_object(parsed: Dict) -> Optional[Dict]:
85
+ """
86
+ For multi-task training we need one label + one bbox per image.
87
+ Strategy: pick the largest non-difficult bounding box by area.
88
+ Falls back to any object if all are marked difficult.
89
+ """
90
+ objects = parsed["objects"]
91
+ if not objects:
92
+ return None
93
+
94
+ non_difficult = [o for o in objects if not o["difficult"]]
95
+ candidates = non_difficult if non_difficult else objects
96
+
97
+ def bbox_area(obj):
98
+ b = obj["bbox"]
99
+ return (b[2] - b[0]) * (b[3] - b[1])
100
+
101
+ return max(candidates, key=bbox_area)
102
+
103
+
104
+ def load_image_ids(voc_root: str, split: str = "train") -> List[str]:
105
+ """
106
+ Load image IDs from VOC ImageSets/Main/<split>.txt.
107
+ split: 'train', 'val', or 'trainval'
108
+ """
109
+ split_file = Path(voc_root) / "ImageSets" / "Main" / f"{split}.txt"
110
+ if not split_file.exists():
111
+ raise FileNotFoundError(f"Split file not found: {split_file}")
112
+
113
+ with open(split_file) as f:
114
+ ids = [line.strip() for line in f if line.strip()]
115
+ return ids
116
+
117
+
118
+ def collate_fn(batch: List) -> Tuple:
119
+ """
120
+ Custom collate for DataLoader.
121
+ Filters out None samples (images that failed to parse).
122
+ Returns:
123
+ images: Tensor [B, C, H, W]
124
+ labels: Tensor [B] (long)
125
+ bboxes: Tensor [B, 4] (float, normalized)
126
+ image_ids: List[str]
127
+ """
128
+ batch = [b for b in batch if b is not None]
129
+ if not batch:
130
+ return None, None, None, []
131
+
132
+ images = torch.stack([b[0] for b in batch])
133
+ labels = torch.tensor([b[1] for b in batch], dtype=torch.long)
134
+ bboxes = torch.tensor([b[2] for b in batch], dtype=torch.float32)
135
+ image_ids = [b[3] for b in batch]
136
+
137
+ return images, labels, bboxes, image_ids