fc28 commited on
Commit
203f76b
·
verified ·
1 Parent(s): 0cf9ec6

Upload folder using huggingface_hub

Browse files
Files changed (7) hide show
  1. src/__init__.py +0 -0
  2. src/biomarkers.py +81 -0
  3. src/dataset.py +51 -0
  4. src/gradcam.py +45 -0
  5. src/inference.py +32 -0
  6. src/model.py +97 -0
  7. src/train.py +101 -0
src/__init__.py ADDED
File without changes
src/biomarkers.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Interpretable retinal imaging biomarkers of anti-VEGF intolerance.
4
+
5
+ Computes, from a single color fundus photograph:
6
+ - vascular density : fraction of FOV occupied by the vessel mask
7
+ - vascular skeleton length: normalized length of the vessel skeleton
8
+ - vascular fractal dimension : box-counting fractal dimension of the vessels
9
+
10
+ Vessels are enhanced with CLAHE + morphological black-hat on the green channel.
11
+ PIL is used to read images (supports non-ASCII paths).
12
+ """
13
+ import argparse
14
+ import numpy as np
15
+ import cv2
16
+ from PIL import Image
17
+ from skimage.morphology import skeletonize
18
+
19
+
20
+ def _fov_mask(gray):
21
+ _, m = cv2.threshold(gray, 12, 255, cv2.THRESH_BINARY)
22
+ return m > 0
23
+
24
+
25
+ def _vessel_mask(img_bgr, mask):
26
+ g = img_bgr[:, :, 1]
27
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
28
+ ge = clahe.apply(g)
29
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
30
+ bh = cv2.morphologyEx(ge, cv2.MORPH_BLACKHAT, kernel)
31
+ _, vessel = cv2.threshold(bh, 15, 255, cv2.THRESH_BINARY)
32
+ return (vessel > 0) & mask
33
+
34
+
35
+ def fractal_dimension(binary):
36
+ """Box-counting fractal dimension of a binary structure.
37
+
38
+ D = -slope of log N(eps) vs log eps, where N(eps) is the number of boxes of
39
+ side eps that intersect the structure.
40
+ """
41
+ Z = binary > 0
42
+ if Z.sum() == 0:
43
+ return 0.0
44
+
45
+ def boxcount(Z, k):
46
+ S = np.add.reduceat(
47
+ np.add.reduceat(Z, np.arange(0, Z.shape[0], k), axis=0),
48
+ np.arange(0, Z.shape[1], k), axis=1)
49
+ return len(np.where((S > 0) & (S < k * k))[0])
50
+
51
+ sizes = 2 ** np.arange(1, 7)
52
+ counts = [max(boxcount(Z, int(s)), 1) for s in sizes]
53
+ coeffs = np.polyfit(np.log(sizes), np.log(counts), 1)
54
+ return float(-coeffs[0])
55
+
56
+
57
+ def compute_biomarkers(image_path: str, size: int = 512) -> dict:
58
+ pil = Image.open(image_path).convert("RGB")
59
+ img = cv2.cvtColor(np.array(pil), cv2.COLOR_RGB2BGR)
60
+ img = cv2.resize(img, (size, size))
61
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
62
+ mask = _fov_mask(gray)
63
+ area = max(int(mask.sum()), 1)
64
+
65
+ vessel = _vessel_mask(img, mask)
66
+ density = float(vessel.sum() / area)
67
+ skeleton_len = float(skeletonize(vessel).sum() / area)
68
+ fd = fractal_dimension(vessel)
69
+ return {
70
+ "vascular_density": round(density, 4),
71
+ "vascular_skeleton_length": round(skeleton_len, 4),
72
+ "vascular_fractal_dimension": round(fd, 4),
73
+ }
74
+
75
+
76
+ if __name__ == "__main__":
77
+ ap = argparse.ArgumentParser(description="Compute retinal vascular biomarkers.")
78
+ ap.add_argument("--image", required=True)
79
+ args = ap.parse_args()
80
+ for k, v in compute_biomarkers(args.image).items():
81
+ print(f"{k}: {v}")
src/dataset.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Fundus image dataset and transforms.
4
+
5
+ Expects a manifest CSV with columns:
6
+ image_path,label[,patient_id]
7
+ where label in {0,1} (0 = tolerant / NPDR, 1 = intolerant / PDR).
8
+ patient_id is optional but recommended for patient-level cross-validation splits.
9
+
10
+ NO patient data is distributed with this repository; supply your own
11
+ ethically-approved, de-identified manifest and images.
12
+ """
13
+ import pandas as pd
14
+ from PIL import Image
15
+ from torch.utils.data import Dataset
16
+ from torchvision import transforms
17
+
18
+ IMAGENET_MEAN = [0.485, 0.456, 0.406]
19
+ IMAGENET_STD = [0.229, 0.224, 0.225]
20
+
21
+
22
+ def build_transforms(image_size: int = 224, train: bool = False):
23
+ norm = transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD)
24
+ if train:
25
+ return transforms.Compose([
26
+ transforms.RandomResizedCrop(image_size, scale=(0.8, 1.0)),
27
+ transforms.RandomHorizontalFlip(),
28
+ transforms.RandomRotation(15),
29
+ transforms.ColorJitter(0.1, 0.1, 0.1),
30
+ transforms.ToTensor(), norm,
31
+ ])
32
+ return transforms.Compose([
33
+ transforms.Resize((image_size, image_size)),
34
+ transforms.ToTensor(), norm,
35
+ ])
36
+
37
+
38
+ class FundusDataset(Dataset):
39
+ def __init__(self, manifest_csv: str, image_size: int = 224, train: bool = False):
40
+ self.df = pd.read_csv(manifest_csv)
41
+ assert {"image_path", "label"}.issubset(self.df.columns), \
42
+ "manifest must contain columns: image_path,label[,patient_id]"
43
+ self.tf = build_transforms(image_size, train)
44
+
45
+ def __len__(self):
46
+ return len(self.df)
47
+
48
+ def __getitem__(self, i):
49
+ r = self.df.iloc[i]
50
+ x = self.tf(Image.open(r["image_path"]).convert("RGB"))
51
+ return x, int(r["label"])
src/gradcam.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Grad-CAM explanation for the DINOv2 (ViT-L/14) intolerance classifier.
3
+
4
+ ViT tokens are reshaped to a 16x16 grid (224/14) for spatial attribution.
5
+ """
6
+ import argparse
7
+ import numpy as np
8
+ import torch
9
+ from PIL import Image
10
+ from pytorch_grad_cam import GradCAM
11
+ from pytorch_grad_cam.utils.image import show_cam_on_image
12
+ from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget
13
+ from .model import load_dinov2, dinov2_gradcam_target, dinov2_reshape
14
+ from .dataset import build_transforms
15
+
16
+
17
+ def reshape_transform(tensor, grid=16):
18
+ return dinov2_reshape(tensor, grid)
19
+
20
+
21
+ def gradcam(image_path: str, weights: str, out_path: str = "cam.png",
22
+ device: str = None, target_class: int = 1):
23
+ device = device or ("cuda" if torch.cuda.is_available() else "cpu")
24
+ model = load_dinov2(weights, device)
25
+ target_layers = dinov2_gradcam_target(model)
26
+ cam = GradCAM(model=model, target_layers=target_layers,
27
+ reshape_transform=reshape_transform)
28
+
29
+ pil = Image.open(image_path).convert("RGB").resize((224, 224))
30
+ rgb = np.array(pil).astype(np.float32) / 255.0
31
+ x = build_transforms(224, train=False)(pil).unsqueeze(0).to(device)
32
+ grayscale = cam(input_tensor=x, targets=[ClassifierOutputTarget(target_class)])[0]
33
+ vis = show_cam_on_image(rgb, grayscale, use_rgb=True)
34
+ Image.fromarray(vis).save(out_path)
35
+ print("saved:", out_path)
36
+ return vis
37
+
38
+
39
+ if __name__ == "__main__":
40
+ ap = argparse.ArgumentParser(description="Grad-CAM for the intolerance classifier.")
41
+ ap.add_argument("--image", required=True)
42
+ ap.add_argument("--weights", required=True)
43
+ ap.add_argument("--out", default="cam.png")
44
+ args = ap.parse_args()
45
+ gradcam(args.image, args.weights, args.out)
src/inference.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Single-image (or batch) inference: anti-VEGF intolerance risk score."""
3
+ import argparse
4
+ import torch
5
+ from PIL import Image
6
+ from .model import load_dinov2
7
+ from .dataset import build_transforms
8
+
9
+
10
+ def predict(image_path: str, weights: str, device: str = None, threshold: float = 0.5):
11
+ device = device or ("cuda" if torch.cuda.is_available() else "cpu")
12
+ model = load_dinov2(weights, device) # primary backbone: DINOv2 (ViT-L/14)
13
+ tf = build_transforms(224, train=False)
14
+ x = tf(Image.open(image_path).convert("RGB")).unsqueeze(0).to(device)
15
+ with torch.no_grad():
16
+ prob = torch.softmax(model(x), 1)[0, 1].item()
17
+ return {
18
+ "intolerance_risk": round(prob, 4),
19
+ "prediction": "intolerant" if prob >= threshold else "tolerant",
20
+ "threshold": threshold,
21
+ }
22
+
23
+
24
+ if __name__ == "__main__":
25
+ ap = argparse.ArgumentParser(description="anti-VEGF intolerance risk from a fundus image.")
26
+ ap.add_argument("--image", required=True)
27
+ ap.add_argument("--weights", required=True)
28
+ ap.add_argument("--threshold", type=float, default=0.5)
29
+ args = ap.parse_args()
30
+ out = predict(args.image, args.weights, threshold=args.threshold)
31
+ for k, v in out.items():
32
+ print(f"{k}: {v}")
src/model.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Models for anti-VEGF intolerance prediction.
4
+
5
+ PRIMARY: DINOv2 (ViT-L/14) — a generalist vision foundation model (Meta AI, Apache-2.0),
6
+ fine-tuned for binary classification. Released fine-tuned weights (dino_deploy.pth) are
7
+ Apache-2.0 (free for research and commercial use with attribution). Use `build_dinov2`
8
+ / `load_dinov2`.
9
+
10
+ COMPARATOR: RETFound (ViT-L/16) — a retinal-domain foundation model (Zhou et al.,
11
+ Nature 2023, CC BY-NC 4.0). Provided via `FundusClassifier` for the head-to-head
12
+ comparison reported in the paper; RETFound weights are NOT redistributed here.
13
+ """
14
+ import torch
15
+ import torch.nn as nn
16
+ import timm
17
+
18
+
19
+ class FundusClassifier(nn.Module):
20
+ """ViT-L/16 backbone (RETFound-initialized) + linear head.
21
+
22
+ Args:
23
+ num_classes: 2 (tolerant / intolerant).
24
+ retfound_weights: path to RETFound .pth, or None to start from timm init.
25
+ drop_rate: dropout for the head.
26
+ """
27
+
28
+ def __init__(self, num_classes: int = 2, retfound_weights: str | None = None,
29
+ backbone: str = "vit_large_patch16_224", drop_rate: float = 0.2):
30
+ super().__init__()
31
+ self.backbone = timm.create_model(backbone, pretrained=False,
32
+ num_classes=0, drop_rate=drop_rate)
33
+ if retfound_weights:
34
+ self._load_retfound(retfound_weights)
35
+ d = self.backbone.num_features
36
+ self.head = nn.Sequential(
37
+ nn.LayerNorm(d), nn.Dropout(drop_rate), nn.Linear(d, num_classes)
38
+ )
39
+
40
+ def _load_retfound(self, path: str):
41
+ sd = torch.load(path, map_location="cpu", weights_only=False)
42
+ sd = sd.get("model", sd)
43
+ own = self.backbone.state_dict()
44
+ matched = {k: v for k, v in sd.items()
45
+ if not k.startswith("decoder") and k != "mask_token"
46
+ and k in own and own[k].shape == v.shape}
47
+ own.update(matched)
48
+ self.backbone.load_state_dict(own, strict=False)
49
+ print(f"[RETFound] loaded {len(matched)}/{len(own)} encoder tensors")
50
+
51
+ def forward(self, x, return_feat: bool = False):
52
+ f = self.backbone(x)
53
+ logits = self.head(f)
54
+ return (logits, f) if return_feat else logits
55
+
56
+
57
+ def load_finetuned(weights_path: str, device: str = "cuda"):
58
+ """Load a fully fine-tuned RETFound (comparator) classifier checkpoint."""
59
+ model = FundusClassifier(num_classes=2, retfound_weights=None)
60
+ state = torch.load(weights_path, map_location="cpu")
61
+ model.load_state_dict(state)
62
+ return model.to(device).eval()
63
+
64
+
65
+ # --------------------------------------------------------------------------- #
66
+ # PRIMARY model: DINOv2 (ViT-L/14) generalist vision foundation model
67
+ # --------------------------------------------------------------------------- #
68
+ def build_dinov2(num_classes: int = 2, img_size: int = 224, drop_rate: float = 0.2):
69
+ """DINOv2 (ViT-L/14) backbone + LayerNorm/Dropout/Linear head, as an nn.Sequential.
70
+
71
+ The 224-px input yields a 16x16 = 256 patch-token grid. The returned module's
72
+ state_dict matches the released `dino_deploy.pth` (keys '0.*' backbone, '1.*' head).
73
+ """
74
+ backbone = timm.create_model("vit_large_patch14_dinov2", pretrained=False,
75
+ num_classes=0, img_size=img_size, drop_rate=drop_rate)
76
+ head = nn.Sequential(nn.LayerNorm(backbone.num_features),
77
+ nn.Dropout(drop_rate),
78
+ nn.Linear(backbone.num_features, num_classes))
79
+ return nn.Sequential(backbone, head)
80
+
81
+
82
+ def load_dinov2(weights_path: str, device: str = "cuda", img_size: int = 224):
83
+ """Load the fine-tuned DINOv2 classifier (dino_deploy.pth) for inference."""
84
+ model = build_dinov2(num_classes=2, img_size=img_size)
85
+ model.load_state_dict(torch.load(weights_path, map_location="cpu"))
86
+ return model.to(device).eval()
87
+
88
+
89
+ def dinov2_gradcam_target(model):
90
+ """Grad-CAM target layer for the DINOv2 Sequential model."""
91
+ return [model[0].blocks[-1].norm1]
92
+
93
+
94
+ def dinov2_reshape(tensor, grid: int = 16):
95
+ """reshape_transform for Grad-CAM on DINOv2 (keep the last grid*grid patch tokens)."""
96
+ x = tensor[:, -grid * grid:, :]
97
+ return x.reshape(tensor.size(0), grid, grid, tensor.size(2)).permute(0, 3, 1, 2)
src/train.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Fine-tune a vision foundation model for anti-VEGF intolerance (5-fold, patient-level CV).
4
+
5
+ Primary backbone: DINOv2 (ViT-L/14, Apache-2.0); set `backbone: retfound` in the config
6
+ to fine-tune the RETFound comparator instead. Class-weighted cross-entropy handles the
7
+ ~9:1 imbalance; AMP + cosine schedule; early stopping on validation balanced accuracy.
8
+ Supply your own de-identified manifest (see src/dataset.py). NO patient data is included.
9
+ """
10
+ import argparse, yaml
11
+ import numpy as np
12
+ import pandas as pd
13
+ import torch
14
+ import torch.nn as nn
15
+ from torch.utils.data import DataLoader
16
+ from sklearn.model_selection import StratifiedGroupKFold, StratifiedKFold
17
+ from sklearn.metrics import roc_auc_score, balanced_accuracy_score
18
+ from .model import FundusClassifier, build_dinov2
19
+ from .dataset import FundusDataset, build_transforms
20
+ from PIL import Image
21
+
22
+
23
+ class _DS(torch.utils.data.Dataset):
24
+ def __init__(self, df, train, size=224):
25
+ self.df = df.reset_index(drop=True); self.tf = build_transforms(size, train)
26
+ def __len__(self): return len(self.df)
27
+ def __getitem__(self, i):
28
+ r = self.df.iloc[i]
29
+ return self.tf(Image.open(r["image_path"]).convert("RGB")), int(r["label"])
30
+
31
+
32
+ def class_weights(labels, device):
33
+ c = np.bincount(labels, minlength=2).astype(float)
34
+ w = c.sum() / (2 * np.maximum(c, 1))
35
+ return torch.tensor(w, dtype=torch.float32, device=device)
36
+
37
+
38
+ def train_one_fold(tr, va, cfg, device):
39
+ tr_ds, va_ds = _DS(tr, True), _DS(va, False)
40
+ tr_ld = DataLoader(tr_ds, cfg["batch_size"], shuffle=True, num_workers=4, drop_last=True)
41
+ va_ld = DataLoader(va_ds, cfg["batch_size"], shuffle=False, num_workers=4)
42
+ if cfg.get("backbone", "dinov2") == "retfound":
43
+ model = FundusClassifier(2, cfg.get("retfound_weights")).to(device)
44
+ else:
45
+ model = build_dinov2(2, img_size=cfg.get("image_size", 224)).to(device)
46
+ crit = nn.CrossEntropyLoss(weight=class_weights(tr["label"].values, device))
47
+ opt = torch.optim.AdamW(model.parameters(), lr=cfg["lr"], weight_decay=cfg["weight_decay"])
48
+ sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=cfg["epochs"])
49
+ scaler = torch.amp.GradScaler("cuda", enabled=device == "cuda")
50
+ best, bad = -1, 0
51
+ for ep in range(cfg["epochs"]):
52
+ model.train()
53
+ for x, y in tr_ld:
54
+ x, y = x.to(device), y.to(device); opt.zero_grad()
55
+ with torch.amp.autocast("cuda", enabled=device == "cuda"):
56
+ loss = crit(model(x), y)
57
+ scaler.scale(loss).backward(); scaler.step(opt); scaler.update()
58
+ sched.step()
59
+ ys, ps = _eval(model, va_ld, device)
60
+ auc = roc_auc_score(ys, ps); ba = balanced_accuracy_score(ys, (ps >= 0.5).astype(int))
61
+ if ba > best: best, bad = ba, 0; torch.save(model.state_dict(), cfg["out_weights"])
62
+ else:
63
+ bad += 1
64
+ if bad >= cfg.get("patience", 10): break
65
+ return best
66
+
67
+
68
+ @torch.no_grad()
69
+ def _eval(model, ld, device):
70
+ model.eval(); ys, ps = [], []
71
+ for x, y in ld:
72
+ p = torch.softmax(model(x.to(device)), 1)[:, 1].cpu().numpy()
73
+ ps.extend(p); ys.extend(y.numpy())
74
+ return np.array(ys), np.array(ps)
75
+
76
+
77
+ def main():
78
+ ap = argparse.ArgumentParser()
79
+ ap.add_argument("--config", default="configs/default.yaml")
80
+ args = ap.parse_args()
81
+ cfg = yaml.safe_load(open(args.config, encoding="utf-8"))
82
+ device = "cuda" if torch.cuda.is_available() else "cpu"
83
+ df = pd.read_csv(cfg["manifest"])
84
+ y = df["label"].values
85
+ if "patient_id" in df.columns:
86
+ splitter = StratifiedGroupKFold(cfg["folds"], shuffle=True, random_state=cfg["seed"])
87
+ folds = splitter.split(df, y, df["patient_id"].values)
88
+ else:
89
+ splitter = StratifiedKFold(cfg["folds"], shuffle=True, random_state=cfg["seed"])
90
+ folds = splitter.split(df, y)
91
+ aucs = []
92
+ for k, (tr_idx, va_idx) in enumerate(folds):
93
+ cfg["out_weights"] = f"weights/fold{k}.pth"
94
+ best = train_one_fold(df.iloc[tr_idx], df.iloc[va_idx], cfg, device)
95
+ print(f"fold{k}: best balanced_acc={best:.3f}")
96
+ aucs.append(best)
97
+ print(f"mean balanced_acc: {np.mean(aucs):.3f} ± {np.std(aucs):.3f}")
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()