| 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"
|
| if "spice_spectrum" in p:
|
| return "spice_spectrum"
|
| return "unknown"
|
|
|
|
|
|
|
|
|
| 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),
|
|
|
|
|
|
|
| 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(),
|
| ])
|
|
|
|
|
|
|
|
|
| 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/."""
|
|
|
| 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}"
|
|
|
| if os.name == "nt" and p.startswith("/mnt/"):
|
| parts = p.split("/", 3)
|
| 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
|
|
|
|
|
|
|
|
|
| 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"))
|
|
|
|
|
| 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]
|
|
|
|
|
|
|
|
|
| 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,
|
| )
|
|
|