Spaces:
Running on Zero
Running on Zero
| """ | |
| datasets.py — Image/mask/fixation loaders for the two reported benchmarks, | |
| plus a small base class so adding a third dataset is just a few lines. | |
| Expected folder layout (same for both datasets): | |
| <root>/ | |
| images/ one image per case (.jpg/.png for Kvasir, .dcm for prostate MRI) | |
| masks/ matching binary mask, same basename, .png | |
| <fixation_csv> | |
| one row per fixation, see gazerefine.gaze for the expected columns. | |
| The IMAGE column must match an images/ filename (Kvasir) or the | |
| DICOM basename + ".jpg" (prostate — fixation collection was run on | |
| JPEG-rendered slices while the model reads the original DICOM). | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import numpy as np | |
| import pandas as pd | |
| import torch | |
| from PIL import Image | |
| from torch.utils.data import Dataset | |
| import torchvision.transforms as T | |
| from .constants import IMG_SIZE, IMG_MEAN, IMG_STD | |
| from .gaze import get_scanpath, IMG_COL | |
| class _BaseGazeDataset(Dataset): | |
| """Shared image/mask transform + fixation-grouping logic. | |
| Subclasses only need to implement ``_load_image(name)`` and provide the | |
| set of valid image ids that have a matching mask and fixation entries. | |
| """ | |
| def __init__(self, root: str, fixation_csv: str, img_size: int = IMG_SIZE): | |
| self.root = root | |
| self.img_dir = os.path.join(root, "images") | |
| self.mask_dir = os.path.join(root, "masks") | |
| self.img_size = img_size | |
| self.df = pd.read_csv(fixation_csv) | |
| self.df.columns = self.df.columns.str.strip() | |
| self.fix_df = self.df.groupby(IMG_COL) | |
| self.max_len = int(self.df.groupby(IMG_COL).size().max()) | |
| print(f"[Dataset] max scanpath length = {self.max_len}") | |
| self.image_ids: list[str] = [] # set by subclass __init__ | |
| self.img_tf = T.Compose([ | |
| T.Resize((img_size, img_size)), | |
| T.ToTensor(), | |
| T.Normalize(IMG_MEAN, IMG_STD), | |
| ]) | |
| self.mask_tf = T.Compose([ | |
| T.Resize((img_size, img_size), interpolation=T.InterpolationMode.NEAREST), | |
| T.ToTensor(), | |
| ]) | |
| def __len__(self): | |
| return len(self.image_ids) | |
| def _load_image(self, name: str) -> Image.Image: | |
| raise NotImplementedError | |
| def _mask_path(self, name: str) -> str: | |
| raise NotImplementedError | |
| def _fixation_key(self, name: str) -> str: | |
| """CSV IMAGE-column key for this sample. Override if it differs from | |
| the on-disk basename (e.g. prostate MRI uses .jpg keys for .dcm files).""" | |
| return name | |
| def __getitem__(self, idx: int): | |
| name = self.image_ids[idx] | |
| image = self.img_tf(self._load_image(name)) | |
| mask = self.mask_tf(Image.open(self._mask_path(name)).convert("L")) | |
| mask = (mask > 0.5).float() | |
| fix_rows = self.fix_df.get_group(self._fixation_key(name)) | |
| fixation = get_scanpath(fix_rows, self.max_len) | |
| return {"image": image, "fixation": fixation, "mask": mask, "name": name} | |
| class KvasirSEGDataset(_BaseGazeDataset): | |
| """Kvasir-SEG colonoscopy polyp segmentation. images/masks share filenames | |
| (e.g. ``cju0qkwl35piu0993l0dewei2.jpg`` in both folders).""" | |
| def __init__(self, root: str, fixation_csv: str, img_size: int = IMG_SIZE): | |
| super().__init__(root, fixation_csv, img_size) | |
| img_files = set(os.listdir(self.img_dir)) | |
| mask_files = set(os.listdir(self.mask_dir)) | |
| csv_imgs = set(self.df[IMG_COL].unique()) | |
| self.image_ids = sorted(img_files & mask_files & csv_imgs) | |
| if not self.image_ids: | |
| raise RuntimeError("No overlap between images/, masks/ and the fixation CSV.") | |
| print(f"[Dataset] Kvasir-SEG valid samples = {len(self.image_ids)}") | |
| def _load_image(self, name: str) -> Image.Image: | |
| return Image.open(os.path.join(self.img_dir, name)).convert("RGB") | |
| def _mask_path(self, name: str) -> str: | |
| return os.path.join(self.mask_dir, name) | |
| class ProstateMRIDataset(_BaseGazeDataset): | |
| """NCI-ISBI prostate MRI. images/ holds DICOM (.dcm), masks/ holds PNG, | |
| and the fixation CSV references each case as ``<basename>.jpg`` (the | |
| format the eye-tracking session was actually rendered/displayed in).""" | |
| def __init__(self, root: str, fixation_csv: str, img_size: int = IMG_SIZE): | |
| super().__init__(root, fixation_csv, img_size) | |
| dcm_basenames = {os.path.splitext(f)[0] for f in os.listdir(self.img_dir) if f.endswith(".dcm")} | |
| png_basenames = {os.path.splitext(f)[0] for f in os.listdir(self.mask_dir) if f.endswith(".png")} | |
| csv_basenames = {os.path.splitext(f)[0] for f in self.df[IMG_COL].unique()} | |
| self.image_ids = sorted(dcm_basenames & png_basenames & csv_basenames) | |
| if not self.image_ids: | |
| raise RuntimeError("No overlap between images/ (.dcm), masks/ (.png) and the fixation CSV.") | |
| print(f"[Dataset] Prostate MRI valid samples = {len(self.image_ids)}") | |
| def _load_image(self, name: str) -> Image.Image: | |
| import pydicom # local import: optional dependency, only needed for DICOM datasets | |
| dcm_path = os.path.join(self.img_dir, f"{name}.dcm") | |
| dicom = pydicom.dcmread(dcm_path) | |
| arr = dicom.pixel_array.astype(np.float32) | |
| if arr.max() > arr.min(): | |
| arr = (arr - arr.min()) / (arr.max() - arr.min()) | |
| else: | |
| arr = np.zeros_like(arr) | |
| rgb = np.stack([arr, arr, arr], axis=-1) # grayscale -> 3-channel, DINOv3 expects RGB | |
| return Image.fromarray((rgb * 255).astype(np.uint8)) | |
| def _mask_path(self, name: str) -> str: | |
| return os.path.join(self.mask_dir, f"{name}.png") | |
| def _fixation_key(self, name: str) -> str: | |
| return f"{name}.jpg" | |
| DATASET_REGISTRY = { | |
| "kvasir": KvasirSEGDataset, | |
| "prostate_mri": ProstateMRIDataset, | |
| } | |