File size: 11,020 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
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
"""IDRiD optic disc / fovea localisation: heatmap regression, not segmentation.

A genuinely different task from lesion or vessel segmentation -- the target
is a single (x, y) point per structure, not a per-pixel mask. Following the
roadmap's own specified approach ("heatmap regression"), each target point
becomes a 2D Gaussian blob at working resolution; the model predicts a
2-channel heatmap (OD, fovea) and inference reads the point back off as the
argmax of each channel.

IDRiD's "C. Localization" task ships a genuine official split -- 413
training / 103 test images, each in its own CSV with (x, y) pixel
coordinates at full (2848x4288) resolution, for both the optic disc and the
fovea. This is the largest, cleanest-labelled subset of IDRiD used in this
project so far (segmentation's hard-exudate harness has 81 images total;
this one has 516).

OD-diameter normalisation: the roadmap's success criterion is "mean
localisation error < 0.5 x OD diameter", but per-image OD diameter isn't
available for the localisation set itself -- only the disjoint 81-image
segmentation set ships OD masks, and the two subsets use different filename
numbering (IDRiD_01..54 vs IDRiD_001..413/103), so they cannot be joined by
ID. The mean OD diameter measured directly from the 54 real segmentation
masks (527.7px at full 2848x4288 resolution) is used as a fixed proxy
constant instead of a per-image value or an invented one.
"""

from __future__ import annotations

import csv
import math
from dataclasses import dataclass
from pathlib import Path

import albumentations as A
import cv2
import lightning as L
import numpy as np
import torch
import torch.nn as nn
from albumentations.pytorch import ToTensorV2
from torch.utils.data import Dataset

__all__ = [
    "IDRiDLocalizationPair",
    "find_idrid_localization_pairs",
    "build_localization_transforms",
    "make_gaussian_heatmap",
    "heatmap_argmax",
    "IDRiDLocalizationDataset",
    "LocalizationModule",
    "MEAN_OD_DIAMETER_PX_FULL_RES",
    "working_res_od_diameter",
    "euclidean",
]

# Measured directly from the 54 real IDRiD segmentation-task OD masks (see
# module docstring for why this is a cross-subset proxy, not a per-image
# ground truth value).
MEAN_OD_DIAMETER_PX_FULL_RES = 527.7
_FULL_RES_H = 2848


@dataclass(frozen=True)
class IDRiDLocalizationPair:
    image_id: str
    image_path: Path
    od_xy: tuple[float, float]
    fovea_xy: tuple[float, float]


def _read_coordinate_csv(path: Path) -> dict[str, tuple[float, float]]:
    """IDRiD's markup CSVs ship ~50 trailing empty columns per row and a
    header row with inconsistent spacing ("X- Coordinate", "Y - Coordinate")
    -- read positionally (columns 0-2), not by header name."""
    coords = {}
    with open(path, newline="") as f:
        rows = list(csv.reader(f))
    for row in rows[1:]:
        if not row or not row[0].strip():
            continue
        coords[row[0].strip()] = (float(row[1]), float(row[2]))
    return coords


def find_idrid_localization_pairs(
    idrid_root: str | Path, split: str
) -> list[IDRiDLocalizationPair]:
    """Args: idrid_root e.g. "data/raw/idrid"; split "train" or "test"."""
    root = Path(idrid_root) / "C. Localization"
    split_dir = "a. Training Set" if split == "train" else "b. Testing Set"
    split_label = "Training Set" if split == "train" else "Testing Set"
    image_dir = root / "1. Original Images" / split_dir

    od_csv = (
        root
        / "2. Groundtruths"
        / "1. Optic Disc Center Location"
        / f"{'a' if split == 'train' else 'b'}. IDRiD_OD_Center_{split_label}_Markups.csv"
    )
    fovea_csv = (
        root
        / "2. Groundtruths"
        / "2. Fovea Center Location"
        / f"IDRiD_Fovea_Center_{split_label}_Markups.csv"
    )
    od_coords = _read_coordinate_csv(od_csv)
    fovea_coords = _read_coordinate_csv(fovea_csv)

    pairs = []
    for image_path in sorted(image_dir.glob("*.jpg")):
        image_id = image_path.stem
        if image_id in od_coords and image_id in fovea_coords:
            pairs.append(
                IDRiDLocalizationPair(
                    image_id=image_id,
                    image_path=image_path,
                    od_xy=od_coords[image_id],
                    fovea_xy=fovea_coords[image_id],
                )
            )
    return pairs


def build_localization_transforms(size: tuple[int, int], train: bool) -> A.Compose:
    """`size` is (height, width). Flips are included, unlike segmentation's
    IDRiD transforms sharing the same rotation-invariance reasoning does NOT
    apply here -- flipping DOES change which anatomical side is which, but
    that's fine: the (x, y) keypoints are flipped consistently with the
    image by albumentations' own keypoint handling, and the model is trained
    to predict two independent points, not a rotation-invariant blob."""
    normalise = A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225))
    keypoint_params = A.KeypointParams(format="xy", remove_invisible=False)
    if not train:
        return A.Compose(
            [A.Resize(*size), normalise, ToTensorV2()], keypoint_params=keypoint_params
        )
    return A.Compose(
        [
            A.Resize(*size),
            A.HorizontalFlip(p=0.5),
            A.VerticalFlip(p=0.5),
            A.RandomBrightnessContrast(brightness_limit=0.15, contrast_limit=0.15, p=0.5),
            normalise,
            ToTensorV2(),
        ],
        keypoint_params=keypoint_params,
    )


