Spaces:
Sleeping
Sleeping
File size: 6,471 Bytes
b379113 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 | """IDRiD lesion segmentation dataset: on-the-fly patch sampling from full-resolution images.
IDRiD images are 2848x4288 -- about 17x the pixel count of the 1024px grading
images this project trains at elsewhere, and holding even one at full
resolution through a segmentation decoder is not something this project's
measured memory budget (docs/05_PROTOTYPE_SCOPE.md) supports. The roadmap's
own plan is "patch sampling at full resolution" (docs/04_ROADMAP.md, Phase 4),
not downscaling -- a hard exudate here is 0.07% of the image by pixel count
(measured directly, IDRiD_41_EX.tif), so shrinking the image shrinks the
lesion out of existence before the model ever sees it.
Patches are drawn fresh every `__getitem__` call, not pre-extracted once,
biased toward lesion-containing regions (`lesion_patch_prob`) -- a uniformly
random 512x512 crop of a 2848x4288 image with <0.1% positive pixels would
return an all-background patch almost every time, and a model trained on
"predict background" batches learns exactly that.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import albumentations as A
import cv2
import numpy as np
from albumentations.pytorch import ToTensorV2
from torch.utils.data import Dataset
__all__ = [
"LesionImagePair",
"find_idrid_lesion_pairs",
"IDRiDLesionDataset",
"build_patch_transforms",
]
LESION_FOLDERS = {
"microaneurysms": "1. Microaneurysms",
"haemorrhages": "2. Haemorrhages",
"hard_exudates": "3. Hard Exudates",
"soft_exudates": "4. Soft Exudates",
"optic_disc": "5. Optic Disc",
}
# IDRiD's own mask filename suffixes, per lesion type -- not derivable from
# the folder name alone (e.g. "Hard Exudates" -> "_EX", not "_HardExudates").
LESION_SUFFIXES = {
"microaneurysms": "_MA",
"haemorrhages": "_HE",
"hard_exudates": "_EX",
"soft_exudates": "_SE",
"optic_disc": "_OD",
}
@dataclass(frozen=True)
class LesionImagePair:
image_id: str
image_path: Path
mask_path: Path
def find_idrid_lesion_pairs(
idrid_root: str | Path, lesion: str, split: str
) -> list[LesionImagePair]:
"""Match IDRiD images to their mask for one lesion type and split.
Args:
idrid_root: e.g. "data/raw/idrid".
lesion: a key of LESION_FOLDERS.
split: "train" or "test".
"""
if lesion not in LESION_FOLDERS:
raise ValueError(f"unknown lesion {lesion!r}; expected one of {sorted(LESION_FOLDERS)}")
root = Path(idrid_root)
split_dir = "a. Training Set" if split == "train" else "b. Testing Set"
image_dir = root / "A. Segmentation" / "1. Original Images" / split_dir
mask_dir = (
root
/ "A. Segmentation"
/ "2. All Segmentation Groundtruths"
/ split_dir
/ LESION_FOLDERS[lesion]
)
pairs = []
for image_path in sorted(image_dir.glob("*.jpg")):
mask_path = mask_dir / f"{image_path.stem}{LESION_SUFFIXES[lesion]}.tif"
if mask_path.exists():
pairs.append(
LesionImagePair(
image_id=image_path.stem, image_path=image_path, mask_path=mask_path
)
)
return pairs
def build_patch_transforms(patch_size: int, train: bool) -> A.Compose:
"""Same reasoning as drdetect.data.dataset.build_transforms: flips/rotation
are label-preserving for a fundus with no canonical orientation, and no
blur/noise, since it would erase small lesions the same way it would
erase microaneurysms in the grading pipeline."""
normalise = A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225))
if not train:
return A.Compose([normalise, ToTensorV2()])
return A.Compose(
[
A.HorizontalFlip(p=0.5),
A.VerticalFlip(p=0.5),
A.RandomRotate90(p=0.5),
A.RandomBrightnessContrast(brightness_limit=0.15, contrast_limit=0.15, p=0.5),
normalise,
ToTensorV2(),
]
)
class IDRiDLesionDataset(Dataset):
"""One lesion type, patch-sampled on the fly.
`patches_per_image` fixes an epoch's length to something meaningful for a
54-image training set -- without it, `len(dataset) == len(pairs)` would
make one epoch see 54 patches total, most of a 2848x4288 image never
sampled at all.
"""
def __init__(
self,
pairs: list[LesionImagePair],
*,
patch_size: int = 512,
train: bool = True,
patches_per_image: int = 20,
lesion_patch_prob: float = 0.8,
seed: int = 42,
):
self.pairs = pairs
self.patch_size = patch_size
self.train = train
self.patches_per_image = patches_per_image
self.lesion_patch_prob = lesion_patch_prob
self.transform = build_patch_transforms(patch_size, train)
self._rng = np.random.default_rng(seed)
def __len__(self) -> int:
return len(self.pairs) * self.patches_per_image
def _load(self, pair: LesionImagePair) -> tuple[np.ndarray, np.ndarray]:
image = cv2.imread(str(pair.image_path), cv2.IMREAD_COLOR)
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
mask = cv2.imread(str(pair.mask_path), cv2.IMREAD_GRAYSCALE)
mask = (mask > 0).astype(np.uint8)
return image, mask
def _sample_patch(self, image: np.ndarray, mask: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
h, w = mask.shape
p = self.patch_size
ys, xs = np.where(mask > 0)
want_lesion_patch = len(ys) > 0 and self._rng.random() < self.lesion_patch_prob
if want_lesion_patch:
i = self._rng.integers(0, len(ys))
cy, cx = int(ys[i]), int(xs[i])
top = int(np.clip(cy - p // 2 + self._rng.integers(-p // 4, p // 4 + 1), 0, h - p))
left = int(np.clip(cx - p // 2 + self._rng.integers(-p // 4, p // 4 + 1), 0, w - p))
else:
top = int(self._rng.integers(0, max(h - p, 1)))
left = int(self._rng.integers(0, max(w - p, 1)))
return image[top : top + p, left : left + p], mask[top : top + p, left : left + p]
def __getitem__(self, idx: int):
pair = self.pairs[idx % len(self.pairs)]
image, mask = self._load(pair)
patch_img, patch_mask = self._sample_patch(image, mask)
out = self.transform(image=patch_img, mask=patch_mask)
return out["image"], out["mask"].float().unsqueeze(0)
|