Spaces:
Running on Zero
Running on Zero
OmUniyal commited on
Commit ·
69ce2b7
1
Parent(s): 8f0fde8
feat: phase 1 - data pipeline (utils, transforms, dataset)
Browse files- src/data/dataset.py +162 -0
- src/data/transforms.py +162 -0
- 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
|