def make_gaussian_heatmap(h: int, w: int, cx: float, cy: float, sigma: float) -> np.ndarray:
    """A single-channel float32 Gaussian blob peaking at 1.0 at (cx, cy)."""
    ys, xs = np.mgrid[0:h, 0:w]
    heatmap = np.exp(-(((xs - cx) ** 2 + (ys - cy) ** 2) / (2.0 * sigma**2)))
    return heatmap.astype(np.float32)


def heatmap_argmax(heatmap: np.ndarray) -> tuple[float, float]:
    """(x, y) of a 2D array's peak."""
    y, x = np.unravel_index(np.argmax(heatmap), heatmap.shape)
    return float(x), float(y)


class IDRiDLocalizationDataset(Dataset):
    def __init__(
        self,
        pairs: list[IDRiDLocalizationPair],
        *,
        size: tuple[int, int] = (512, 768),
        train: bool = True,
        sigma: float = 12.0,
    ):
        self.pairs = pairs
        self.size = size
        self.sigma = sigma
        self.transform = build_localization_transforms(size, train)

    def __len__(self) -> int:
        return len(self.pairs)

    def __getitem__(self, idx: int):
        pair = self.pairs[idx]
        image = cv2.cvtColor(cv2.imread(str(pair.image_path), cv2.IMREAD_COLOR), cv2.COLOR_BGR2RGB)
        keypoints = [pair.od_xy, pair.fovea_xy]

        out = self.transform(image=image, keypoints=keypoints)
        h, w = self.size
        (od_x, od_y), (fov_x, fov_y) = out["keypoints"]

        od_heatmap = make_gaussian_heatmap(h, w, od_x, od_y, self.sigma)
        fovea_heatmap = make_gaussian_heatmap(h, w, fov_x, fov_y, self.sigma)
        heatmaps = torch.from_numpy(np.stack([od_heatmap, fovea_heatmap]))

        targets_xy = torch.tensor([[od_x, od_y], [fov_x, fov_y]], dtype=torch.float32)
        return out["image"], heatmaps, targets_xy


def working_res_od_diameter(size: tuple[int, int]) -> float:
    """MEAN_OD_DIAMETER_PX_FULL_RES rescaled to a `size=(h, w)` working
    resolution, assuming the uniform-scale resize `build_localization_transforms`
    actually performs (height ratio == width ratio, since the working
    aspect ratio is chosen to match IDRiD's real one)."""
    scale = size[0] / _FULL_RES_H
    return MEAN_OD_DIAMETER_PX_FULL_RES * scale


def euclidean(a: tuple[float, float], b: tuple[float, float]) -> float:
    return math.hypot(a[0] - b[0], a[1] - b[1])


class LocalizationModule(L.LightningModule):
    """MSE between predicted (sigmoid) heatmaps and target Gaussians --
    standard for heatmap-regression landmark detection. Validation reads the
    point back off each predicted channel's argmax and reports Euclidean
    error in OD-diameter units, the roadmap's own stated success unit.
    """

    def __init__(
        self,
        model: nn.Module,
        *,
        lr: float = 1e-4,
        weight_decay: float = 1e-4,
        working_od_diameter: float = 1.0,
        max_epochs: int = 40,
    ):
        super().__init__()
        self.save_hyperparameters(ignore=["model"])
        self.model = model
        self._val_pred_heatmaps: list[torch.Tensor] = []
        self._val_target_xy: list[torch.Tensor] = []

    def forward(self, x):
        return self.model(x)

    def _loss(self, logits: torch.Tensor, target_heatmaps: torch.Tensor) -> torch.Tensor:
        probs = torch.sigmoid(logits)
        return nn.functional.mse_loss(probs, target_heatmaps)

    def training_step(self, batch, batch_idx):
        x, heatmaps, _ = batch
        logits = self(x)
        loss = self._loss(logits, heatmaps)
        if not torch.isfinite(loss):
            raise RuntimeError(f"Localisation loss is {loss.item()} at step {batch_idx}. Diverged.")
        self.log("train/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
        return loss

    def validation_step(self, batch, batch_idx):
        x, heatmaps, targets_xy = batch
        logits = self(x)
        loss = self._loss(logits, heatmaps)
        self.log("val/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
        self._val_pred_heatmaps.append(torch.sigmoid(logits).detach().float().cpu())
        self._val_target_xy.append(targets_xy.detach().cpu())

    def on_validation_epoch_end(self):
        if not self._val_pred_heatmaps:
            return
        preds = torch.cat(self._val_pred_heatmaps).numpy()
        targets = torch.cat(self._val_target_xy).numpy()

        od_errors, fovea_errors = [], []
        diameter = self.hparams.working_od_diameter
        for i in range(preds.shape[0]):
            od_pred = heatmap_argmax(preds[i, 0])
            fovea_pred = heatmap_argmax(preds[i, 1])
            od_errors.append(euclidean(od_pred, tuple(targets[i, 0])) / diameter)
            fovea_errors.append(euclidean(fovea_pred, tuple(targets[i, 1])) / diameter)

        od_mean = float(np.mean(od_errors))
        fovea_mean = float(np.mean(fovea_errors))
        self.log("val/od_error_diameters", od_mean, prog_bar=True)
        self.log("val/fovea_error_diameters", fovea_mean, prog_bar=True)
        self.log("val/mean_error_diameters", (od_mean + fovea_mean) / 2.0, prog_bar=True)

        self._val_pred_heatmaps.clear()
        self._val_target_xy.clear()

    def configure_optimizers(self):
        optimiser = torch.optim.AdamW(
            self.model.parameters(), lr=self.hparams.lr, weight_decay=self.hparams.weight_decay
        )
        scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
            optimiser, T_max=self.hparams.max_epochs
        )
        return {
            "optimizer": optimiser,
            "lr_scheduler": {"scheduler": scheduler, "interval": "epoch"},
        }