SpiceNet / code /src /dataset.py
Noushad999's picture
Upload folder using huggingface_hub
1ea7ba6 verified
Raw
History Blame Contribute Delete
11.7 kB
import os
from pathlib import Path
from typing import List, Optional, Tuple
import numpy as np
from PIL import Image
import torch
from torch.utils.data import Dataset, DataLoader
import albumentations as A
from albumentations.pytorch import ToTensorV2
from sklearn.model_selection import train_test_split
import config
try:
from src.features import extract_all as _extract_all
_FEATURES_OK = True
except Exception:
_FEATURES_OK = False
def source_of(path: str) -> str:
"""Recover the acquisition source (domain) from a sample path."""
p = str(path).lower().replace("\\", "/")
if "indian_spices" in p:
return "indian" # studio (white background)
if "spice_spectrum" in p:
return "spice_spectrum" # in-the-wild
return "unknown"
# ── Transforms ───────────────────────────────────────────────────────────────
def get_train_transform(img_size: int = config.IMG_SIZE) -> A.Compose:
return A.Compose([
A.RandomResizedCrop(size=(img_size, img_size), scale=config.AUG_SCALE, ratio=(0.75, 1.33)),
A.HorizontalFlip(p=0.5),
A.VerticalFlip(p=0.3),
A.Rotate(limit=config.AUG_ROTATION, p=0.6),
A.ColorJitter(
brightness=config.AUG_BRIGHTNESS,
contrast=config.AUG_CONTRAST,
saturation=config.AUG_SATURATION,
hue=config.AUG_HUE,
p=0.7,
),
A.OneOf([
A.GaussianBlur(blur_limit=(3, 5)),
A.MotionBlur(blur_limit=5),
], p=0.3),
A.GaussNoise(p=0.2),
A.CoarseDropout(
num_holes_range=(1, 8),
hole_height_range=(8, 32),
hole_width_range=(8, 32),
fill=0,
p=0.3,
),
A.Normalize(mean=config.IMG_MEAN, std=config.IMG_STD),
ToTensorV2(),
])
def get_train_transform_strong(img_size: int = config.IMG_SIZE) -> A.Compose:
"""Anti-shortcut augmentation pipeline.
Built from peer-reviewed building blocks:
* Random Erasing with random-uniform & inpaint fills (Zhong et al. 2017,
AAAI 2020) β€” denies the model fixed background context.
* Heavier ColorJitter + Hue shift β€” denies stable color shortcuts.
* RandomResizedCrop + Affine β€” denies stable position shortcuts.
* Stronger blur/noise mixture β€” denies texture shortcuts.
Returns a Compose that should be drop-in compatible with the default
train transform (same input dtype, same output tensor shape).
"""
return A.Compose([
A.RandomResizedCrop(size=(img_size, img_size), scale=(0.6, 1.0), ratio=(0.75, 1.33)),
A.HorizontalFlip(p=0.5),
A.VerticalFlip(p=0.3),
A.Affine(rotate=(-config.AUG_ROTATION, config.AUG_ROTATION),
translate_percent=(0.0, 0.08), scale=(0.9, 1.1), p=0.5),
A.ColorJitter(
brightness=0.35, contrast=0.35,
saturation=0.35, hue=0.12, p=0.75,
),
A.OneOf([
A.GaussianBlur(blur_limit=(3, 5)),
A.MotionBlur(blur_limit=5),
], p=0.30),
A.GaussNoise(std_range=(0.03, 0.10), p=0.20),
# Anti-shortcut erasure: 1-3 medium holes per image with varied fills.
# Sized so total occluded area stays <~20% of the image, leaving the
# spice visible while breaking any fixed-background dependency.
A.OneOf([
A.CoarseDropout(num_holes_range=(1, 3),
hole_height_range=(20, 48),
hole_width_range=(20, 48),
fill="random_uniform", p=1.0),
A.CoarseDropout(num_holes_range=(1, 3),
hole_height_range=(20, 48),
hole_width_range=(20, 48),
fill="random", p=1.0),
A.CoarseDropout(num_holes_range=(1, 2),
hole_height_range=(30, 60),
hole_width_range=(30, 60),
fill=0, p=1.0),
], p=0.55),
A.Normalize(mean=config.IMG_MEAN, std=config.IMG_STD),
ToTensorV2(),
])
def get_val_transform(img_size: int = config.IMG_SIZE) -> A.Compose:
resize = int(img_size * 256 / 224)
return A.Compose([
A.Resize(resize, resize),
A.CenterCrop(img_size, img_size),
A.Normalize(mean=config.IMG_MEAN, std=config.IMG_STD),
ToTensorV2(),
])
# ── Splits ────────────────────────────────────────────────────────────────────
def _collect(data_dir: Path, classes: List[str]):
paths, labels = [], []
for idx, cls in enumerate(classes):
for fp in sorted((data_dir / cls).iterdir()):
if fp.suffix.lower() in {".jpg", ".jpeg", ".png"}:
paths.append(str(fp))
labels.append(idx)
return paths, labels
def build_splits(data_dir: Path = config.DATA_DIR):
paths, labels = _collect(data_dir, config.CLASSES)
x_tr, x_tmp, y_tr, y_tmp = train_test_split(
paths, labels, test_size=1 - config.TRAIN_RATIO,
stratify=labels, random_state=config.RANDOM_SEED,
)
val_frac = config.VAL_RATIO / (1 - config.TRAIN_RATIO)
x_val, x_te, y_val, y_te = train_test_split(
x_tmp, y_tmp, test_size=1 - val_frac,
stratify=y_tmp, random_state=config.RANDOM_SEED,
)
return x_tr, y_tr, x_val, y_val, x_te, y_te
def _translate_path(p: str) -> str:
"""Cross-platform path translation between Windows D:\\ and WSL /mnt/d/."""
# On Linux/WSL, convert Windows path -> /mnt/<drive>/...
if os.name != "nt" and len(p) > 2 and p[1] == ":":
drive = p[0].lower()
rest = p[2:].replace("\\", "/").lstrip("/")
return f"/mnt/{drive}/{rest}"
# On Windows, convert /mnt/d/... -> D:\\...
if os.name == "nt" and p.startswith("/mnt/"):
parts = p.split("/", 3) # ['', 'mnt', 'd', 'rest']
if len(parts) >= 4:
return f"{parts[2].upper()}:\\{parts[3].replace('/', os.sep)}"
return p
def load_manifest_splits(manifest_path):
"""Load (paths, labels) per split from a unified-benchmark manifest JSON.
Handles Windows<->WSL path translation transparently.
"""
import json
with open(manifest_path) as f:
m = json.load(f)
out = {}
for split in ("train", "val", "test"):
paths = [_translate_path(p) for p, _ in m["samples"][split]]
labels = [int(y) for _, y in m["samples"][split]]
out[split] = (paths, labels)
classes = [c["name"] for c in sorted(m["classes"], key=lambda c: c["index"])]
return out, classes
# ── Dataset ───────────────────────────────────────────────────────────────────
class SpiceDataset(Dataset):
"""Unified dataset β€” returns (image, texture, color, label).
texture/color are zero tensors if multimodal=False or features unavailable.
"""
def __init__(
self,
paths: List[str],
labels: List[int],
transform: Optional[A.Compose] = None,
multimodal: bool = False,
return_source: bool = False,
source_map: Optional[dict] = None,
):
self.paths = paths
self.labels = labels
self.transform = transform
self.multimodal = multimodal and _FEATURES_OK
self.return_source = return_source
self.source_map = source_map or {}
def __len__(self):
return len(self.paths)
def __getitem__(self, idx):
img_np = np.array(Image.open(self.paths[idx]).convert("RGB"))
# Extract hand-crafted features BEFORE augmentation (on original image)
if self.multimodal:
tex_np, col_np = _extract_all(img_np)
tex = torch.from_numpy(tex_np)
col = torch.from_numpy(col_np)
else:
tex = torch.zeros(config.TEX_INPUT_DIM)
col = torch.zeros(config.COL_INPUT_DIM)
if self.transform:
img_np = self.transform(image=img_np)["image"]
if self.return_source:
src = self.source_map.get(source_of(self.paths[idx]), -1)
return img_np, tex, col, self.labels[idx], src
return img_np, tex, col, self.labels[idx]
# ── DataLoaders ───────────────────────────────────────────────────────────────
def get_dataloaders(
multimodal: bool = False,
data_dir: Path = config.DATA_DIR,
batch_size: int = config.BATCH_SIZE,
num_workers: int = config.NUM_WORKERS,
strong_aug: bool = False,
manifest_path: Optional[str] = None,
):
"""Build train/val/test dataloaders.
If `manifest_path` is given, splits come from the unified-benchmark manifest;
otherwise the legacy `build_splits` on `data_dir` is used.
"""
if manifest_path is not None:
splits, _ = load_manifest_splits(manifest_path)
x_tr, y_tr = splits["train"]
x_val, y_val = splits["val"]
x_te, y_te = splits["test"]
else:
x_tr, y_tr, x_val, y_val, x_te, y_te = build_splits(data_dir)
train_tf = get_train_transform_strong() if strong_aug else get_train_transform()
tr_ds = SpiceDataset(x_tr, y_tr, train_tf, multimodal)
val_ds = SpiceDataset(x_val, y_val, get_val_transform(), multimodal)
te_ds = SpiceDataset(x_te, y_te, get_val_transform(), multimodal)
mk = dict(batch_size=batch_size, num_workers=num_workers, pin_memory=True)
return (
DataLoader(tr_ds, shuffle=True, drop_last=True, **mk),
DataLoader(val_ds, shuffle=False, **mk),
DataLoader(te_ds, shuffle=False, **mk),
x_te, y_te,
)
def get_aifnet_dataloaders(
manifest_path: str,
batch_size: int = config.BATCH_SIZE,
num_workers: int = config.NUM_WORKERS,
strong_aug: bool = True,
):
"""Source-aware multimodal loaders for AIFNet.
Each batch is (image, texture, color, label, source_id). The source_map maps
acquisition-source name -> integer id (built deterministically from all paths
in the manifest, so train/val/test share the same id space).
"""
splits, classes = load_manifest_splits(manifest_path)
all_paths = splits["train"][0] + splits["val"][0] + splits["test"][0]
source_map = {s: i for i, s in enumerate(sorted({source_of(p) for p in all_paths}))}
train_tf = get_train_transform_strong() if strong_aug else get_train_transform()
def _ds(split, tf):
p, y = splits[split]
return SpiceDataset(p, y, tf, multimodal=True, return_source=True, source_map=source_map)
mk = dict(batch_size=batch_size, num_workers=num_workers, pin_memory=True)
return (
DataLoader(_ds("train", train_tf), shuffle=True, drop_last=True, **mk),
DataLoader(_ds("val", get_val_transform()), shuffle=False, **mk),
DataLoader(_ds("test", get_val_transform()), shuffle=False, **mk),
classes, source_map,
)