adarshcod30 commited on
Commit
1b84e30
·
verified ·
1 Parent(s): d3d0864

Fix a cp -R staging bug that left the Grad-CAM fix undeployed; remove stray nested src/src and egg-info

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. src/drdetect.egg-info/PKG-INFO +0 -643
  2. src/drdetect.egg-info/SOURCES.txt +0 -20
  3. src/drdetect.egg-info/dependency_links.txt +0 -1
  4. src/drdetect.egg-info/requires.txt +0 -40
  5. src/drdetect.egg-info/top_level.txt +0 -1
  6. src/drdetect/serve/pipeline.py +11 -1
  7. src/src/drdetect.egg-info/PKG-INFO +0 -643
  8. src/src/drdetect.egg-info/SOURCES.txt +0 -20
  9. src/src/drdetect.egg-info/dependency_links.txt +0 -1
  10. src/src/drdetect.egg-info/requires.txt +0 -40
  11. src/src/drdetect.egg-info/top_level.txt +0 -1
  12. src/src/drdetect/__init__.py +0 -6
  13. src/src/drdetect/calibration/__init__.py +0 -0
  14. src/src/drdetect/calibration/mc_dropout.py +0 -33
  15. src/src/drdetect/calibration/temperature.py +0 -101
  16. src/src/drdetect/data/__init__.py +0 -0
  17. src/src/drdetect/data/dataset.py +0 -134
  18. src/src/drdetect/data/manifest.py +0 -107
  19. src/src/drdetect/data/splits.py +0 -349
  20. src/src/drdetect/enhance/__init__.py +0 -0
  21. src/src/drdetect/enhance/preprocessing.py +0 -131
  22. src/src/drdetect/eval/__init__.py +0 -0
  23. src/src/drdetect/eval/metrics.py +0 -340
  24. src/src/drdetect/explain/__init__.py +0 -0
  25. src/src/drdetect/explain/cam_variants.py +0 -69
  26. src/src/drdetect/explain/evidence.py +0 -175
  27. src/src/drdetect/explain/gradcam.py +0 -97
  28. src/src/drdetect/explain/localization_metrics.py +0 -55
  29. src/src/drdetect/explain/sanity_checks.py +0 -157
  30. src/src/drdetect/fusion/__init__.py +0 -1
  31. src/src/drdetect/fusion/embedding.py +0 -75
  32. src/src/drdetect/fusion/features.py +0 -260
  33. src/src/drdetect/fusion/model.py +0 -35
  34. src/src/drdetect/grading/__init__.py +0 -0
  35. src/src/drdetect/grading/losses.py +0 -330
  36. src/src/drdetect/grading/model.py +0 -92
  37. src/src/drdetect/grading/module.py +0 -256
  38. src/src/drdetect/quality/__init__.py +0 -0
  39. src/src/drdetect/quality/assessment.py +0 -127
  40. src/src/drdetect/segmentation/__init__.py +0 -0
  41. src/src/drdetect/segmentation/dataset.py +0 -174
  42. src/src/drdetect/segmentation/localization.py +0 -289
  43. src/src/drdetect/segmentation/metrics.py +0 -131
  44. src/src/drdetect/segmentation/microaneurysms.py +0 -495
  45. src/src/drdetect/segmentation/model.py +0 -47
  46. src/src/drdetect/segmentation/module.py +0 -120
  47. src/src/drdetect/segmentation/quadrants.py +0 -69
  48. src/src/drdetect/segmentation/vessels.py +0 -230
  49. src/src/drdetect/serve/__init__.py +0 -0
  50. src/src/drdetect/serve/api.py +0 -123
src/drdetect.egg-info/PKG-INFO DELETED
@@ -1,643 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: drdetect
3
- Version: 0.1.0
4
- Summary: Explainable, quality-aware AI for diabetic retinopathy screening
5
- Author: Adarsh Dwivedi
6
- License: MIT License
7
-
8
- Copyright (c) 2026 Adarsh Dwivedi
9
-
10
- Permission is hereby granted, free of charge, to any person obtaining a copy
11
- of this software and associated documentation files (the "Software"), to deal
12
- in the Software without restriction, including without limitation the rights
13
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
- copies of the Software, and to permit persons to whom the Software is
15
- furnished to do so, subject to the following conditions:
16
-
17
- The above copyright notice and this permission notice shall be included in all
18
- copies or substantial portions of the Software.
19
-
20
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
- SOFTWARE.
27
-
28
- ---
29
-
30
- NOTE ON SCOPE: This licence covers the SOURCE CODE in this repository only.
31
-
32
- Datasets referenced by this project remain under their own licences and are NOT
33
- redistributed here (IDRiD: CC BY 4.0; APTOS / EyePACS: Kaggle competition rules;
34
- Messidor-2: ADCIS terms; DRIVE: research use with registration).
35
-
36
- Trained model weights derived from those datasets are released for RESEARCH USE
37
- ONLY.
38
-
39
- This software is a research prototype. It is NOT a medical device and must not
40
- be used for clinical diagnosis.
41
-
42
- Project-URL: Homepage, https://github.com/adarshcod30/Diabetic-Retinopathy-Detection
43
- Project-URL: Issues, https://github.com/adarshcod30/Diabetic-Retinopathy-Detection/issues
44
- Keywords: diabetic-retinopathy,medical-imaging,explainable-ai,deep-learning,fundus,computer-vision,healthcare-ai
45
- Classifier: Development Status :: 2 - Pre-Alpha
46
- Classifier: Intended Audience :: Science/Research
47
- Classifier: License :: OSI Approved :: MIT License
48
- Classifier: Programming Language :: Python :: 3.11
49
- Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
50
- Requires-Python: <3.13,>=3.10
51
- Description-Content-Type: text/markdown
52
- License-File: LICENSE
53
- Requires-Dist: torch>=2.2
54
- Requires-Dist: torchvision>=0.17
55
- Requires-Dist: lightning>=2.2
56
- Requires-Dist: timm>=1.0
57
- Requires-Dist: monai>=1.3
58
- Requires-Dist: segmentation-models-pytorch>=0.3
59
- Requires-Dist: opencv-python-headless>=4.9
60
- Requires-Dist: scikit-image>=0.22
61
- Requires-Dist: albumentations>=1.4
62
- Requires-Dist: Pillow>=10.2
63
- Requires-Dist: numpy>=1.26
64
- Requires-Dist: pandas>=2.2
65
- Requires-Dist: scipy>=1.12
66
- Requires-Dist: scikit-learn>=1.4
67
- Requires-Dist: statsmodels>=0.14
68
- Requires-Dist: torchmetrics>=1.3
69
- Requires-Dist: hydra-core>=1.3
70
- Requires-Dist: omegaconf>=2.3
71
- Requires-Dist: wandb>=0.16
72
- Requires-Dist: grad-cam>=1.5
73
- Requires-Dist: simpy>=4.1
74
- Requires-Dist: reportlab>=4.1
75
- Requires-Dist: fastapi>=0.110
76
- Requires-Dist: uvicorn>=0.29
77
- Requires-Dist: onnx>=1.16
78
- Requires-Dist: onnxruntime>=1.17
79
- Requires-Dist: gradio>=4.25
80
- Requires-Dist: matplotlib>=3.8
81
- Requires-Dist: seaborn>=0.13
82
- Requires-Dist: tqdm>=4.66
83
- Requires-Dist: kaggle>=1.6
84
- Requires-Dist: python-dotenv>=1.0
85
- Provides-Extra: dev
86
- Requires-Dist: pytest>=8.0; extra == "dev"
87
- Requires-Dist: pytest-cov>=5.0; extra == "dev"
88
- Requires-Dist: ruff>=0.4; extra == "dev"
89
- Requires-Dist: pre-commit>=3.7; extra == "dev"
90
- Requires-Dist: ipykernel>=6.29; extra == "dev"
91
- Requires-Dist: jupyterlab>=4.1; extra == "dev"
92
- Dynamic: license-file
93
-
94
- <div align="center">
95
-
96
- # Diabetic Retinopathy Detection
97
-
98
- ### Explainable, quality-aware AI for diabetic retinopathy screening in rural India — built to be audited, not just admired
99
-
100
- [![Status](https://img.shields.io/badge/status-in%20development-orange)](docs/04_ROADMAP.md)
101
- [![License](https://img.shields.io/badge/license-MIT%20(code)-blue)](LICENSE)
102
- [![Python](https://img.shields.io/badge/python-3.11-3776AB?logo=python&logoColor=white)](#getting-started)
103
- [![PyTorch](https://img.shields.io/badge/PyTorch-2.x-EE4C2C?logo=pytorch&logoColor=white)](#tech-stack)
104
- [![Not a medical device](https://img.shields.io/badge/⚠️-not%20a%20medical%20device-red)](#ethical-boundary--intended-use)
105
-
106
- [**Analysis**](docs/01_PROJECT_ANALYSIS.md) · [**Literature**](docs/02_LITERATURE_REVIEW.md) · [**Tech Stack**](docs/03_TECH_STACK.md) · [**Roadmap**](docs/04_ROADMAP.md) · [**Prototype Scope**](docs/05_PROTOTYPE_SCOPE.md) · [**Report Bug**](https://github.com/adarshcod30/Diabetic-Retinopathy-Detection/issues)
107
-
108
- </div>
109
-
110
- > **⚠️ Development status.** This repository is in **Phase 0** of a 20-week plan. The architecture,
111
- > evidence base, and validation protocol are complete and documented; models are not yet trained.
112
- > Every performance figure below is labelled **target** until it is measured on a locked test set.
113
- > No unmeasured number is presented as a result.
114
-
115
- ---
116
-
117
- ## Table of Contents
118
-
119
- - [Overview](#overview)
120
- - [Key Features](#key-features)
121
- - [Tech Stack](#tech-stack)
122
- - [System Architecture](#system-architecture)
123
- - [Application Flow](#application-flow)
124
- - [Data & ML Pipeline](#data--ml-pipeline)
125
- - [Results & Model Performance](#results--model-performance)
126
- - [Deployment & Infrastructure](#deployment--infrastructure)
127
- - [Prototype Scope](#prototype-scope-tier-p)
128
- - [Project Structure](#project-structure)
129
- - [Getting Started](#getting-started)
130
- - [Usage](#usage)
131
- - [Testing](#testing)
132
- - [Roadmap](#roadmap)
133
- - [Ethical Boundary & Intended Use](#ethical-boundary--intended-use)
134
- - [Contributing](#contributing)
135
- - [License](#license)
136
- - [Contact](#contact)
137
-
138
- ---
139
-
140
- ## Overview
141
-
142
- **Problem.** India has roughly **101 million adults with diabetes** (ICMR-INDIAB, *Lancet Diabetes &
143
- Endocrinology* 2023 — 11.4 % weighted prevalence across 113,043 participants). Diabetic retinopathy
144
- affects **14–17 %** of them, and there is approximately **one retina specialist per 1.26 million
145
- people**. Manual screening at that scale is arithmetically impossible.
146
-
147
- **Solution.** A seven-stage screening pipeline that assesses image quality before it grades, grades
148
- using the International Clinical DR Severity Scale, and explains itself with lesion-level evidence a
149
- clinician can verify in seconds — plus a discrete-event simulation of the district screening programme
150
- the model would run inside.
151
-
152
- **Why it is different.** Automated DR grading is a solved-enough problem: [Gulshan 2016](https://jamanetwork.com/journals/jama/fullarticle/2588763)
153
- reached 97.5 % sensitivity, and [IDx-DR](https://www.nature.com/articles/s41746-018-0040-6) is
154
- FDA-authorised. The unsolved problems are the ones this project targets:
155
-
156
- 1. **Explainability that is measured, not asserted.** Nearly every DR project ships a Grad-CAM and
157
- stops. This one runs [Adebayo's sanity checks](https://arxiv.org/abs/1810.03292) on the saliency
158
- method itself, then *quantifies* whether the heatmap lands on real lesions using IDRiD's
159
- pixel-level masks (pointing game, CAM–lesion IoU).
160
- 2. **Graceful failure on field images.** [Beede et al. (CHI 2020)](https://dl.acm.org/doi/10.1145/3313831.3376718)
161
- found that in 11 Thai clinics the dominant failure was ungradable images being silently rejected,
162
- wasting patient trips. Here, rejection returns an actionable reason: defocus, illumination, or
163
- field of view.
164
- 3. **Clinically grounded evidence.** Lesion segmentation feeds *both* the grade and the explanation,
165
- mapped onto the ICDR criteria — so the rationale is the same evidence the model actually used.
166
-
167
- **Keywords:** `diabetic-retinopathy` `medical-imaging` `explainable-ai` `grad-cam` `deep-learning`
168
- `pytorch` `image-segmentation` `fundus-photography` `computer-vision` `healthcare-ai` `ordinal-regression`
169
- `model-calibration` `simpy` `rural-health` `screening`
170
-
171
- ---
172
-
173
- ## Key Features
174
-
175
- | Feature | Description |
176
- |---|---|
177
- | **Quality gate with recapture guidance** | 3-class gradability model (EyeQ) fused with focus, illumination and FOV features; a rejected image returns *why*, so the operator can retake it while the patient is still seated |
178
- | **Adaptive enhancement** | Ben Graham local-colour normalisation, CLAHE, illumination correction — every step ablated, none assumed |
179
- | **Retinal structure segmentation** | Vessels (DRIVE), optic disc & fovea landmarks, and four lesion classes (MA, haemorrhage, hard/soft exudate) at pixel level |
180
- | **Sub-pixel microaneurysm detection** | Candidate generation via morphological top-hat + matched filtering, then CNN classification — because MAs vanish under naïve downscaling |
181
- | **Ordinal DR grading** | ICDR 0–4 with rank-consistent ordinal heads, not plain cross-entropy: grading 0 as 4 is not the same mistake as 0 as 1 |
182
- | **Lesion-aware fusion** | CNN embedding concatenated with clinically meaningful lesion features (counts, areas, quadrant distribution) |
183
- | **Calibrated confidence** | Temperature scaling with reliability diagrams and ECE; low-confidence cases escalate to a human grader |
184
- | **Audited explainability** | Grad-CAM variants scored against ground-truth lesion masks, with sanity checks reported pass/fail |
185
- | **Auto-generated clinical report** | Annotated PDF with lesion overlays, ICDR evidence table, and calibrated confidence — designed for a 30-second review |
186
- | **Screening-programme simulation** | SimPy discrete-event model of a 100,000-patient/year district programme: bandwidth, throughput, grader capacity, ophthalmologist-hours freed |
187
-
188
- ---
189
-
190
- ## Tech Stack
191
-
192
- | Layer | Technology |
193
- |---|---|
194
- | DL framework | PyTorch 2.x · PyTorch Lightning · timm |
195
- | Medical imaging | MONAI · segmentation-models-pytorch |
196
- | Classical CV | OpenCV · scikit-image · Albumentations |
197
- | Explainability | pytorch-grad-cam (Grad-CAM / ++ / Score-CAM / Eigen-CAM) |
198
- | Config & tracking | Hydra + OmegaConf · Weights & Biases |
199
- | Metrics & statistics | torchmetrics · scikit-learn · scipy · statsmodels (bootstrap CI, DeLong, McNemar) |
200
- | Simulation | SimPy *(optional Simulink mirror)* |
201
- | Serving | FastAPI · ONNX Runtime · Docker |
202
- | Demo | Gradio → HuggingFace Spaces |
203
- | Reporting | ReportLab |
204
- | Quality | ruff · pytest · pre-commit · GitHub Actions |
205
-
206
- Rationale for every choice, including the MATLAB→open-source mapping: [`docs/03_TECH_STACK.md`](docs/03_TECH_STACK.md).
207
-
208
- ---
209
-
210
- ## System Architecture
211
-
212
- ```mermaid
213
- flowchart TD
214
- A[Fundus image<br/>portable camera] --> B[Stage 1<br/>Quality Assessment]
215
- B -->|Reject| B1[Recapture guidance:<br/>defocus · illumination · FOV]
216
- B1 -.retake.-> A
217
- B -->|Good / Usable| C[Stage 2<br/>Adaptive Enhancement]
218
- C --> D[Stage 3<br/>Segmentation]
219
- D --> D1[Vessels]
220
- D --> D2[Optic disc / fovea<br/>→ quadrant map]
221
- D --> D3[Lesions: MA · HE · SE · EX]
222
- D3 --> E[Lesion features<br/>counts · areas · quadrants]
223
- C --> F[Stage 4<br/>Grading backbone]
224
- E --> G[Fusion head]
225
- F --> G
226
- G --> H[Stage 5<br/>Ordinal head + calibration]
227
- H --> I{Confident?}
228
- I -->|Yes| J[Stage 6<br/>Explainability]
229
- I -->|No| K[Escalate to human grader]
230
- D3 --> J
231
- J --> L[Annotated PDF report]
232
- L --> M[Ophthalmologist review]
233
- K --> M
234
- M --> N[(Audit log)]
235
- N -.feedback.-> F
236
- ```
237
-
238
- **In plain language.** An image arrives from a portable camera in a primary health centre. Before
239
- anything else, the system decides whether it is *gradable* — and if not, tells the operator exactly
240
- what to fix. A gradable image is normalised for the wide variation in camera and lighting, then passed
241
- down two parallel paths: one segments the retina's structures and lesions, the other classifies overall
242
- severity. The two paths meet at a fusion head, because the lesions a clinician would look for are
243
- exactly the features that should drive the grade. The result is calibrated into an honest probability;
244
- uncertain cases go straight to a human. Confident cases produce a report whose explanation is grounded
245
- in the same lesion evidence the model used — not a decorative heatmap. Every decision is logged, and
246
- graders' corrections feed the next training round.
247
-
248
- ---
249
-
250
- ## Application Flow
251
-
252
- ```mermaid
253
- sequenceDiagram
254
- participant P as Patient
255
- participant H as Health worker
256
- participant E as Edge app
257
- participant S as District server
258
- participant O as Ophthalmologist
259
-
260
- P->>H: Arrives at screening camp
261
- H->>E: Capture fundus image
262
- E->>E: On-device quality check
263
- alt Ungradable
264
- E-->>H: "Image defocused — retake"
265
- H->>E: Recapture
266
- end
267
- E->>S: Upload (store-and-forward if offline)
268
- S->>S: Enhance → segment → grade → calibrate
269
- alt High confidence, no DR
270
- S-->>H: Auto-cleared · routine recall
271
- else Referable or uncertain
272
- S->>O: Queue annotated report
273
- O->>O: Review (~30 s target)
274
- O-->>H: Confirmed grade + referral
275
- end
276
- H-->>P: Result and next step
277
- O->>S: Correction logged for retraining
278
- ```
279
-
280
- The **on-device quality check is deliberate**: a round trip to the cloud to learn the image was blurry
281
- is precisely the failure mode documented in Thailand. The check must complete while the patient is
282
- still in the chair.
283
-
284
- ---
285
-
286
- ## Data & ML Pipeline
287
-
288
- ### Data sources
289
-
290
- | Dataset | Size | Labels | Population | Role | Licence |
291
- |---|---|---|---|---|---|
292
- | [APTOS 2019](https://www.kaggle.com/c/aptos2019-blindness-detection) | 3,662 | ICDR 0–4 | **India** (Aravind, Madurai) | Primary training | Competition rules |
293
- | [EyePACS 2015](https://www.kaggle.com/c/diabetic-retinopathy-detection) | 88,702 | ICDR 0–4 (noisy) | US | Pretraining *(cloud-side only)* | Competition rules |
294
- | [IDRiD](https://ieee-dataport.org/open-access/indian-diabetic-retinopathy-image-dataset-idrid) | 516 | Grades + **pixel masks** + OD/fovea | **India** (Nanded) | Lesion segmentation, XAI ground truth | CC BY 4.0 |
295
- | [Messidor-2](https://www.adcis.net/en/third-party/messidor2/) | 1,748 | Adjudicated grades | France | **Locked external test** | ADCIS terms |
296
- | [DRIVE](https://drive.grand-challenge.org/) | 40 | Vessel masks | Netherlands | Vessel segmentation | Research use |
297
- | EyeQ | 28,792 | Good / Usable / Reject | derived from EyePACS | Quality model | Research use |
298
-
299
- > Raw images are **never committed**. `scripts/download_data.sh` fetches them; `data/manifests/` commits
300
- > sha256 hashes so anyone can verify byte-identical inputs.
301
-
302
- ### Pipeline
303
-
304
- ```mermaid
305
- flowchart LR
306
- R[Raw fundus] --> Q{Gradable?}
307
- Q -->|No| RC[Recapture]
308
- Q -->|Yes| P1[crop_from_gray]
309
- P1 --> P2[circle_crop]
310
- P2 --> P3[Ben Graham<br/>local colour norm]
311
- P3 --> P4[CLAHE + illumination norm]
312
- P4 --> P5[Resize 512–768 px<br/>cache to disk]
313
- P5 --> AUG[Augment:<br/>flip · rotate · colour jitter]
314
- AUG --> TR[Train]
315
- TR --> CAL[Calibrate on val]
316
- CAL --> TEST[Locked external test<br/>evaluated ONCE]
317
- ```
318
-
319
- **Cleaning & preparation.** Circle-crop removes the black surround and standardises the field of view;
320
- `crop_from_gray` handles off-centre captures. **Ben Graham preprocessing** — subtracting a heavily
321
- blurred copy to remove local average colour — is the single highest-value step, as established by the
322
- Kaggle DR 2015 winner and used by most APTOS top solutions. Preprocessed images are cached once at
323
- 512 px, which shrinks APTOS from ~10 GB to ~200 MB and makes every subsequent epoch cheaper.
324
-
325
- **Feature engineering.** Beyond learned features, the lesion branch yields *clinically named* features:
326
- microaneurysm count, haemorrhage and exudate area, per-quadrant distribution (using the optic
327
- disc–fovea axis), and distance-to-fovea. These are the ICDR criteria expressed numerically — they
328
- improve the grade and simultaneously become the explanation.
329
-
330
- **Training approach.**
331
- - Two-stage: pretrain on EyePACS (or initialise from [RETFound](https://www.nature.com/articles/s41586-023-06555-x),
332
- a masked-autoencoder foundation model trained on 1.6 M retinal images), then fine-tune on APTOS.
333
- - **Ordinal loss**, not cross-entropy — DR grades are ordered, and recent SOTA
334
- ([Dual-SwinOrd](https://www.mdpi.com/2306-5354/13/4/374), AOR-DR) confirms this matters.
335
- - **Resolution is the dominant hyperparameter.** A microaneurysm is ~10 px on a 4288 px image; at
336
- 224 px it is sub-pixel and physically destroyed. Sweep 384/512/768 early.
337
- - **Patient-level splits** — both eyes of one patient must never straddle train and test.
338
-
339
- **Evaluation metrics.** Quadratic Weighted Kappa (grading); sensitivity and specificity at the
340
- referable-DR operating point (grade ≥ 2); AUROC; **AUPRC for lesion segmentation** (lesion pixels are
341
- <0.1 % of an image, which makes AUROC flattering and near-meaningless); Expected Calibration Error;
342
- all with bootstrap 95 % CIs, DeLong for AUC comparisons, and McNemar for paired sensitivity/specificity.
343
-
344
- ---
345
-
346
- ## Results & Model Performance
347
-
348
- > **No results yet — models are not trained.** The table below states **targets** and the **published
349
- > work they are benchmarked against**. It will be replaced with measured values, confidence intervals,
350
- > and the full ablation once Phase 8 completes.
351
-
352
- ### Targets
353
-
354
- | Metric | Target | Benchmark it is measured against |
355
- |---|---|---|
356
- | Referable DR sensitivity | **≥ 90 %** | Ting 2017: 90.5 % · IDx-DR: 87.2 % · Google/Aravind: 88.9 % |
357
- | Referable DR specificity | **≥ 85 %** | Ting 2017: 91.6 % · IDx-DR: 90.7 % · Google/Aravind: 92.2 % |
358
- | QWK (APTOS, 5-class) | **≥ 0.90** | Dual-SwinOrd SOTA: 0.9370 |
359
- | Quality classification (EyeQ) | **≈ 0.90 acc** | VISTA: 0.9066 acc, 0.8868 F1 |
360
- | Lesion AUPRC (IDRiD) | per-class, cross-validated | IDRiD ISBI-2018 challenge leaderboard |
361
- | Calibration (ECE) | **< 0.05** after temperature scaling | — |
362
- | Grad-CAM localisation | pointing-game accuracy vs IDRiD masks | *rarely reported — this is the contribution* |
363
-
364
- ### Published comparators
365
-
366
- | Study | Task | Sensitivity | Specificity | AUC |
367
- |---|---|---|---|---|
368
- | [Gulshan 2016 (JAMA)](https://jamanetwork.com/journals/jama/fullarticle/2588763) | Referable DR, EyePACS-1 | 97.5 % | 93.4 % | 0.991 |
369
- | [Ting 2017 (JAMA)](https://pubmed.ncbi.nlm.nih.gov/29234807/) | Referable DR, multiethnic | 90.5 % | 91.6 % | 0.936 |
370
- | [Abràmoff 2018 (npj Digit Med)](https://www.nature.com/articles/s41746-018-0040-6) | mtmDR, **prospective primary care** | 87.2 % | 90.7 % | — |
371
- | [Gulshan 2019 (JAMA Ophthalmol)](https://research.google/pubs/performance-of-a-deep-learning-algorithm-vs-manual-grading-for-detecting-diabetic-retinopathy-in-india/) | Referable DR, **Aravind, India** | 88.9 % | 92.2 % | 0.963 |
372
- | same | Referable DR, **Sankara Nethralaya** | 92.1 % | 95.2 % | 0.980 |
373
-
374
- > **A note on honesty:** a faithful reproduction of Gulshan 2016 scored AUC 0.951 on EyePACS but only
375
- > **0.853 on Messidor-2**. External validation drops are the norm, not a failure. This project plans
376
- > for that drop, reports it, and analyses the domain shift rather than re-tuning until it disappears.
377
-
378
- The planned **ablation** — eleven configurations, one factor added per row, evaluated on the locked
379
- test set with significance tests — is specified in
380
- [§7 of the analysis](docs/01_PROJECT_ANALYSIS.md#7-the-ablation-that-answers-integrated--single-technique).
381
-
382
- ---
383
-
384
- ## Deployment & Infrastructure
385
-
386
- | Concern | Approach |
387
- |---|---|
388
- | **Training** | Kaggle Notebooks (T4/P100, 30 GPU-h/week, EyePACS pre-mounted) — chosen because the dev machine has 28 GB free disk and EyePACS is ~90 GB |
389
- | **Local development** | Apple M4 via PyTorch MPS — preprocessing, IDRiD segmentation, inference, explainability, demo |
390
- | **Model export** | PyTorch → ONNX → ONNX Runtime (server), CoreML (Apple), TFLite (Android capture app) |
391
- | **Serving** | FastAPI + ONNX Runtime in Docker (`linux/amd64` + `linux/arm64`, so Jetson/Raspberry Pi remain viable for a PHC) |
392
- | **Edge** | On-device quality check so blur is caught before upload, not after |
393
- | **Connectivity** | Store-and-forward queue; the simulation explicitly models 1–10 Mbps rural links and outages |
394
- | **Public demo** | Gradio on HuggingFace Spaces (free CPU tier) |
395
- | **CI/CD** | GitHub Actions — ruff, pytest, and an end-to-end single-image smoke test on CPU |
396
- | **Monitoring** | Audit log of every prediction + grader override; drift review before any retraining |
397
- | **Reproducibility** | Hydra configs, fixed seeds, sha256 data manifests; `make setup && make evaluate` reproduces the headline table |
398
-
399
- ---
400
-
401
- ## Project Structure
402
-
403
- ```
404
- Diabetic-Retinopathy-Detection/
405
- ├── configs/ # Hydra YAMLs — one per experiment; this IS the ablation grid
406
- ├── data/ # gitignored (manifests are committed)
407
- │ ├── raw/ interim/ processed/ external/
408
- │ └── manifests/ # sha256 + labels + patient_id
409
- ├── docs/
410
- │ ├── 01_PROJECT_ANALYSIS.md # what this is, why it is hard, what "done" means
411
- │ ├── 02_LITERATURE_REVIEW.md # annotated evidence base
412
- │ ├── 03_TECH_STACK.md # tooling decisions and rationale
413
- │ ├── 04_ROADMAP.md # 20-week phased plan
414
- │ └── 05_PROTOTYPE_SCOPE.md # Tier-P: scaling down without breaking the science
415
- ├── notebooks/ # exploration only — logic lives in src/
416
- ├── src/drdetect/
417
- │ ├── data/ # datasets, patient-level splits, manifests
418
- │ ├── quality/ # Stage 1 — gradability + recapture guidance
419
- │ ├── enhance/ # Stage 2 — Ben Graham, CLAHE, illumination
420
- │ ├── segmentation/ # Stage 3 — vessels, OD/fovea, lesions
421
- │ ├── grading/ # Stage 4 — backbones, ordinal heads, fusion
422
- │ ├── calibration/ # Stage 5 — temperature scaling, thresholds, uncertainty
423
- │ ├── explain/ # Stage 6 — CAMs, sanity checks, localisation metrics, reports
424
- │ ├── eval/ # metrics, bootstrap CI, DeLong, McNemar
425
- │ ├── serve/ # FastAPI + ONNX
426
- │ └── utils/ # seeding, logging, io
427
- ├── simulation/
428
- │ ├── simpy/ # Stage 7 — district screening programme model
429
- │ └── simulink/ # optional .slx mirror
430
- ├── scripts/ # benchmark_device.py · download_data.sh · preprocess.py · train.py · evaluate.py
431
- ├── tests/
432
- ├── models/ # gitignored; released via GitHub Releases / HF Hub
433
- └── .github/workflows/
434
- ```
435
-
436
- ---
437
-
438
- ## Getting Started
439
-
440
- ### Prerequisites
441
-
442
- - Python **3.11** (not 3.13 — several CV/DL wheels still lag)
443
- - ~20 GB free disk for local datasets
444
- - A Kaggle account (for datasets and free GPU)
445
- - Optional: CUDA GPU. Apple Silicon works via MPS for everything except large-scale training.
446
-
447
- ### Installation
448
-
449
- ```bash
450
- git clone https://github.com/adarshcod30/Diabetic-Retinopathy-Detection.git
451
- cd Diabetic-Retinopathy-Detection
452
- ```
453
-
454
- ```bash
455
- conda create -n dr python=3.11 -y && conda activate dr
456
- ```
457
-
458
- ```bash
459
- pip install -e ".[dev]"
460
- ```
461
-
462
- ```bash
463
- python -c "import torch; print('MPS:', torch.backends.mps.is_available(), '| CUDA:', torch.cuda.is_available())"
464
- ```
465
-
466
- ### Get the data
467
-
468
- Place your Kaggle token at `~/.config/kaggle/kaggle.json`, then:
469
-
470
- ```bash
471
- bash scripts/download_data.sh --datasets aptos,idrid,drive
472
- ```
473
-
474
- > Messidor-2 requires accepting [ADCIS terms](https://www.adcis.net/en/third-party/messidor2/) and is
475
- > downloaded manually into `data/external/messidor2/`. **Do not** download EyePACS locally — it is
476
- > ~90 GB; train against it on Kaggle instead.
477
-
478
- ### Preprocess
479
-
480
- ```bash
481
- python scripts/preprocess.py --dataset aptos --size 512 --pipeline bengraham
482
- ```
483
-
484
- ---
485
-
486
- ## Usage
487
-
488
- Grade a single image and produce an annotated PDF report:
489
-
490
- ```bash
491
- python scripts/predict.py --image path/to/fundus.jpg --report out/report.pdf
492
- ```
493
-
494
- Launch the interactive demo:
495
-
496
- ```bash
497
- python -m drdetect.serve.demo
498
- ```
499
-
500
- Run a training experiment (Hydra — every ablation row is a config override):
501
-
502
- ```bash
503
- python scripts/train.py experiment=grading_effnetv2 data.image_size=512 loss=ordinal
504
- ```
505
-
506
- Evaluate on the locked external test set:
507
-
508
- ```bash
509
- python scripts/evaluate.py --checkpoint models/checkpoints/best.ckpt --split external_test --bootstrap 2000
510
- ```
511
-
512
- Run the screening-programme simulation:
513
-
514
- ```bash
515
- python -m simulation.simpy.district --patients-per-year 100000 --graders 4 --bandwidth-mbps 5
516
- ```
517
-
518
- > Commands reflect the target interface; scripts land progressively through Phases 1–7. See
519
- > [`docs/04_ROADMAP.md`](docs/04_ROADMAP.md) for what exists today.
520
-
521
- ---
522
-
523
- ## Testing
524
-
525
- ```bash
526
- pytest -q
527
- ```
528
-
529
- ```bash
530
- ruff check . && ruff format --check .
531
- ```
532
-
533
- Test strategy:
534
- - **Unit** — preprocessing determinism, circle-crop geometry, metric correctness against hand-computed
535
- cases, patient-level split integrity (*asserts no patient appears in two splits* — the leakage bug
536
- that silently inflates every published number)
537
- - **Integration** — one image end-to-end on CPU, asserting a valid grade and a well-formed PDF
538
- - **CI** — ruff + pytest + the smoke test on every push
539
-
540
- ---
541
-
542
- ## Roadmap
543
-
544
- | Phase | Weeks | Milestone |
545
- |---|---|---|
546
- | 0 · Foundation | 1 | Repo, environment, data audit, manifests |
547
- | 1 · Baseline | 2–3 | Baseline QWK on APTOS — a number to beat |
548
- | **2 · Vertical slice** ⭐ | 4–5 | **JPEG in → annotated PDF out** |
549
- | 3 · Grading depth | 6–8 | Resolution sweep, ordinal loss, pretraining → QWK ≥ 0.90 |
550
- | 4 · Segmentation | 9–11 | Vessels, OD/fovea, four lesion classes, MA detector |
551
- | 5 · Integration | 12–13 | Lesion fusion + calibration + uncertainty escalation |
552
- | **6 · Rigorous XAI** ⭐ | 14–15 | **Sanity checks + measured CAM localisation** |
553
- | 7 · Simulation | 16–17 | District programme model, ophthalmologist-hours freed |
554
- | 8 · Validation | 18–19 | Full ablation on locked test set with significance tests |
555
- | 9 · Release | 20 | ONNX, Docker, HF Spaces demo, model card |
556
-
557
- Detail, exit criteria, and an explicit scope-cut order: [`docs/04_ROADMAP.md`](docs/04_ROADMAP.md).
558
-
559
- **Known scope limit:** neovascularisation segmentation is *out of scope* — no public dataset provides
560
- NV pixel masks. PDR is detected at image level and the gap is documented rather than papered over.
561
-
562
- ---
563
-
564
- ## Prototype Scope (Tier-P)
565
-
566
- Development runs on a 16 GB Apple M4 with ~28 GB free disk. That constrains **compute**, not
567
- data — after caching at 512 px, APTOS, IDRiD, Messidor-2 and DRIVE together occupy **0.4 GB**.
568
-
569
- So the scale-down cuts compute and keeps every image:
570
-
571
- | | Full (Tier-F) | **Prototype (Tier-P)** |
572
- |---|---|---|
573
- | APTOS / IDRiD / DRIVE / Messidor-2 | full | **full — unchanged** |
574
- | EyePACS pretraining | 88,702 | 12–15k stratified, cached on Kaggle |
575
- | Backbone | EfficientNetV2-S | EfficientNet-B0 |
576
- | Resolution | 768 px | 512 px *(floor — below this microaneurysms vanish)* |
577
- | Cross-validation | 5-fold + TTA | single split + hflip TTA |
578
-
579
- **≈34× cheaper, zero task data discarded.** Every cut is a config override, reversible on
580
- Kaggle without re-splitting or re-collecting anything.
581
-
582
- Two things are never scaled down: the **test set** (a 50-case test set gives a ±8.5 pp
583
- confidence interval, which makes a ">90 % sensitivity" claim unmakeable) and the **validation
584
- protocol**. Grade 3 has only 193 images in all of APTOS — subsampling collapses it to 15–38
585
- training examples long before the dataset merely looks small.
586
-
587
- Full arithmetic and the claims that do and don't survive: [`docs/05_PROTOTYPE_SCOPE.md`](docs/05_PROTOTYPE_SCOPE.md).
588
-
589
- ## Ethical Boundary & Intended Use
590
-
591
- **This is a research prototype. It is not a medical device and must not be used for clinical
592
- diagnosis.**
593
-
594
- - Software intended for diagnosis falls under **CDSCO** medical-device rules in India; the comparable
595
- US system (IDx-DR) required an FDA De Novo authorisation. This project has neither and seeks neither.
596
- - Outputs are **decision support with a human in the loop**, never a diagnosis. Every generated report
597
- carries the disclaimer.
598
- - **Training populations are documented and limited:** APTOS and IDRiD are Indian, EyePACS is US,
599
- Messidor-2 is French. Performance on any other population is **unverified**.
600
- - No patient-identifying data is stored in this repository; demo images are EXIF-stripped.
601
- - Released weights are **research use only**, consistent with the licences of the data they derive from.
602
-
603
- ---
604
-
605
- ## Contributing
606
-
607
- Contributions are welcome — particularly clinical review of the explanation format, additional external
608
- validation datasets, and reproductions of the ablation.
609
-
610
- 1. Fork and branch (`git checkout -b feature/your-feature`)
611
- 2. `pre-commit install`
612
- 3. Add tests for anything you change
613
- 4. Ensure `pytest` and `ruff check .` pass
614
- 5. Open a pull request describing *what you measured*, not only what you changed
615
-
616
- Findings that contradict results here are especially welcome. Please open an issue with the
617
- configuration and seed.
618
-
619
- ---
620
-
621
- ## License
622
-
623
- Code is released under the **MIT License** — see [LICENSE](LICENSE).
624
-
625
- **Data and weights are not.** Datasets remain under their original licences (IDRiD CC BY 4.0;
626
- APTOS/EyePACS competition rules; Messidor-2 ADCIS terms; DRIVE research use). Raw images are never
627
- redistributed here. Trained weights derived from research-use data are released for **research use
628
- only**.
629
-
630
- ---
631
-
632
- ## Contact
633
-
634
- **Adarsh Dwivedi** · [@adarshcod30](https://github.com/adarshcod30)
635
- Project: <https://github.com/adarshcod30/Diabetic-Retinopathy-Detection>
636
-
637
- ## Acknowledgments
638
-
639
- The datasets that make this possible: [APTOS](https://www.kaggle.com/c/aptos2019-blindness-detection)
640
- (Aravind Eye Hospital), [IDRiD](https://idrid.grand-challenge.org/) (Porwal et al.),
641
- [Messidor-2](https://www.adcis.net/en/third-party/messidor2/) (ADCIS), and
642
- [DRIVE](https://drive.grand-challenge.org/). Full evidence base:
643
- [`docs/02_LITERATURE_REVIEW.md`](docs/02_LITERATURE_REVIEW.md).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/drdetect.egg-info/SOURCES.txt DELETED
@@ -1,20 +0,0 @@
1
- LICENSE
2
- README.md
3
- pyproject.toml
4
- src/drdetect/__init__.py
5
- src/drdetect.egg-info/PKG-INFO
6
- src/drdetect.egg-info/SOURCES.txt
7
- src/drdetect.egg-info/dependency_links.txt
8
- src/drdetect.egg-info/requires.txt
9
- src/drdetect.egg-info/top_level.txt
10
- src/drdetect/calibration/__init__.py
11
- src/drdetect/data/__init__.py
12
- src/drdetect/enhance/__init__.py
13
- src/drdetect/enhance/preprocessing.py
14
- src/drdetect/eval/__init__.py
15
- src/drdetect/explain/__init__.py
16
- src/drdetect/grading/__init__.py
17
- src/drdetect/quality/__init__.py
18
- src/drdetect/segmentation/__init__.py
19
- src/drdetect/serve/__init__.py
20
- src/drdetect/utils/__init__.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/drdetect.egg-info/dependency_links.txt DELETED
@@ -1 +0,0 @@
1
-
 
 
src/drdetect.egg-info/requires.txt DELETED
@@ -1,40 +0,0 @@
1
- torch>=2.2
2
- torchvision>=0.17
3
- lightning>=2.2
4
- timm>=1.0
5
- monai>=1.3
6
- segmentation-models-pytorch>=0.3
7
- opencv-python-headless>=4.9
8
- scikit-image>=0.22
9
- albumentations>=1.4
10
- Pillow>=10.2
11
- numpy>=1.26
12
- pandas>=2.2
13
- scipy>=1.12
14
- scikit-learn>=1.4
15
- statsmodels>=0.14
16
- torchmetrics>=1.3
17
- hydra-core>=1.3
18
- omegaconf>=2.3
19
- wandb>=0.16
20
- grad-cam>=1.5
21
- simpy>=4.1
22
- reportlab>=4.1
23
- fastapi>=0.110
24
- uvicorn>=0.29
25
- onnx>=1.16
26
- onnxruntime>=1.17
27
- gradio>=4.25
28
- matplotlib>=3.8
29
- seaborn>=0.13
30
- tqdm>=4.66
31
- kaggle>=1.6
32
- python-dotenv>=1.0
33
-
34
- [dev]
35
- pytest>=8.0
36
- pytest-cov>=5.0
37
- ruff>=0.4
38
- pre-commit>=3.7
39
- ipykernel>=6.29
40
- jupyterlab>=4.1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/drdetect.egg-info/top_level.txt DELETED
@@ -1 +0,0 @@
1
- drdetect
 
 
src/drdetect/serve/pipeline.py CHANGED
@@ -128,7 +128,17 @@ def run_pipeline(
128
  class_probs = probs
129
  confidence = float(probs[grade])
130
 
131
- cam = generate_cam(model, tensor, target_class=grade)
 
 
 
 
 
 
 
 
 
 
132
  overlay = overlay_cam(pre, cam)
133
 
134
  return PredictionResult(
 
128
  class_probs = probs
129
  confidence = float(probs[grade])
130
 
131
+ # Grad-CAM indexes directly into the model's raw output (pytorch_grad_cam's
132
+ # ClassifierOutputTarget does `output[target_class]`), which only equals
133
+ # `grade` for the 5-way heads (ce/distance_ce). A regression head has a
134
+ # single output (outputs_for_loss("regression") == 1) -- passing grade=2
135
+ # there indexes position 2 of a 1-element tensor and crashes
136
+ # (IndexError inside pytorch_grad_cam, surfacing as an UnboundLocalError
137
+ # in generate_cam). Clamping to the head's actual output count fixes every
138
+ # loss: a no-op for ce/distance_ce, and the only valid (and semantically
139
+ # correct -- there is only one score to explain) choice for regression.
140
+ cam_target = min(grade, outputs_for_loss(loss_name) - 1)
141
+ cam = generate_cam(model, tensor, target_class=cam_target)
142
  overlay = overlay_cam(pre, cam)
143
 
144
  return PredictionResult(
src/src/drdetect.egg-info/PKG-INFO DELETED
@@ -1,643 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: drdetect
3
- Version: 0.1.0
4
- Summary: Explainable, quality-aware AI for diabetic retinopathy screening
5
- Author: Adarsh Dwivedi
6
- License: MIT License
7
-
8
- Copyright (c) 2026 Adarsh Dwivedi
9
-
10
- Permission is hereby granted, free of charge, to any person obtaining a copy
11
- of this software and associated documentation files (the "Software"), to deal
12
- in the Software without restriction, including without limitation the rights
13
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
- copies of the Software, and to permit persons to whom the Software is
15
- furnished to do so, subject to the following conditions:
16
-
17
- The above copyright notice and this permission notice shall be included in all
18
- copies or substantial portions of the Software.
19
-
20
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
- SOFTWARE.
27
-
28
- ---
29
-
30
- NOTE ON SCOPE: This licence covers the SOURCE CODE in this repository only.
31
-
32
- Datasets referenced by this project remain under their own licences and are NOT
33
- redistributed here (IDRiD: CC BY 4.0; APTOS / EyePACS: Kaggle competition rules;
34
- Messidor-2: ADCIS terms; DRIVE: research use with registration).
35
-
36
- Trained model weights derived from those datasets are released for RESEARCH USE
37
- ONLY.
38
-
39
- This software is a research prototype. It is NOT a medical device and must not
40
- be used for clinical diagnosis.
41
-
42
- Project-URL: Homepage, https://github.com/adarshcod30/Diabetic-Retinopathy-Detection
43
- Project-URL: Issues, https://github.com/adarshcod30/Diabetic-Retinopathy-Detection/issues
44
- Keywords: diabetic-retinopathy,medical-imaging,explainable-ai,deep-learning,fundus,computer-vision,healthcare-ai
45
- Classifier: Development Status :: 2 - Pre-Alpha
46
- Classifier: Intended Audience :: Science/Research
47
- Classifier: License :: OSI Approved :: MIT License
48
- Classifier: Programming Language :: Python :: 3.11
49
- Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
50
- Requires-Python: <3.13,>=3.10
51
- Description-Content-Type: text/markdown
52
- License-File: LICENSE
53
- Requires-Dist: torch>=2.2
54
- Requires-Dist: torchvision>=0.17
55
- Requires-Dist: lightning>=2.2
56
- Requires-Dist: timm>=1.0
57
- Requires-Dist: monai>=1.3
58
- Requires-Dist: segmentation-models-pytorch>=0.3
59
- Requires-Dist: opencv-python-headless>=4.9
60
- Requires-Dist: scikit-image>=0.22
61
- Requires-Dist: albumentations>=1.4
62
- Requires-Dist: Pillow>=10.2
63
- Requires-Dist: numpy>=1.26
64
- Requires-Dist: pandas>=2.2
65
- Requires-Dist: scipy>=1.12
66
- Requires-Dist: scikit-learn>=1.4
67
- Requires-Dist: statsmodels>=0.14
68
- Requires-Dist: torchmetrics>=1.3
69
- Requires-Dist: hydra-core>=1.3
70
- Requires-Dist: omegaconf>=2.3
71
- Requires-Dist: wandb>=0.16
72
- Requires-Dist: grad-cam>=1.5
73
- Requires-Dist: simpy>=4.1
74
- Requires-Dist: reportlab>=4.1
75
- Requires-Dist: fastapi>=0.110
76
- Requires-Dist: uvicorn>=0.29
77
- Requires-Dist: onnx>=1.16
78
- Requires-Dist: onnxruntime>=1.17
79
- Requires-Dist: gradio>=4.25
80
- Requires-Dist: matplotlib>=3.8
81
- Requires-Dist: seaborn>=0.13
82
- Requires-Dist: tqdm>=4.66
83
- Requires-Dist: kaggle>=1.6
84
- Requires-Dist: python-dotenv>=1.0
85
- Provides-Extra: dev
86
- Requires-Dist: pytest>=8.0; extra == "dev"
87
- Requires-Dist: pytest-cov>=5.0; extra == "dev"
88
- Requires-Dist: ruff>=0.4; extra == "dev"
89
- Requires-Dist: pre-commit>=3.7; extra == "dev"
90
- Requires-Dist: ipykernel>=6.29; extra == "dev"
91
- Requires-Dist: jupyterlab>=4.1; extra == "dev"
92
- Dynamic: license-file
93
-
94
- <div align="center">
95
-
96
- # Diabetic Retinopathy Detection
97
-
98
- ### Explainable, quality-aware AI for diabetic retinopathy screening in rural India — built to be audited, not just admired
99
-
100
- [![Status](https://img.shields.io/badge/status-in%20development-orange)](docs/04_ROADMAP.md)
101
- [![License](https://img.shields.io/badge/license-MIT%20(code)-blue)](LICENSE)
102
- [![Python](https://img.shields.io/badge/python-3.11-3776AB?logo=python&logoColor=white)](#getting-started)
103
- [![PyTorch](https://img.shields.io/badge/PyTorch-2.x-EE4C2C?logo=pytorch&logoColor=white)](#tech-stack)
104
- [![Not a medical device](https://img.shields.io/badge/⚠️-not%20a%20medical%20device-red)](#ethical-boundary--intended-use)
105
-
106
- [**Analysis**](docs/01_PROJECT_ANALYSIS.md) · [**Literature**](docs/02_LITERATURE_REVIEW.md) · [**Tech Stack**](docs/03_TECH_STACK.md) · [**Roadmap**](docs/04_ROADMAP.md) · [**Prototype Scope**](docs/05_PROTOTYPE_SCOPE.md) · [**Report Bug**](https://github.com/adarshcod30/Diabetic-Retinopathy-Detection/issues)
107
-
108
- </div>
109
-
110
- > **⚠️ Development status.** This repository is in **Phase 0** of a 20-week plan. The architecture,
111
- > evidence base, and validation protocol are complete and documented; models are not yet trained.
112
- > Every performance figure below is labelled **target** until it is measured on a locked test set.
113
- > No unmeasured number is presented as a result.
114
-
115
- ---
116
-
117
- ## Table of Contents
118
-
119
- - [Overview](#overview)
120
- - [Key Features](#key-features)
121
- - [Tech Stack](#tech-stack)
122
- - [System Architecture](#system-architecture)
123
- - [Application Flow](#application-flow)
124
- - [Data & ML Pipeline](#data--ml-pipeline)
125
- - [Results & Model Performance](#results--model-performance)
126
- - [Deployment & Infrastructure](#deployment--infrastructure)
127
- - [Prototype Scope](#prototype-scope-tier-p)
128
- - [Project Structure](#project-structure)
129
- - [Getting Started](#getting-started)
130
- - [Usage](#usage)
131
- - [Testing](#testing)
132
- - [Roadmap](#roadmap)
133
- - [Ethical Boundary & Intended Use](#ethical-boundary--intended-use)
134
- - [Contributing](#contributing)
135
- - [License](#license)
136
- - [Contact](#contact)
137
-
138
- ---
139
-
140
- ## Overview
141
-
142
- **Problem.** India has roughly **101 million adults with diabetes** (ICMR-INDIAB, *Lancet Diabetes &
143
- Endocrinology* 2023 — 11.4 % weighted prevalence across 113,043 participants). Diabetic retinopathy
144
- affects **14–17 %** of them, and there is approximately **one retina specialist per 1.26 million
145
- people**. Manual screening at that scale is arithmetically impossible.
146
-
147
- **Solution.** A seven-stage screening pipeline that assesses image quality before it grades, grades
148
- using the International Clinical DR Severity Scale, and explains itself with lesion-level evidence a
149
- clinician can verify in seconds — plus a discrete-event simulation of the district screening programme
150
- the model would run inside.
151
-
152
- **Why it is different.** Automated DR grading is a solved-enough problem: [Gulshan 2016](https://jamanetwork.com/journals/jama/fullarticle/2588763)
153
- reached 97.5 % sensitivity, and [IDx-DR](https://www.nature.com/articles/s41746-018-0040-6) is
154
- FDA-authorised. The unsolved problems are the ones this project targets:
155
-
156
- 1. **Explainability that is measured, not asserted.** Nearly every DR project ships a Grad-CAM and
157
- stops. This one runs [Adebayo's sanity checks](https://arxiv.org/abs/1810.03292) on the saliency
158
- method itself, then *quantifies* whether the heatmap lands on real lesions using IDRiD's
159
- pixel-level masks (pointing game, CAM–lesion IoU).
160
- 2. **Graceful failure on field images.** [Beede et al. (CHI 2020)](https://dl.acm.org/doi/10.1145/3313831.3376718)
161
- found that in 11 Thai clinics the dominant failure was ungradable images being silently rejected,
162
- wasting patient trips. Here, rejection returns an actionable reason: defocus, illumination, or
163
- field of view.
164
- 3. **Clinically grounded evidence.** Lesion segmentation feeds *both* the grade and the explanation,
165
- mapped onto the ICDR criteria — so the rationale is the same evidence the model actually used.
166
-
167
- **Keywords:** `diabetic-retinopathy` `medical-imaging` `explainable-ai` `grad-cam` `deep-learning`
168
- `pytorch` `image-segmentation` `fundus-photography` `computer-vision` `healthcare-ai` `ordinal-regression`
169
- `model-calibration` `simpy` `rural-health` `screening`
170
-
171
- ---
172
-
173
- ## Key Features
174
-
175
- | Feature | Description |
176
- |---|---|
177
- | **Quality gate with recapture guidance** | 3-class gradability model (EyeQ) fused with focus, illumination and FOV features; a rejected image returns *why*, so the operator can retake it while the patient is still seated |
178
- | **Adaptive enhancement** | Ben Graham local-colour normalisation, CLAHE, illumination correction — every step ablated, none assumed |
179
- | **Retinal structure segmentation** | Vessels (DRIVE), optic disc & fovea landmarks, and four lesion classes (MA, haemorrhage, hard/soft exudate) at pixel level |
180
- | **Sub-pixel microaneurysm detection** | Candidate generation via morphological top-hat + matched filtering, then CNN classification — because MAs vanish under naïve downscaling |
181
- | **Ordinal DR grading** | ICDR 0–4 with rank-consistent ordinal heads, not plain cross-entropy: grading 0 as 4 is not the same mistake as 0 as 1 |
182
- | **Lesion-aware fusion** | CNN embedding concatenated with clinically meaningful lesion features (counts, areas, quadrant distribution) |
183
- | **Calibrated confidence** | Temperature scaling with reliability diagrams and ECE; low-confidence cases escalate to a human grader |
184
- | **Audited explainability** | Grad-CAM variants scored against ground-truth lesion masks, with sanity checks reported pass/fail |
185
- | **Auto-generated clinical report** | Annotated PDF with lesion overlays, ICDR evidence table, and calibrated confidence — designed for a 30-second review |
186
- | **Screening-programme simulation** | SimPy discrete-event model of a 100,000-patient/year district programme: bandwidth, throughput, grader capacity, ophthalmologist-hours freed |
187
-
188
- ---
189
-
190
- ## Tech Stack
191
-
192
- | Layer | Technology |
193
- |---|---|
194
- | DL framework | PyTorch 2.x · PyTorch Lightning · timm |
195
- | Medical imaging | MONAI · segmentation-models-pytorch |
196
- | Classical CV | OpenCV · scikit-image · Albumentations |
197
- | Explainability | pytorch-grad-cam (Grad-CAM / ++ / Score-CAM / Eigen-CAM) |
198
- | Config & tracking | Hydra + OmegaConf · Weights & Biases |
199
- | Metrics & statistics | torchmetrics · scikit-learn · scipy · statsmodels (bootstrap CI, DeLong, McNemar) |
200
- | Simulation | SimPy *(optional Simulink mirror)* |
201
- | Serving | FastAPI · ONNX Runtime · Docker |
202
- | Demo | Gradio → HuggingFace Spaces |
203
- | Reporting | ReportLab |
204
- | Quality | ruff · pytest · pre-commit · GitHub Actions |
205
-
206
- Rationale for every choice, including the MATLAB→open-source mapping: [`docs/03_TECH_STACK.md`](docs/03_TECH_STACK.md).
207
-
208
- ---
209
-
210
- ## System Architecture
211
-
212
- ```mermaid
213
- flowchart TD
214
- A[Fundus image<br/>portable camera] --> B[Stage 1<br/>Quality Assessment]
215
- B -->|Reject| B1[Recapture guidance:<br/>defocus · illumination · FOV]
216
- B1 -.retake.-> A
217
- B -->|Good / Usable| C[Stage 2<br/>Adaptive Enhancement]
218
- C --> D[Stage 3<br/>Segmentation]
219
- D --> D1[Vessels]
220
- D --> D2[Optic disc / fovea<br/>→ quadrant map]
221
- D --> D3[Lesions: MA · HE · SE · EX]
222
- D3 --> E[Lesion features<br/>counts · areas · quadrants]
223
- C --> F[Stage 4<br/>Grading backbone]
224
- E --> G[Fusion head]
225
- F --> G
226
- G --> H[Stage 5<br/>Ordinal head + calibration]
227
- H --> I{Confident?}
228
- I -->|Yes| J[Stage 6<br/>Explainability]
229
- I -->|No| K[Escalate to human grader]
230
- D3 --> J
231
- J --> L[Annotated PDF report]
232
- L --> M[Ophthalmologist review]
233
- K --> M
234
- M --> N[(Audit log)]
235
- N -.feedback.-> F
236
- ```
237
-
238
- **In plain language.** An image arrives from a portable camera in a primary health centre. Before
239
- anything else, the system decides whether it is *gradable* — and if not, tells the operator exactly
240
- what to fix. A gradable image is normalised for the wide variation in camera and lighting, then passed
241
- down two parallel paths: one segments the retina's structures and lesions, the other classifies overall
242
- severity. The two paths meet at a fusion head, because the lesions a clinician would look for are
243
- exactly the features that should drive the grade. The result is calibrated into an honest probability;
244
- uncertain cases go straight to a human. Confident cases produce a report whose explanation is grounded
245
- in the same lesion evidence the model used — not a decorative heatmap. Every decision is logged, and
246
- graders' corrections feed the next training round.
247
-
248
- ---
249
-
250
- ## Application Flow
251
-
252
- ```mermaid
253
- sequenceDiagram
254
- participant P as Patient
255
- participant H as Health worker
256
- participant E as Edge app
257
- participant S as District server
258
- participant O as Ophthalmologist
259
-
260
- P->>H: Arrives at screening camp
261
- H->>E: Capture fundus image
262
- E->>E: On-device quality check
263
- alt Ungradable
264
- E-->>H: "Image defocused — retake"
265
- H->>E: Recapture
266
- end
267
- E->>S: Upload (store-and-forward if offline)
268
- S->>S: Enhance → segment → grade → calibrate
269
- alt High confidence, no DR
270
- S-->>H: Auto-cleared · routine recall
271
- else Referable or uncertain
272
- S->>O: Queue annotated report
273
- O->>O: Review (~30 s target)
274
- O-->>H: Confirmed grade + referral
275
- end
276
- H-->>P: Result and next step
277
- O->>S: Correction logged for retraining
278
- ```
279
-
280
- The **on-device quality check is deliberate**: a round trip to the cloud to learn the image was blurry
281
- is precisely the failure mode documented in Thailand. The check must complete while the patient is
282
- still in the chair.
283
-
284
- ---
285
-
286
- ## Data & ML Pipeline
287
-
288
- ### Data sources
289
-
290
- | Dataset | Size | Labels | Population | Role | Licence |
291
- |---|---|---|---|---|---|
292
- | [APTOS 2019](https://www.kaggle.com/c/aptos2019-blindness-detection) | 3,662 | ICDR 0–4 | **India** (Aravind, Madurai) | Primary training | Competition rules |
293
- | [EyePACS 2015](https://www.kaggle.com/c/diabetic-retinopathy-detection) | 88,702 | ICDR 0–4 (noisy) | US | Pretraining *(cloud-side only)* | Competition rules |
294
- | [IDRiD](https://ieee-dataport.org/open-access/indian-diabetic-retinopathy-image-dataset-idrid) | 516 | Grades + **pixel masks** + OD/fovea | **India** (Nanded) | Lesion segmentation, XAI ground truth | CC BY 4.0 |
295
- | [Messidor-2](https://www.adcis.net/en/third-party/messidor2/) | 1,748 | Adjudicated grades | France | **Locked external test** | ADCIS terms |
296
- | [DRIVE](https://drive.grand-challenge.org/) | 40 | Vessel masks | Netherlands | Vessel segmentation | Research use |
297
- | EyeQ | 28,792 | Good / Usable / Reject | derived from EyePACS | Quality model | Research use |
298
-
299
- > Raw images are **never committed**. `scripts/download_data.sh` fetches them; `data/manifests/` commits
300
- > sha256 hashes so anyone can verify byte-identical inputs.
301
-
302
- ### Pipeline
303
-
304
- ```mermaid
305
- flowchart LR
306
- R[Raw fundus] --> Q{Gradable?}
307
- Q -->|No| RC[Recapture]
308
- Q -->|Yes| P1[crop_from_gray]
309
- P1 --> P2[circle_crop]
310
- P2 --> P3[Ben Graham<br/>local colour norm]
311
- P3 --> P4[CLAHE + illumination norm]
312
- P4 --> P5[Resize 512–768 px<br/>cache to disk]
313
- P5 --> AUG[Augment:<br/>flip · rotate · colour jitter]
314
- AUG --> TR[Train]
315
- TR --> CAL[Calibrate on val]
316
- CAL --> TEST[Locked external test<br/>evaluated ONCE]
317
- ```
318
-
319
- **Cleaning & preparation.** Circle-crop removes the black surround and standardises the field of view;
320
- `crop_from_gray` handles off-centre captures. **Ben Graham preprocessing** — subtracting a heavily
321
- blurred copy to remove local average colour — is the single highest-value step, as established by the
322
- Kaggle DR 2015 winner and used by most APTOS top solutions. Preprocessed images are cached once at
323
- 512 px, which shrinks APTOS from ~10 GB to ~200 MB and makes every subsequent epoch cheaper.
324
-
325
- **Feature engineering.** Beyond learned features, the lesion branch yields *clinically named* features:
326
- microaneurysm count, haemorrhage and exudate area, per-quadrant distribution (using the optic
327
- disc–fovea axis), and distance-to-fovea. These are the ICDR criteria expressed numerically — they
328
- improve the grade and simultaneously become the explanation.
329
-
330
- **Training approach.**
331
- - Two-stage: pretrain on EyePACS (or initialise from [RETFound](https://www.nature.com/articles/s41586-023-06555-x),
332
- a masked-autoencoder foundation model trained on 1.6 M retinal images), then fine-tune on APTOS.
333
- - **Ordinal loss**, not cross-entropy — DR grades are ordered, and recent SOTA
334
- ([Dual-SwinOrd](https://www.mdpi.com/2306-5354/13/4/374), AOR-DR) confirms this matters.
335
- - **Resolution is the dominant hyperparameter.** A microaneurysm is ~10 px on a 4288 px image; at
336
- 224 px it is sub-pixel and physically destroyed. Sweep 384/512/768 early.
337
- - **Patient-level splits** — both eyes of one patient must never straddle train and test.
338
-
339
- **Evaluation metrics.** Quadratic Weighted Kappa (grading); sensitivity and specificity at the
340
- referable-DR operating point (grade ≥ 2); AUROC; **AUPRC for lesion segmentation** (lesion pixels are
341
- <0.1 % of an image, which makes AUROC flattering and near-meaningless); Expected Calibration Error;
342
- all with bootstrap 95 % CIs, DeLong for AUC comparisons, and McNemar for paired sensitivity/specificity.
343
-
344
- ---
345
-
346
- ## Results & Model Performance
347
-
348
- > **No results yet — models are not trained.** The table below states **targets** and the **published
349
- > work they are benchmarked against**. It will be replaced with measured values, confidence intervals,
350
- > and the full ablation once Phase 8 completes.
351
-
352
- ### Targets
353
-
354
- | Metric | Target | Benchmark it is measured against |
355
- |---|---|---|
356
- | Referable DR sensitivity | **≥ 90 %** | Ting 2017: 90.5 % · IDx-DR: 87.2 % · Google/Aravind: 88.9 % |
357
- | Referable DR specificity | **≥ 85 %** | Ting 2017: 91.6 % · IDx-DR: 90.7 % · Google/Aravind: 92.2 % |
358
- | QWK (APTOS, 5-class) | **≥ 0.90** | Dual-SwinOrd SOTA: 0.9370 |
359
- | Quality classification (EyeQ) | **≈ 0.90 acc** | VISTA: 0.9066 acc, 0.8868 F1 |
360
- | Lesion AUPRC (IDRiD) | per-class, cross-validated | IDRiD ISBI-2018 challenge leaderboard |
361
- | Calibration (ECE) | **< 0.05** after temperature scaling | — |
362
- | Grad-CAM localisation | pointing-game accuracy vs IDRiD masks | *rarely reported — this is the contribution* |
363
-
364
- ### Published comparators
365
-
366
- | Study | Task | Sensitivity | Specificity | AUC |
367
- |---|---|---|---|---|
368
- | [Gulshan 2016 (JAMA)](https://jamanetwork.com/journals/jama/fullarticle/2588763) | Referable DR, EyePACS-1 | 97.5 % | 93.4 % | 0.991 |
369
- | [Ting 2017 (JAMA)](https://pubmed.ncbi.nlm.nih.gov/29234807/) | Referable DR, multiethnic | 90.5 % | 91.6 % | 0.936 |
370
- | [Abràmoff 2018 (npj Digit Med)](https://www.nature.com/articles/s41746-018-0040-6) | mtmDR, **prospective primary care** | 87.2 % | 90.7 % | — |
371
- | [Gulshan 2019 (JAMA Ophthalmol)](https://research.google/pubs/performance-of-a-deep-learning-algorithm-vs-manual-grading-for-detecting-diabetic-retinopathy-in-india/) | Referable DR, **Aravind, India** | 88.9 % | 92.2 % | 0.963 |
372
- | same | Referable DR, **Sankara Nethralaya** | 92.1 % | 95.2 % | 0.980 |
373
-
374
- > **A note on honesty:** a faithful reproduction of Gulshan 2016 scored AUC 0.951 on EyePACS but only
375
- > **0.853 on Messidor-2**. External validation drops are the norm, not a failure. This project plans
376
- > for that drop, reports it, and analyses the domain shift rather than re-tuning until it disappears.
377
-
378
- The planned **ablation** — eleven configurations, one factor added per row, evaluated on the locked
379
- test set with significance tests — is specified in
380
- [§7 of the analysis](docs/01_PROJECT_ANALYSIS.md#7-the-ablation-that-answers-integrated--single-technique).
381
-
382
- ---
383
-
384
- ## Deployment & Infrastructure
385
-
386
- | Concern | Approach |
387
- |---|---|
388
- | **Training** | Kaggle Notebooks (T4/P100, 30 GPU-h/week, EyePACS pre-mounted) — chosen because the dev machine has 28 GB free disk and EyePACS is ~90 GB |
389
- | **Local development** | Apple M4 via PyTorch MPS — preprocessing, IDRiD segmentation, inference, explainability, demo |
390
- | **Model export** | PyTorch → ONNX → ONNX Runtime (server), CoreML (Apple), TFLite (Android capture app) |
391
- | **Serving** | FastAPI + ONNX Runtime in Docker (`linux/amd64` + `linux/arm64`, so Jetson/Raspberry Pi remain viable for a PHC) |
392
- | **Edge** | On-device quality check so blur is caught before upload, not after |
393
- | **Connectivity** | Store-and-forward queue; the simulation explicitly models 1–10 Mbps rural links and outages |
394
- | **Public demo** | Gradio on HuggingFace Spaces (free CPU tier) |
395
- | **CI/CD** | GitHub Actions — ruff, pytest, and an end-to-end single-image smoke test on CPU |
396
- | **Monitoring** | Audit log of every prediction + grader override; drift review before any retraining |
397
- | **Reproducibility** | Hydra configs, fixed seeds, sha256 data manifests; `make setup && make evaluate` reproduces the headline table |
398
-
399
- ---
400
-
401
- ## Project Structure
402
-
403
- ```
404
- Diabetic-Retinopathy-Detection/
405
- ├── configs/ # Hydra YAMLs — one per experiment; this IS the ablation grid
406
- ├── data/ # gitignored (manifests are committed)
407
- │ ├── raw/ interim/ processed/ external/
408
- │ └── manifests/ # sha256 + labels + patient_id
409
- ├── docs/
410
- │ ├── 01_PROJECT_ANALYSIS.md # what this is, why it is hard, what "done" means
411
- │ ├── 02_LITERATURE_REVIEW.md # annotated evidence base
412
- │ ├── 03_TECH_STACK.md # tooling decisions and rationale
413
- │ ├── 04_ROADMAP.md # 20-week phased plan
414
- │ └── 05_PROTOTYPE_SCOPE.md # Tier-P: scaling down without breaking the science
415
- ├── notebooks/ # exploration only — logic lives in src/
416
- ├── src/drdetect/
417
- │ ├── data/ # datasets, patient-level splits, manifests
418
- │ ├── quality/ # Stage 1 — gradability + recapture guidance
419
- │ ├── enhance/ # Stage 2 — Ben Graham, CLAHE, illumination
420
- │ ├── segmentation/ # Stage 3 — vessels, OD/fovea, lesions
421
- │ ├── grading/ # Stage 4 — backbones, ordinal heads, fusion
422
- │ ├── calibration/ # Stage 5 — temperature scaling, thresholds, uncertainty
423
- │ ├── explain/ # Stage 6 — CAMs, sanity checks, localisation metrics, reports
424
- │ ├── eval/ # metrics, bootstrap CI, DeLong, McNemar
425
- │ ├── serve/ # FastAPI + ONNX
426
- │ └── utils/ # seeding, logging, io
427
- ├── simulation/
428
- │ ├── simpy/ # Stage 7 — district screening programme model
429
- │ └── simulink/ # optional .slx mirror
430
- ├── scripts/ # benchmark_device.py · download_data.sh · preprocess.py · train.py · evaluate.py
431
- ├── tests/
432
- ├── models/ # gitignored; released via GitHub Releases / HF Hub
433
- └── .github/workflows/
434
- ```
435
-
436
- ---
437
-
438
- ## Getting Started
439
-
440
- ### Prerequisites
441
-
442
- - Python **3.11** (not 3.13 — several CV/DL wheels still lag)
443
- - ~20 GB free disk for local datasets
444
- - A Kaggle account (for datasets and free GPU)
445
- - Optional: CUDA GPU. Apple Silicon works via MPS for everything except large-scale training.
446
-
447
- ### Installation
448
-
449
- ```bash
450
- git clone https://github.com/adarshcod30/Diabetic-Retinopathy-Detection.git
451
- cd Diabetic-Retinopathy-Detection
452
- ```
453
-
454
- ```bash
455
- conda create -n dr python=3.11 -y && conda activate dr
456
- ```
457
-
458
- ```bash
459
- pip install -e ".[dev]"
460
- ```
461
-
462
- ```bash
463
- python -c "import torch; print('MPS:', torch.backends.mps.is_available(), '| CUDA:', torch.cuda.is_available())"
464
- ```
465
-
466
- ### Get the data
467
-
468
- Place your Kaggle token at `~/.config/kaggle/kaggle.json`, then:
469
-
470
- ```bash
471
- bash scripts/download_data.sh --datasets aptos,idrid,drive
472
- ```
473
-
474
- > Messidor-2 requires accepting [ADCIS terms](https://www.adcis.net/en/third-party/messidor2/) and is
475
- > downloaded manually into `data/external/messidor2/`. **Do not** download EyePACS locally — it is
476
- > ~90 GB; train against it on Kaggle instead.
477
-
478
- ### Preprocess
479
-
480
- ```bash
481
- python scripts/preprocess.py --dataset aptos --size 512 --pipeline bengraham
482
- ```
483
-
484
- ---
485
-
486
- ## Usage
487
-
488
- Grade a single image and produce an annotated PDF report:
489
-
490
- ```bash
491
- python scripts/predict.py --image path/to/fundus.jpg --report out/report.pdf
492
- ```
493
-
494
- Launch the interactive demo:
495
-
496
- ```bash
497
- python -m drdetect.serve.demo
498
- ```
499
-
500
- Run a training experiment (Hydra — every ablation row is a config override):
501
-
502
- ```bash
503
- python scripts/train.py experiment=grading_effnetv2 data.image_size=512 loss=ordinal
504
- ```
505
-
506
- Evaluate on the locked external test set:
507
-
508
- ```bash
509
- python scripts/evaluate.py --checkpoint models/checkpoints/best.ckpt --split external_test --bootstrap 2000
510
- ```
511
-
512
- Run the screening-programme simulation:
513
-
514
- ```bash
515
- python -m simulation.simpy.district --patients-per-year 100000 --graders 4 --bandwidth-mbps 5
516
- ```
517
-
518
- > Commands reflect the target interface; scripts land progressively through Phases 1–7. See
519
- > [`docs/04_ROADMAP.md`](docs/04_ROADMAP.md) for what exists today.
520
-
521
- ---
522
-
523
- ## Testing
524
-
525
- ```bash
526
- pytest -q
527
- ```
528
-
529
- ```bash
530
- ruff check . && ruff format --check .
531
- ```
532
-
533
- Test strategy:
534
- - **Unit** — preprocessing determinism, circle-crop geometry, metric correctness against hand-computed
535
- cases, patient-level split integrity (*asserts no patient appears in two splits* — the leakage bug
536
- that silently inflates every published number)
537
- - **Integration** — one image end-to-end on CPU, asserting a valid grade and a well-formed PDF
538
- - **CI** — ruff + pytest + the smoke test on every push
539
-
540
- ---
541
-
542
- ## Roadmap
543
-
544
- | Phase | Weeks | Milestone |
545
- |---|---|---|
546
- | 0 · Foundation | 1 | Repo, environment, data audit, manifests |
547
- | 1 · Baseline | 2–3 | Baseline QWK on APTOS — a number to beat |
548
- | **2 · Vertical slice** ⭐ | 4–5 | **JPEG in → annotated PDF out** |
549
- | 3 · Grading depth | 6–8 | Resolution sweep, ordinal loss, pretraining → QWK ≥ 0.90 |
550
- | 4 · Segmentation | 9–11 | Vessels, OD/fovea, four lesion classes, MA detector |
551
- | 5 · Integration | 12–13 | Lesion fusion + calibration + uncertainty escalation |
552
- | **6 · Rigorous XAI** ⭐ | 14–15 | **Sanity checks + measured CAM localisation** |
553
- | 7 · Simulation | 16–17 | District programme model, ophthalmologist-hours freed |
554
- | 8 · Validation | 18–19 | Full ablation on locked test set with significance tests |
555
- | 9 · Release | 20 | ONNX, Docker, HF Spaces demo, model card |
556
-
557
- Detail, exit criteria, and an explicit scope-cut order: [`docs/04_ROADMAP.md`](docs/04_ROADMAP.md).
558
-
559
- **Known scope limit:** neovascularisation segmentation is *out of scope* — no public dataset provides
560
- NV pixel masks. PDR is detected at image level and the gap is documented rather than papered over.
561
-
562
- ---
563
-
564
- ## Prototype Scope (Tier-P)
565
-
566
- Development runs on a 16 GB Apple M4 with ~28 GB free disk. That constrains **compute**, not
567
- data — after caching at 512 px, APTOS, IDRiD, Messidor-2 and DRIVE together occupy **0.4 GB**.
568
-
569
- So the scale-down cuts compute and keeps every image:
570
-
571
- | | Full (Tier-F) | **Prototype (Tier-P)** |
572
- |---|---|---|
573
- | APTOS / IDRiD / DRIVE / Messidor-2 | full | **full — unchanged** |
574
- | EyePACS pretraining | 88,702 | 12–15k stratified, cached on Kaggle |
575
- | Backbone | EfficientNetV2-S | EfficientNet-B0 |
576
- | Resolution | 768 px | 512 px *(floor — below this microaneurysms vanish)* |
577
- | Cross-validation | 5-fold + TTA | single split + hflip TTA |
578
-
579
- **≈34× cheaper, zero task data discarded.** Every cut is a config override, reversible on
580
- Kaggle without re-splitting or re-collecting anything.
581
-
582
- Two things are never scaled down: the **test set** (a 50-case test set gives a ±8.5 pp
583
- confidence interval, which makes a ">90 % sensitivity" claim unmakeable) and the **validation
584
- protocol**. Grade 3 has only 193 images in all of APTOS — subsampling collapses it to 15–38
585
- training examples long before the dataset merely looks small.
586
-
587
- Full arithmetic and the claims that do and don't survive: [`docs/05_PROTOTYPE_SCOPE.md`](docs/05_PROTOTYPE_SCOPE.md).
588
-
589
- ## Ethical Boundary & Intended Use
590
-
591
- **This is a research prototype. It is not a medical device and must not be used for clinical
592
- diagnosis.**
593
-
594
- - Software intended for diagnosis falls under **CDSCO** medical-device rules in India; the comparable
595
- US system (IDx-DR) required an FDA De Novo authorisation. This project has neither and seeks neither.
596
- - Outputs are **decision support with a human in the loop**, never a diagnosis. Every generated report
597
- carries the disclaimer.
598
- - **Training populations are documented and limited:** APTOS and IDRiD are Indian, EyePACS is US,
599
- Messidor-2 is French. Performance on any other population is **unverified**.
600
- - No patient-identifying data is stored in this repository; demo images are EXIF-stripped.
601
- - Released weights are **research use only**, consistent with the licences of the data they derive from.
602
-
603
- ---
604
-
605
- ## Contributing
606
-
607
- Contributions are welcome — particularly clinical review of the explanation format, additional external
608
- validation datasets, and reproductions of the ablation.
609
-
610
- 1. Fork and branch (`git checkout -b feature/your-feature`)
611
- 2. `pre-commit install`
612
- 3. Add tests for anything you change
613
- 4. Ensure `pytest` and `ruff check .` pass
614
- 5. Open a pull request describing *what you measured*, not only what you changed
615
-
616
- Findings that contradict results here are especially welcome. Please open an issue with the
617
- configuration and seed.
618
-
619
- ---
620
-
621
- ## License
622
-
623
- Code is released under the **MIT License** — see [LICENSE](LICENSE).
624
-
625
- **Data and weights are not.** Datasets remain under their original licences (IDRiD CC BY 4.0;
626
- APTOS/EyePACS competition rules; Messidor-2 ADCIS terms; DRIVE research use). Raw images are never
627
- redistributed here. Trained weights derived from research-use data are released for **research use
628
- only**.
629
-
630
- ---
631
-
632
- ## Contact
633
-
634
- **Adarsh Dwivedi** · [@adarshcod30](https://github.com/adarshcod30)
635
- Project: <https://github.com/adarshcod30/Diabetic-Retinopathy-Detection>
636
-
637
- ## Acknowledgments
638
-
639
- The datasets that make this possible: [APTOS](https://www.kaggle.com/c/aptos2019-blindness-detection)
640
- (Aravind Eye Hospital), [IDRiD](https://idrid.grand-challenge.org/) (Porwal et al.),
641
- [Messidor-2](https://www.adcis.net/en/third-party/messidor2/) (ADCIS), and
642
- [DRIVE](https://drive.grand-challenge.org/). Full evidence base:
643
- [`docs/02_LITERATURE_REVIEW.md`](docs/02_LITERATURE_REVIEW.md).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect.egg-info/SOURCES.txt DELETED
@@ -1,20 +0,0 @@
1
- LICENSE
2
- README.md
3
- pyproject.toml
4
- src/drdetect/__init__.py
5
- src/drdetect.egg-info/PKG-INFO
6
- src/drdetect.egg-info/SOURCES.txt
7
- src/drdetect.egg-info/dependency_links.txt
8
- src/drdetect.egg-info/requires.txt
9
- src/drdetect.egg-info/top_level.txt
10
- src/drdetect/calibration/__init__.py
11
- src/drdetect/data/__init__.py
12
- src/drdetect/enhance/__init__.py
13
- src/drdetect/enhance/preprocessing.py
14
- src/drdetect/eval/__init__.py
15
- src/drdetect/explain/__init__.py
16
- src/drdetect/grading/__init__.py
17
- src/drdetect/quality/__init__.py
18
- src/drdetect/segmentation/__init__.py
19
- src/drdetect/serve/__init__.py
20
- src/drdetect/utils/__init__.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect.egg-info/dependency_links.txt DELETED
@@ -1 +0,0 @@
1
-
 
 
src/src/drdetect.egg-info/requires.txt DELETED
@@ -1,40 +0,0 @@
1
- torch>=2.2
2
- torchvision>=0.17
3
- lightning>=2.2
4
- timm>=1.0
5
- monai>=1.3
6
- segmentation-models-pytorch>=0.3
7
- opencv-python-headless>=4.9
8
- scikit-image>=0.22
9
- albumentations>=1.4
10
- Pillow>=10.2
11
- numpy>=1.26
12
- pandas>=2.2
13
- scipy>=1.12
14
- scikit-learn>=1.4
15
- statsmodels>=0.14
16
- torchmetrics>=1.3
17
- hydra-core>=1.3
18
- omegaconf>=2.3
19
- wandb>=0.16
20
- grad-cam>=1.5
21
- simpy>=4.1
22
- reportlab>=4.1
23
- fastapi>=0.110
24
- uvicorn>=0.29
25
- onnx>=1.16
26
- onnxruntime>=1.17
27
- gradio>=4.25
28
- matplotlib>=3.8
29
- seaborn>=0.13
30
- tqdm>=4.66
31
- kaggle>=1.6
32
- python-dotenv>=1.0
33
-
34
- [dev]
35
- pytest>=8.0
36
- pytest-cov>=5.0
37
- ruff>=0.4
38
- pre-commit>=3.7
39
- ipykernel>=6.29
40
- jupyterlab>=4.1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect.egg-info/top_level.txt DELETED
@@ -1 +0,0 @@
1
- drdetect
 
 
src/src/drdetect/__init__.py DELETED
@@ -1,6 +0,0 @@
1
- """drdetect — explainable, quality-aware diabetic retinopathy screening.
2
-
3
- Research prototype. NOT a medical device; not for clinical diagnosis.
4
- """
5
-
6
- __version__ = "0.1.0"
 
 
 
 
 
 
 
src/src/drdetect/calibration/__init__.py DELETED
File without changes
src/src/drdetect/calibration/mc_dropout.py DELETED
@@ -1,33 +0,0 @@
1
- """MC-dropout uncertainty estimation for the grading model.
2
-
3
- EfficientNet's classifier dropout is *functional*, not a persistent
4
- `nn.Dropout` submodule -- timm's own `EfficientNet.forward_head` calls
5
- `F.dropout(x, p=self.drop_rate, training=self.training)` directly. There is
6
- no Dropout module to selectively re-enable via `.apply()`, so reactivating
7
- it at inference means putting the WHOLE model back into `.train()` mode
8
- (which sets the `self.training` flag `F.dropout` checks), then immediately
9
- re-freezing BatchNorm with this project's own `freeze_batchnorm` so its
10
- running statistics stay fixed -- leaving the dropout call as the only thing
11
- still affected by the reactivated training flag.
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import torch
17
- import torch.nn as nn
18
-
19
- from drdetect.grading.model import freeze_batchnorm
20
-
21
- __all__ = ["mc_dropout_samples"]
22
-
23
-
24
- def mc_dropout_samples(model: nn.Module, x: torch.Tensor, n_samples: int = 20) -> torch.Tensor:
25
- """Returns logits of shape (n_samples, batch, num_outputs), each from an
26
- independent stochastic forward pass with dropout active and BatchNorm
27
- still frozen on its pretrained running statistics."""
28
- model.train()
29
- freeze_batchnorm(model)
30
- with torch.no_grad():
31
- samples = torch.stack([model(x) for _ in range(n_samples)])
32
- model.eval()
33
- return samples
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/calibration/temperature.py DELETED
@@ -1,101 +0,0 @@
1
- """Temperature scaling (Guo et al. 2017) for post-hoc confidence calibration.
2
-
3
- Modern networks are systematically overconfident: raw softmax output is not a
4
- probability, even when the model's argmax is highly accurate. This project's
5
- own Phase 2 report has said so explicitly on every single output --
6
- "Model confidence (uncalibrated)" -- since there was nothing calibrated to
7
- report. This module is what closes that disclaimer, for checkpoints it has
8
- actually been run against.
9
-
10
- Temperature scaling fixes overconfidence with one learned scalar T, applied
11
- AFTER training with every other weight frozen:
12
-
13
- calibrated_probs = softmax(logits / T)
14
-
15
- T > 1 softens (reduces) confidence; T < 1 sharpens it. It cannot change which
16
- class wins (dividing every logit by the same positive number does not
17
- reorder them), only how confident the model is entitled to sound about it.
18
-
19
- Only meaningful for a softmax head (`ce` / `distance_ce`). CORN's logits are
20
- per-task sigmoids, not a single softmax, and regression has no probability
21
- distribution to calibrate at all -- both are out of scope here, not silently
22
- mishandled.
23
- """
24
-
25
- from __future__ import annotations
26
-
27
- import json
28
- from pathlib import Path
29
-
30
- import torch
31
- import torch.nn as nn
32
- import torch.nn.functional as F
33
-
34
- __all__ = ["TemperatureScaler", "fit_temperature", "save_temperature", "load_temperature"]
35
-
36
-
37
- class TemperatureScaler(nn.Module):
38
- """Wraps a frozen classifier's logits; only `log_temperature` is ever trained."""
39
-
40
- def __init__(self):
41
- super().__init__()
42
- self.log_temperature = nn.Parameter(torch.zeros(1))
43
-
44
- @property
45
- def temperature(self) -> torch.Tensor:
46
- # exp(), not a raw Parameter: keeps T > 0 for every value the
47
- # optimiser can reach. logits / T is undefined at T=0 and inverted
48
- # (sharpens instead of softens) for T<0 -- a plain Parameter could
49
- # wander into either during optimisation; exp() cannot reach them.
50
- return self.log_temperature.exp()
51
-
52
- def forward(self, logits: torch.Tensor) -> torch.Tensor:
53
- return logits / self.temperature
54
-
55
-
56
- def fit_temperature(logits: torch.Tensor, targets: torch.Tensor, *, max_iter: int = 50) -> float:
57
- """Fit T on held-out (logits, targets) by minimising NLL. Returns the fitted T.
58
-
59
- `logits` must come from a split the model was not trained OR early-stopped
60
- on. This project has only a train/val split per fold (no separate
61
- calibration split was reserved at Phase 0), so in practice this is fit on
62
- the same fold-0 validation set every other Phase 1/3 number came from --
63
- the identical "optimistic, not yet the Phase 8 number" caveat already
64
- disclosed for the operating threshold applies here too, not a new one.
65
-
66
- LBFGS, not Adam/SGD: the standard choice for this exact problem in the
67
- original paper's own reference implementation -- one parameter, a
68
- well-behaved objective, converges in a handful of iterations.
69
- """
70
- scaler = TemperatureScaler()
71
- optimizer = torch.optim.LBFGS([scaler.log_temperature], lr=0.01, max_iter=max_iter)
72
-
73
- def closure():
74
- optimizer.zero_grad()
75
- loss = F.cross_entropy(scaler(logits), targets)
76
- loss.backward()
77
- return loss
78
-
79
- optimizer.step(closure)
80
- return float(scaler.temperature.item())
81
-
82
-
83
- def save_temperature(checkpoint_path: str | Path, temperature: float, *, fitted_on: str) -> Path:
84
- """Write a sidecar JSON next to `checkpoint_path`, not inside the .ckpt.
85
-
86
- Keeping it separate means load_temperature() can no-op cleanly for every
87
- checkpoint that has never been calibrated, instead of every existing
88
- checkpoint needing to carry a temperature=1.0 placeholder it was never
89
- actually fit for.
90
- """
91
- out = Path(checkpoint_path).with_name("temperature.json")
92
- out.write_text(json.dumps({"temperature": temperature, "fitted_on": fitted_on}, indent=2))
93
- return out
94
-
95
-
96
- def load_temperature(checkpoint_path: str | Path) -> float:
97
- """1.0 (uncalibrated, a no-op divisor) if this checkpoint has no fitted temperature."""
98
- sidecar = Path(checkpoint_path).with_name("temperature.json")
99
- if not sidecar.exists():
100
- return 1.0
101
- return float(json.loads(sidecar.read_text())["temperature"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/data/__init__.py DELETED
File without changes
src/src/drdetect/data/dataset.py DELETED
@@ -1,134 +0,0 @@
1
- """Torch dataset over the preprocessed cache.
2
-
3
- Reads the 512 px cache written by scripts/preprocess.py, not the raw images.
4
- The expensive work (crop, Ben Graham, resize) happened once; the training loop
5
- only decodes a small JPEG and augments.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- from pathlib import Path
11
-
12
- import cv2
13
- import numpy as np
14
- from torch.utils.data import Dataset
15
-
16
- from drdetect.data.manifest import ImageRecord, read_manifest
17
-
18
- __all__ = ["FundusDataset", "build_transforms", "load_split"]
19
-
20
-
21
- class FundusDataset(Dataset):
22
- """Fundus images with ICDR grades.
23
-
24
- Args:
25
- records: manifest rows for this split.
26
- root: directory the manifest's `path` fields are relative to.
27
- transform: Albumentations transform. Must include normalisation and
28
- ToTensorV2 -- this class does no implicit conversion, so what the
29
- model receives is always explicit in the config.
30
- """
31
-
32
- def __init__(self, records: list[ImageRecord], root: str | Path, transform=None):
33
- self.records = records
34
- self.root = Path(root)
35
- self.transform = transform
36
-
37
- def __len__(self) -> int:
38
- return len(self.records)
39
-
40
- def __getitem__(self, idx: int):
41
- rec = self.records[idx]
42
- path = self.root / rec.path
43
- image = cv2.imread(str(path), cv2.IMREAD_COLOR)
44
- if image is None:
45
- raise FileNotFoundError(
46
- f"Could not read {path}. Re-run scripts/preprocess.py, or verify the "
47
- f"cache against the manifest with drdetect.data.manifest.verify_manifest."
48
- )
49
- image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
50
-
51
- if self.transform is not None:
52
- image = self.transform(image=image)["image"]
53
- return image, rec.label
54
-
55
- @property
56
- def labels(self) -> np.ndarray:
57
- return np.array([r.label for r in self.records])
58
-
59
- @property
60
- def groups(self) -> list[str]:
61
- return [r.group_id for r in self.records]
62
-
63
-
64
- def build_transforms(size: int, train: bool):
65
- """Augmentations.
66
-
67
- Train-time choices and why:
68
- * flips and rotation -- a fundus has no canonical orientation, and left vs
69
- right eyes are mirror images, so these are label-preserving;
70
- * mild brightness/contrast -- models the illumination variation that
71
- portable cameras genuinely produce in the field;
72
- * NO heavy blur or noise -- a microaneurysm is 1-3 px at this resolution,
73
- and those augmentations would erase the very feature that defines
74
- grade 1.
75
- """
76
- import albumentations as A
77
- from albumentations.pytorch import ToTensorV2
78
-
79
- # ImageNet statistics, because the backbone is ImageNet-pretrained.
80
- normalise = A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225))
81
-
82
- if not train:
83
- return A.Compose([A.Resize(size, size), normalise, ToTensorV2()])
84
-
85
- return A.Compose(
86
- [
87
- A.Resize(size, size),
88
- A.HorizontalFlip(p=0.5),
89
- A.VerticalFlip(p=0.5),
90
- A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=180, p=0.7),
91
- A.RandomBrightnessContrast(brightness_limit=0.15, contrast_limit=0.15, p=0.5),
92
- normalise,
93
- ToTensorV2(),
94
- ]
95
- )
96
-
97
-
98
- def load_split(
99
- manifest_path: str | Path,
100
- *,
101
- fold: int = 0,
102
- n_splits: int = 5,
103
- seed: int = 42,
104
- allow_ungrouped: bool = False,
105
- ) -> tuple[list[ImageRecord], list[ImageRecord], str]:
106
- """Split a manifest into (train, val, strategy) for one fold.
107
-
108
- Grouping comes from the manifest's `group_id`, which scripts/preprocess.py
109
- populates by perceptual hashing. Leakage is asserted, not assumed.
110
- """
111
- from drdetect.data.splits import (
112
- assert_no_group_leakage,
113
- stratified_group_split,
114
- validate_grouping,
115
- )
116
-
117
- records = read_manifest(manifest_path)
118
- labels = [r.label for r in records]
119
- groups = [r.group_id for r in records]
120
-
121
- # Reject a collapsed grouping BEFORE splitting. A single giant group passes
122
- # the leakage assertion trivially and produces a wildly lopsided split.
123
- if any(groups):
124
- validate_grouping(groups)
125
-
126
- folds, strategy = stratified_group_split(
127
- labels, groups, n_splits=n_splits, seed=seed, allow_ungrouped=allow_ungrouped
128
- )
129
- assert_no_group_leakage(folds, groups)
130
-
131
- val_idx = set(folds[fold].tolist())
132
- train_records = [r for i, r in enumerate(records) if i not in val_idx]
133
- val_records = [r for i, r in enumerate(records) if i in val_idx]
134
- return train_records, val_records, strategy
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/data/manifest.py DELETED
@@ -1,107 +0,0 @@
1
- """Dataset manifests: the committed record of exactly which bytes were used.
2
-
3
- Raw images are never committed (licence + size). A manifest is committed instead:
4
- one row per image with its sha256, so anyone can verify they hold byte-identical
5
- inputs before comparing results. This is DVC's job, done with a CSV.
6
-
7
- Schema
8
- ------
9
- image_id stable identifier (dataset-unique)
10
- path path relative to the dataset root
11
- sha256 content hash of the raw file
12
- dataset aptos | idrid | messidor2 | drive | eyepacs
13
- label ICDR grade 0-4, or -1 when unlabelled
14
- group_id patient/eye grouping key for leak-free splits, or "" if unknown
15
- phash structural perceptual hash (decimal string), or "" -- cached so
16
- regrouping does not require re-decoding every source image
17
- width pixels
18
- height pixels
19
- """
20
-
21
- from __future__ import annotations
22
-
23
- import csv
24
- import hashlib
25
- from dataclasses import asdict, dataclass, fields
26
- from pathlib import Path
27
-
28
- __all__ = ["ImageRecord", "sha256_file", "write_manifest", "read_manifest", "verify_manifest"]
29
-
30
- CHUNK = 1 << 20 # 1 MiB
31
-
32
-
33
- @dataclass(frozen=True)
34
- class ImageRecord:
35
- image_id: str
36
- path: str
37
- sha256: str
38
- dataset: str
39
- label: int = -1
40
- group_id: str = ""
41
- phash: str = ""
42
- width: int = 0
43
- height: int = 0
44
-
45
-
46
- def sha256_file(path: str | Path) -> str:
47
- """Content hash, streamed so a 90 GB dataset does not need 90 GB of RAM."""
48
- h = hashlib.sha256()
49
- with open(path, "rb") as fh:
50
- while chunk := fh.read(CHUNK):
51
- h.update(chunk)
52
- return h.hexdigest()
53
-
54
-
55
- def write_manifest(records: list[ImageRecord], out_path: str | Path) -> Path:
56
- out_path = Path(out_path)
57
- out_path.parent.mkdir(parents=True, exist_ok=True)
58
- cols = [f.name for f in fields(ImageRecord)]
59
- with open(out_path, "w", newline="") as fh:
60
- writer = csv.DictWriter(fh, fieldnames=cols)
61
- writer.writeheader()
62
- for rec in sorted(records, key=lambda r: r.image_id): # stable ordering
63
- writer.writerow(asdict(rec))
64
- return out_path
65
-
66
-
67
- def read_manifest(path: str | Path) -> list[ImageRecord]:
68
- out: list[ImageRecord] = []
69
- with open(path, newline="") as fh:
70
- for row in csv.DictReader(fh):
71
- out.append(
72
- ImageRecord(
73
- image_id=row["image_id"],
74
- path=row["path"],
75
- sha256=row["sha256"],
76
- dataset=row["dataset"],
77
- label=int(row["label"]),
78
- group_id=row["group_id"],
79
- phash=row.get("phash", ""),
80
- width=int(row["width"]),
81
- height=int(row["height"]),
82
- )
83
- )
84
- return out
85
-
86
-
87
- def verify_manifest(
88
- manifest_path: str | Path, root: str | Path, *, sample: int | None = None
89
- ) -> tuple[list[str], list[str]]:
90
- """Re-hash files on disk and compare against the manifest.
91
-
92
- Returns (missing, mismatched) image_ids. `sample` limits the check to the
93
- first N records -- full verification of a large dataset is I/O bound.
94
- """
95
- root = Path(root)
96
- records = read_manifest(manifest_path)
97
- if sample is not None:
98
- records = records[:sample]
99
-
100
- missing, mismatched = [], []
101
- for rec in records:
102
- fpath = root / rec.path
103
- if not fpath.exists():
104
- missing.append(rec.image_id)
105
- elif sha256_file(fpath) != rec.sha256:
106
- mismatched.append(rec.image_id)
107
- return missing, mismatched
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/data/splits.py DELETED
@@ -1,349 +0,0 @@
1
- """Leak-free cross-validation splits.
2
-
3
- The failure this module exists to prevent
4
- -----------------------------------------
5
- Fundus datasets contain both eyes of the same patient, and the two eyes of one
6
- diabetic are strongly correlated in DR severity. If one eye lands in train and
7
- the other in test, the model has effectively seen the test set. Every reported
8
- metric inflates, the loss curves look perfect, and nothing warns you.
9
-
10
- The complication specific to APTOS
11
- ----------------------------------
12
- APTOS-2019's `train.csv` carries only `id_code` and `diagnosis`. There is **no
13
- patient or eye identifier**, so a true patient-level split is impossible on that
14
- dataset as distributed. Silently falling back to an image-level split would be
15
- exactly the failure described above, so this module:
16
-
17
- 1. refuses to guess -- `stratified_group_split` requires an explicit decision;
18
- 2. offers `perceptual_hash_groups` as a documented mitigation: images that are
19
- near-identical (plausibly the same eye or patient) are grouped so they
20
- cannot straddle folds;
21
- 3. records which strategy was used, so the caveat travels with the results.
22
-
23
- Messidor-2 *does* encode exam pairing in its filenames, so real grouping is
24
- available there and should be used.
25
- """
26
-
27
- from __future__ import annotations
28
-
29
- import warnings
30
- from collections import defaultdict
31
-
32
- import numpy as np
33
-
34
- __all__ = [
35
- "SplitStrategy",
36
- "dhash",
37
- "structural_hash",
38
- "perceptual_hash_groups",
39
- "validate_grouping",
40
- "stratified_group_split",
41
- "assert_no_group_leakage",
42
- ]
43
-
44
-
45
- class SplitStrategy:
46
- """How grouping was determined. Travels with results as a provenance record."""
47
-
48
- TRUE_GROUPS = "true_groups" # real patient/eye ids -- trustworthy
49
- PERCEPTUAL = "perceptual_hash" # near-duplicate grouping -- mitigation only
50
- IMAGE_LEVEL = "image_level" # no grouping -- leakage possible, must be declared
51
-
52
-
53
- def dhash(image: np.ndarray, hash_size: int = 8) -> int:
54
- """Difference hash: a 64-bit perceptual fingerprint.
55
-
56
- Compares each pixel with its right-hand neighbour on a downscaled grayscale
57
- image, so it is robust to resolution, JPEG artefacts and mild brightness
58
- shifts, but sensitive to actual content. Two photographs of the same eye
59
- typically differ by only a few bits.
60
-
61
- Implemented here rather than pulled from `imagehash` to avoid a dependency
62
- for ~10 lines, and so the exact behaviour is pinned by our own tests.
63
- """
64
- import cv2
65
-
66
- if image.ndim == 3:
67
- image = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
68
- resized = cv2.resize(image, (hash_size + 1, hash_size), interpolation=cv2.INTER_AREA)
69
- diff = resized[:, 1:] > resized[:, :-1]
70
- bits = 0
71
- for bit in diff.flatten():
72
- bits = (bits << 1) | int(bit)
73
- return bits
74
-
75
-
76
- def structural_hash(image: np.ndarray, size: int = 256, hash_size: int = 16) -> int:
77
- """Perceptual hash of an image's *coarse structure*, for grouping.
78
-
79
- Deliberately computed on a plain circle-crop + resize, NOT on the
80
- Ben Graham-preprocessed image used for training.
81
-
82
- Ben Graham preprocessing subtracts the local average colour, which removes
83
- exactly the low-frequency content dHash depends on. Measured on a fundus and
84
- a degraded copy of itself: hashing the Ben Graham output put them 8 bits
85
- apart (above the grouping threshold, so the duplicate was missed), while
86
- hashing the structural image put them 0 bits apart -- with unrelated images
87
- still 14 bits away.
88
-
89
- The identity of an eye lives in its vessel and optic-disc layout. That is
90
- what this hashes.
91
-
92
- Resolution matters more than it looks. At 8x8 (64 bits) on 128 px, measured
93
- across APTOS the median inter-image distance was 18 bits with ~670 pairs
94
- falling within 5 bits -- most of them false matches, because 64 bits cannot
95
- separate two fundus photographs that are both "a bright disc on black".
96
- At 16x16 (256 bits) on 256 px the distribution is cleanly bimodal: 148 pairs
97
- at 0-4 bits and then NOTHING until 39 bits. Those false matches are what
98
- percolated the grouping into a single giant component.
99
- """
100
- import cv2
101
-
102
- from drdetect.enhance.preprocessing import circle_crop
103
-
104
- cropped = circle_crop(image)
105
- resized = cv2.resize(cropped, (size, size), interpolation=cv2.INTER_AREA)
106
- return dhash(resized, hash_size=hash_size)
107
-
108
-
109
- def _hamming(a: int, b: int) -> int:
110
- return bin(a ^ b).count("1")
111
-
112
-
113
- def perceptual_hash_groups(
114
- image_ids: list[str],
115
- hashes: list[int],
116
- max_distance: int = 6,
117
- *,
118
- max_group_size: int | None = None,
119
- ) -> dict[str, str]:
120
- """Group near-duplicate images via union-find over Hamming distance.
121
-
122
- Returns {image_id: group_id}. Images within `max_distance` bits are treated
123
- as the same group (transitively).
124
-
125
- Percolation, and why `max_group_size` exists
126
- --------------------------------------------
127
- Union-find takes the TRANSITIVE closure: a~b and b~c puts a, b and c in one
128
- group even if a and c are far apart. Over a large set, a small number of
129
- spurious edges is enough to chain almost everything together -- a random
130
- graph needs only ~N/2 edges to form a giant connected component.
131
-
132
- This is not hypothetical. On APTOS (3,662 images) a 64-bit hash produced
133
- ~670 near-matches, and the transitive closure merged 3,138 images into a
134
- single "group" which then became an entire validation fold. The split was
135
- train=524 / val=3138 and silently reported as `true_groups`.
136
-
137
- Two defences: a hash resolution that does not manufacture false edges (see
138
- `structural_hash`), and this hard cap on group size. `max_group_size`
139
- defaults to max(25, 1% of the dataset) -- a "patient" larger than that is a
140
- hash failure, not a patient, so the merge is refused.
141
-
142
- Choosing `max_distance`
143
- -----------------------
144
- Measured on APTOS with `structural_hash` over all 6.7M pairs, the cumulative
145
- count is flat across thresholds 2-6 (148 pairs at every value) and stays
146
- near-flat to 8 (150). It then climbs -- 207 at 10, 391 at 12 -- and chaining
147
- appears: threshold 10 produced a 31-image group whose members were up to 33
148
- bits apart. The default of 6 sits mid-plateau, so the result is insensitive
149
- to the exact value.
150
-
151
- Note the threshold must be measured for the hash ACTUALLY used. An earlier
152
- value of 10 was taken from a distribution measured on Ben Graham-preprocessed
153
- images and then applied to `structural_hash`, which runs on raw
154
- circle-cropped images. Different transform, different distances.
155
-
156
- This remains a *mitigation*, not a substitute for patient IDs: it catches
157
- repeated or near-identical captures, but two genuinely different photographs
158
- of one patient's two eyes will not be grouped. Declare the limitation when
159
- reporting.
160
- """
161
- if len(image_ids) != len(hashes):
162
- raise ValueError("image_ids and hashes must be the same length")
163
-
164
- n = len(image_ids)
165
- if max_group_size is None:
166
- max_group_size = max(25, n // 100)
167
-
168
- parent = list(range(n))
169
- size = [1] * n
170
-
171
- def find(i: int) -> int:
172
- while parent[i] != i:
173
- parent[i] = parent[parent[i]]
174
- i = parent[i]
175
- return i
176
-
177
- refused = 0
178
-
179
- def union(i: int, j: int) -> None:
180
- nonlocal refused
181
- ri, rj = find(i), find(j)
182
- if ri == rj:
183
- return
184
- if size[ri] + size[rj] > max_group_size:
185
- # Refusing the merge is the safe failure: two smaller groups may
186
- # leak, but one giant group destroys the split entirely.
187
- refused += 1
188
- return
189
- lo, hi = (ri, rj) if ri < rj else (rj, ri)
190
- parent[hi] = lo
191
- size[lo] += size[hi]
192
-
193
- # O(n^2). Fine for APTOS (3,662 -> ~6.7M comparisons, seconds). For EyePACS
194
- # scale this would need LSH bucketing on hash prefixes.
195
- for i in range(n):
196
- hi_hash = hashes[i]
197
- for j in range(i + 1, n):
198
- if (hi_hash ^ hashes[j]).bit_count() <= max_distance:
199
- union(i, j)
200
-
201
- if refused:
202
- warnings.warn(
203
- f"{refused} merge(s) refused because they would exceed max_group_size="
204
- f"{max_group_size}. This usually means the hash is producing false "
205
- f"matches; check structural_hash resolution and max_distance.",
206
- UserWarning,
207
- stacklevel=2,
208
- )
209
-
210
- roots = defaultdict(list)
211
- for idx, image_id in enumerate(image_ids):
212
- roots[find(idx)].append(image_id)
213
-
214
- # Name each group after its lexicographically smallest member, NOT the
215
- # union-find root index. The root index depends on the order the inputs
216
- # arrived in, so identical data grouped twice produced identical partitions
217
- # under different names -- and StratifiedGroupKFold assigns folds by group
218
- # VALUE, so the two runs got different splits. Measured: the 512 px and
219
- # 768 px manifests had the same 3,523 groups but shared only 139 of 733
220
- # validation images, making the two runs non-comparable.
221
- #
222
- # Naming by member makes the mapping a pure function of the partition.
223
- mapping: dict[str, str] = {}
224
- for members in roots.values():
225
- gid = f"g_{min(members)}"
226
- for m in members:
227
- mapping[m] = gid
228
- return mapping
229
-
230
-
231
- def validate_grouping(
232
- groups: list[str],
233
- *,
234
- max_group_fraction: float = 0.05,
235
- min_group_fraction: float = 0.5,
236
- ) -> dict:
237
- """Reject degenerate groupings before they silently destroy a split.
238
-
239
- Raises if any single group holds more than `max_group_fraction` of the data,
240
- or if the number of distinct groups falls below `min_group_fraction` of the
241
- number of images.
242
-
243
- This exists because the failure it catches is invisible downstream: a
244
- grouping that collapsed 86% of APTOS into one group still reported strategy
245
- `true_groups`, still passed the no-leakage assertion (trivially -- one group
246
- cannot straddle folds), and produced a train=524 / val=3138 split that would
247
- have trained for hours and produced a meaningless number.
248
- """
249
- from collections import Counter
250
-
251
- n = len(groups)
252
- counts = Counter(groups)
253
- largest_id, largest = counts.most_common(1)[0]
254
- stats = {
255
- "n_images": n,
256
- "n_groups": len(counts),
257
- "largest_group": largest,
258
- "largest_group_id": largest_id,
259
- "merged": n - len(counts),
260
- }
261
-
262
- if largest > max_group_fraction * n:
263
- raise ValueError(
264
- f"Degenerate grouping: group {largest_id!r} holds {largest}/{n} images "
265
- f"({100 * largest / n:.1f}%), above the {100 * max_group_fraction:.0f}% limit.\n"
266
- f"A 'patient' that large is a hash collapse, not a patient. Re-run "
267
- f"grouping with a higher-resolution hash or a tighter max_distance."
268
- )
269
- if len(counts) < min_group_fraction * n:
270
- raise ValueError(
271
- f"Degenerate grouping: {len(counts)} groups for {n} images "
272
- f"(< {100 * min_group_fraction:.0f}%). The hash is over-merging."
273
- )
274
- return stats
275
-
276
-
277
- def stratified_group_split(
278
- labels: list[int],
279
- groups: list[str] | None,
280
- *,
281
- n_splits: int = 5,
282
- seed: int = 42,
283
- allow_ungrouped: bool = False,
284
- ) -> tuple[list[np.ndarray], str]:
285
- """Stratified k-fold that keeps each group whole.
286
-
287
- Args:
288
- labels: class label per sample, used for stratification.
289
- groups: grouping key per sample. `None` or all-empty means no grouping
290
- information exists.
291
- allow_ungrouped: must be set explicitly to proceed without groups. This
292
- is a guard rail: falling back silently is the bug this module exists
293
- to prevent.
294
-
295
- Returns:
296
- (fold_indices, strategy) where fold_indices[k] holds the *test* indices
297
- of fold k, and strategy is a `SplitStrategy` constant to record.
298
-
299
- Raises:
300
- ValueError: if grouping is absent and `allow_ungrouped` is False.
301
- """
302
- from sklearn.model_selection import StratifiedGroupKFold, StratifiedKFold
303
-
304
- labels_arr = np.asarray(labels)
305
- has_groups = groups is not None and any(g for g in groups)
306
-
307
- if not has_groups:
308
- if not allow_ungrouped:
309
- raise ValueError(
310
- "No grouping information supplied. A plain image-level split can leak "
311
- "the same patient across folds and silently inflate every metric.\n"
312
- "Either provide `groups` (from patient ids, or from "
313
- "perceptual_hash_groups()), or pass allow_ungrouped=True to accept "
314
- "the risk explicitly and declare it when reporting results."
315
- )
316
- warnings.warn(
317
- "Splitting at IMAGE level with no grouping. If this dataset contains "
318
- "multiple images per patient, results will be optimistically biased. "
319
- "Record SplitStrategy.IMAGE_LEVEL alongside any metric derived from it.",
320
- UserWarning,
321
- stacklevel=2,
322
- )
323
- splitter = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=seed)
324
- folds = [test for _, test in splitter.split(np.zeros(len(labels_arr)), labels_arr)]
325
- return folds, SplitStrategy.IMAGE_LEVEL
326
-
327
- groups_arr = np.asarray(groups)
328
- splitter = StratifiedGroupKFold(n_splits=n_splits, shuffle=True, random_state=seed)
329
- folds = [test for _, test in splitter.split(np.zeros(len(labels_arr)), labels_arr, groups_arr)]
330
- return folds, SplitStrategy.TRUE_GROUPS
331
-
332
-
333
- def assert_no_group_leakage(folds: list[np.ndarray], groups: list[str]) -> None:
334
- """Raise if any group appears in more than one fold.
335
-
336
- Call this after splitting, every time. It is cheap, and it converts the most
337
- expensive silent bug in this literature into a loud, immediate failure.
338
- """
339
- seen: dict[str, int] = {}
340
- for fold_idx, indices in enumerate(folds):
341
- for i in indices:
342
- gid = groups[i]
343
- if not gid:
344
- continue
345
- prev = seen.setdefault(gid, fold_idx)
346
- if prev != fold_idx:
347
- raise AssertionError(
348
- f"Group leakage: group {gid!r} appears in folds {prev} and {fold_idx}."
349
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/enhance/__init__.py DELETED
File without changes
src/src/drdetect/enhance/preprocessing.py DELETED
@@ -1,131 +0,0 @@
1
- """Fundus preprocessing primitives.
2
-
3
- The pipeline order matters and is not arbitrary:
4
-
5
- crop_from_gray -> circle_crop -> ben_graham -> (CLAHE) -> resize
6
-
7
- `crop_from_gray` first, because the black surround otherwise drags down every
8
- subsequent statistic (the blur in `ben_graham`, the CLAHE histogram, the
9
- illumination estimate). `ben_graham` before `resize`, because it is defined
10
- relative to image radius and is cheapest to reason about at native scale.
11
-
12
- Reference: Ben Graham's winning solution, Kaggle Diabetic Retinopathy 2015.
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- import cv2
18
- import numpy as np
19
-
20
- __all__ = [
21
- "crop_from_gray",
22
- "circle_crop",
23
- "ben_graham",
24
- "apply_clahe",
25
- "preprocess",
26
- ]
27
-
28
-
29
- def crop_from_gray(image: np.ndarray, tol: int = 7) -> np.ndarray:
30
- """Trim the uninformative dark border around a fundus photograph.
31
-
32
- Fundus cameras produce a bright circular retina on a black rectangle. The
33
- black region carries no signal but skews global statistics, so it goes first.
34
-
35
- Args:
36
- image: HxWx3 uint8 RGB, or HxW uint8 grayscale.
37
- tol: Intensity at or below which a pixel counts as background.
38
-
39
- Returns:
40
- The tightest crop containing above-threshold content. Returns the input
41
- unchanged if the mask is empty (a fully dark frame), rather than raising
42
- -- an all-dark image is a *quality* failure and is Stage 1's business,
43
- not this function's.
44
- """
45
- if image.ndim == 2:
46
- mask = image > tol
47
- if not mask.any():
48
- return image
49
- return image[np.ix_(mask.any(1), mask.any(0))]
50
-
51
- gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
52
- mask = gray > tol
53
- if not mask.any():
54
- return image
55
-
56
- rows, cols = mask.any(1), mask.any(0)
57
- return image[np.ix_(rows, cols)]
58
-
59
-
60
- def circle_crop(image: np.ndarray) -> np.ndarray:
61
- """Mask everything outside the inscribed circular field of view.
62
-
63
- Standardises the FOV across cameras so the model cannot learn to exploit
64
- corner artefacts that happen to correlate with a particular device.
65
- """
66
- image = crop_from_gray(image)
67
- h, w = image.shape[:2]
68
- cx, cy = w // 2, h // 2
69
- r = min(cx, cy)
70
-
71
- mask = np.zeros((h, w), dtype=np.uint8)
72
- cv2.circle(mask, (cx, cy), r, color=1, thickness=-1)
73
-
74
- out = image * mask[..., None] if image.ndim == 3 else image * mask
75
- return crop_from_gray(out)
76
-
77
-
78
- def ben_graham(image: np.ndarray, sigma_scale: float = 30.0, weight: float = 4.0) -> np.ndarray:
79
- """Subtract local average colour (Ben Graham, Kaggle DR 2015).
80
-
81
- out = weight*img - weight*GaussianBlur(img, sigma) + 128
82
-
83
- The blur sigma is tied to *image radius*, not to a fixed pixel count, which
84
- makes the normalisation scale-invariant -- important because fundus cameras
85
- output wildly different resolutions.
86
-
87
- This removes the between-camera and between-patient variation in overall
88
- illumination and pigmentation while preserving the high-frequency detail
89
- that lesions live in.
90
- """
91
- radius = min(image.shape[:2]) / 2.0
92
- sigma = max(radius / sigma_scale, 1.0)
93
- blurred = cv2.GaussianBlur(image, (0, 0), sigmaX=sigma)
94
- out = cv2.addWeighted(image, weight, blurred, -weight, 128)
95
- return np.clip(out, 0, 255).astype(np.uint8)
96
-
97
-
98
- def apply_clahe(image: np.ndarray, clip_limit: float = 2.0, tile_grid: int = 8) -> np.ndarray:
99
- """Contrast-limited adaptive histogram equalisation on the LAB L channel.
100
-
101
- Operating on L rather than per-RGB-channel avoids the colour casts that
102
- channel-wise CLAHE introduces.
103
-
104
- Note: `clip_limit` is a genuine risk knob. Too high and sensor noise is
105
- amplified into structures that look like microaneurysms. Ablate it; do not
106
- assume 2.0 is right for your camera mix.
107
- """
108
- lab = cv2.cvtColor(image, cv2.COLOR_RGB2LAB)
109
- clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(tile_grid, tile_grid))
110
- lab[..., 0] = clahe.apply(lab[..., 0])
111
- return cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)
112
-
113
-
114
- def preprocess(
115
- image: np.ndarray,
116
- size: int = 512,
117
- use_ben_graham: bool = True,
118
- use_clahe: bool = False,
119
- ) -> np.ndarray:
120
- """Full preprocessing chain, resized to `size` x `size`.
121
-
122
- `use_clahe` defaults to False deliberately: it is a hypothesis to be tested
123
- in the ablation, not a default to be inherited. See docs/01_PROJECT_ANALYSIS.md
124
- section 6, Stage 2.
125
- """
126
- out = circle_crop(image)
127
- if use_ben_graham:
128
- out = ben_graham(out)
129
- if use_clahe:
130
- out = apply_clahe(out)
131
- return cv2.resize(out, (size, size), interpolation=cv2.INTER_AREA)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/eval/__init__.py DELETED
File without changes
src/src/drdetect/eval/metrics.py DELETED
@@ -1,340 +0,0 @@
1
- """Evaluation metrics for DR grading.
2
-
3
- Two things here are easy to get wrong and are therefore made explicit:
4
-
5
- 1. **The referable-DR threshold must be chosen on validation and then frozen.**
6
- Picking it on the test set is the most common silent cheat in this
7
- literature -- it converts "we achieved 90% sensitivity" into "there exists a
8
- threshold at which we would have". `choose_threshold_for_sensitivity` and
9
- `evaluate_at_threshold` are separated so the two steps cannot be conflated.
10
-
11
- 2. **A point estimate without an interval is not a result.** On a few hundred
12
- test images, a reported "91.3% sensitivity" can easily be consistent with
13
- 82%. `bootstrap_ci` exists so no headline number ships bare.
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- from dataclasses import dataclass
19
-
20
- import numpy as np
21
-
22
- __all__ = [
23
- "REFERABLE_THRESHOLD",
24
- "BinaryScores",
25
- "quadratic_weighted_kappa",
26
- "referable_labels",
27
- "binary_scores",
28
- "choose_threshold_for_sensitivity",
29
- "sensitivity_at_specificity_floor",
30
- "youden_j",
31
- "evaluate_at_threshold",
32
- "bootstrap_ci",
33
- "expected_calibration_error",
34
- "DeLongResult",
35
- "delong_roc_variance",
36
- "delong_test",
37
- ]
38
-
39
- # ICDR grade >= 2 (Moderate NPDR) is the clinical referral boundary.
40
- REFERABLE_THRESHOLD = 2
41
-
42
-
43
- @dataclass(frozen=True)
44
- class BinaryScores:
45
- sensitivity: float
46
- specificity: float
47
- ppv: float
48
- npv: float
49
- tp: int
50
- fp: int
51
- tn: int
52
- fn: int
53
-
54
- @property
55
- def n_positive(self) -> int:
56
- return self.tp + self.fn
57
-
58
- @property
59
- def n_negative(self) -> int:
60
- return self.tn + self.fp
61
-
62
-
63
- def quadratic_weighted_kappa(y_true, y_pred, *, n_classes: int = 5) -> float:
64
- """Cohen's kappa with quadratic weights -- the standard DR grading metric.
65
-
66
- Quadratic weighting is what makes this the right metric for an *ordinal*
67
- task: confusing grade 0 with grade 4 is penalised 16x more than confusing 0
68
- with 1, matching the clinical cost structure. Plain accuracy treats both as
69
- a single error.
70
- """
71
- y_true = np.asarray(y_true, dtype=int)
72
- y_pred = np.asarray(y_pred, dtype=int)
73
-
74
- obs = np.zeros((n_classes, n_classes), dtype=float)
75
- for t, p in zip(y_true, y_pred, strict=True):
76
- obs[t, p] += 1
77
-
78
- hist_true = obs.sum(axis=1)
79
- hist_pred = obs.sum(axis=0)
80
- expected = np.outer(hist_true, hist_pred) / max(obs.sum(), 1)
81
-
82
- i, j = np.meshgrid(np.arange(n_classes), np.arange(n_classes), indexing="ij")
83
- weights = ((i - j) ** 2) / ((n_classes - 1) ** 2)
84
-
85
- denom = (weights * expected).sum()
86
- if denom == 0:
87
- return 0.0
88
- return float(1.0 - (weights * obs).sum() / denom)
89
-
90
-
91
- def referable_labels(grades) -> np.ndarray:
92
- """Binarise ICDR grades into referable (>=2) vs not."""
93
- return (np.asarray(grades) >= REFERABLE_THRESHOLD).astype(int)
94
-
95
-
96
- def binary_scores(y_true_binary, y_pred_binary) -> BinaryScores:
97
- y_true = np.asarray(y_true_binary, dtype=int)
98
- y_pred = np.asarray(y_pred_binary, dtype=int)
99
-
100
- tp = int(((y_true == 1) & (y_pred == 1)).sum())
101
- fp = int(((y_true == 0) & (y_pred == 1)).sum())
102
- tn = int(((y_true == 0) & (y_pred == 0)).sum())
103
- fn = int(((y_true == 1) & (y_pred == 0)).sum())
104
-
105
- def safe(num: int, den: int) -> float:
106
- return float(num / den) if den else float("nan")
107
-
108
- return BinaryScores(
109
- sensitivity=safe(tp, tp + fn),
110
- specificity=safe(tn, tn + fp),
111
- ppv=safe(tp, tp + fp),
112
- npv=safe(tn, tn + fn),
113
- tp=tp,
114
- fp=fp,
115
- tn=tn,
116
- fn=fn,
117
- )
118
-
119
-
120
- def choose_threshold_for_sensitivity(
121
- y_true_binary, y_score, *, target_sensitivity: float = 0.90
122
- ) -> float:
123
- """Lowest-specificity-cost threshold meeting a sensitivity floor.
124
-
125
- **Call this on the VALIDATION set only.** The returned threshold is then
126
- frozen and passed to `evaluate_at_threshold` on the test set.
127
-
128
- Screening is deliberately asymmetric: a missed referable case may cost
129
- sight, a false positive costs one ophthalmologist review. So we fix
130
- sensitivity and accept whatever specificity follows, rather than optimising
131
- a symmetric metric like accuracy or Youden's J.
132
- """
133
- y_true = np.asarray(y_true_binary, dtype=int)
134
- y_score = np.asarray(y_score, dtype=float)
135
-
136
- best_threshold, best_specificity = 0.0, -1.0
137
- for threshold in np.unique(np.concatenate([y_score, [0.0, 1.0]])):
138
- scores = binary_scores(y_true, (y_score >= threshold).astype(int))
139
- if scores.sensitivity >= target_sensitivity and scores.specificity > best_specificity:
140
- best_threshold, best_specificity = float(threshold), scores.specificity
141
- return best_threshold
142
-
143
-
144
- def sensitivity_at_specificity_floor(y_true_binary, y_score, *, spec_floor: float = 0.85) -> float:
145
- """Best sensitivity achievable while keeping specificity >= spec_floor.
146
-
147
- Why this exists
148
- ----------------
149
- Plain sensitivity, monitored alone with mode="max", is gameable by a
150
- single degenerate trick: predict referable for everyone. Checked directly
151
- against this project's own training logs, `val/sensitivity_referable`
152
- peaks at the exact epoch every other analysis in Phase 3 identified as the
153
- grade-2 collapse (3 of 4 CE runs checked; the fourth misses by one epoch).
154
- At that epoch sensitivity hits ~1.00 because the model refers almost every
155
- image, not because it discriminates well -- specificity there is ~0.80,
156
- barely above chance for the majority class. See
157
- docs/07_PHASE3_RESULTS.md for the measurement.
158
-
159
- Result 4 already showed a *different* naive fix (macro-recall selection)
160
- fails for the same underlying reason: a single-sided metric cannot
161
- distinguish genuine improvement from a degenerate shortcut.
162
-
163
- This function instead operationalises the project's own stated target
164
- (docs/01_PROJECT_ANALYSIS.md: sensitivity >90%, specificity >85%) directly:
165
- among all thresholds achieving specificity >= spec_floor on this data,
166
- return the highest sensitivity. If no threshold clears the floor, return
167
- `best_specificity - 1.0` -- a value guaranteed to be negative, so any
168
- epoch that DOES clear the floor always outranks one that does not, and
169
- epochs that all fail the floor are still ordered by how close they came.
170
-
171
- **Selection use only.** As with every other `val/*` metric monitored
172
- during training, this sweeps thresholds on the validation split purely to
173
- choose which epoch's weights to keep -- the same role val/qwk already
174
- plays. It is not the reported clinical operating point; that threshold is
175
- chosen once, on validation, and frozen before scoring the locked test set
176
- in `scripts/evaluate.py`, per the module-level docstring above.
177
- """
178
- y_true = np.asarray(y_true_binary, dtype=int)
179
- y_score = np.asarray(y_score, dtype=float)
180
-
181
- best_sensitivity = -1.0
182
- best_specificity_overall = -1.0
183
- for threshold in np.unique(np.concatenate([y_score, [0.0, 1.0]])):
184
- scores = binary_scores(y_true, (y_score >= threshold).astype(int))
185
- if scores.specificity > best_specificity_overall:
186
- best_specificity_overall = scores.specificity
187
- if scores.specificity >= spec_floor and scores.sensitivity > best_sensitivity:
188
- best_sensitivity = scores.sensitivity
189
-
190
- if best_sensitivity >= 0.0:
191
- return float(best_sensitivity)
192
- return float(best_specificity_overall - 1.0)
193
-
194
-
195
- def youden_j(y_true_binary, y_score, threshold: float = 0.5) -> float:
196
- """Youden's J = sensitivity + specificity - 1, at a fixed threshold.
197
-
198
- Logged alongside sensitivity_at_specificity_floor purely for comparison in
199
- the metrics CSV -- it is the standard symmetric alternative, included so a
200
- later reader can see both without re-deriving them. Not used for selection
201
- here: unlike the floor-constrained metric, J does not target this
202
- project's stated specificity requirement and can settle on a point that
203
- satisfies neither the sensitivity nor the specificity bar.
204
- """
205
- scores = binary_scores(y_true_binary, (np.asarray(y_score) >= threshold).astype(int))
206
- return float(scores.sensitivity + scores.specificity - 1.0)
207
-
208
-
209
- def evaluate_at_threshold(y_true_binary, y_score, threshold: float) -> BinaryScores:
210
- """Apply a threshold frozen from validation. No tuning happens here."""
211
- return binary_scores(y_true_binary, (np.asarray(y_score) >= threshold).astype(int))
212
-
213
-
214
- def bootstrap_ci(
215
- metric_fn,
216
- *arrays,
217
- n_resamples: int = 2000,
218
- confidence: float = 0.95,
219
- seed: int = 42,
220
- ) -> tuple[float, float, float]:
221
- """Percentile bootstrap CI for any metric over paired arrays.
222
-
223
- Returns (point_estimate, lower, upper). Resamples cases (not predictions),
224
- which is the correct unit -- the uncertainty being quantified is "which
225
- patients happened to be in this test set".
226
- """
227
- arrays = [np.asarray(a) for a in arrays]
228
- n = len(arrays[0])
229
- if any(len(a) != n for a in arrays):
230
- raise ValueError("all arrays must have the same length")
231
-
232
- rng = np.random.default_rng(seed)
233
- point = float(metric_fn(*arrays))
234
-
235
- stats = np.empty(n_resamples, dtype=float)
236
- for b in range(n_resamples):
237
- idx = rng.integers(0, n, size=n)
238
- stats[b] = metric_fn(*[a[idx] for a in arrays])
239
-
240
- stats = stats[np.isfinite(stats)]
241
- if stats.size == 0:
242
- return point, float("nan"), float("nan")
243
-
244
- alpha = (1.0 - confidence) / 2.0
245
- return point, float(np.quantile(stats, alpha)), float(np.quantile(stats, 1 - alpha))
246
-
247
-
248
- @dataclass(frozen=True)
249
- class DeLongResult:
250
- auc_a: float
251
- auc_b: float
252
- z: float
253
- p: float
254
-
255
-
256
- def _delong_structural_components(
257
- pos_scores: np.ndarray, neg_scores: np.ndarray
258
- ) -> tuple[np.ndarray, np.ndarray]:
259
- """Placement values per DeLong et al. (1988) / Sun & Xu (2014): psi(x, y) =
260
- 1 if x>y, 0.5 if x==y, 0 if x<y, averaged over the other group. Their means
261
- both equal AUC; their sample variances/covariances give AUC's variance
262
- without ever forming the full O(n_pos * n_neg) U-statistic sum by hand."""
263
- diff = pos_scores[:, None] - neg_scores[None, :]
264
- psi = np.where(diff > 0, 1.0, np.where(diff == 0, 0.5, 0.0))
265
- return psi.mean(axis=1), psi.mean(axis=0)
266
-
267
-
268
- def delong_roc_variance(y_true_binary, y_score) -> tuple[float, float]:
269
- """AUC and its DeLong variance for one classifier. Mainly a building block
270
- for `delong_test`; exposed because reporting AUC's own CI (not just a
271
- difference) is sometimes wanted on its own."""
272
- y_true = np.asarray(y_true_binary, dtype=int)
273
- y_score = np.asarray(y_score, dtype=float)
274
- v10, v01 = _delong_structural_components(y_score[y_true == 1], y_score[y_true == 0])
275
- auc = float(v10.mean())
276
- var = float(v10.var(ddof=1) / len(v10) + v01.var(ddof=1) / len(v01))
277
- return auc, var
278
-
279
-
280
- def delong_test(y_true_binary, score_a, score_b) -> DeLongResult:
281
- """Paired DeLong test: are two classifiers' AUCs different, on the SAME
282
- sample? (DeLong, DeLong & Clarke-Pearson, 1988; fast form: Sun & Xu, 2014.)
283
-
284
- Why paired, not two independent bootstrap CIs
285
- ----------------------------------------------
286
- `score_a` and `score_b` are two models' scores for the *same* patients.
287
- Comparing two independent-looking AUC confidence intervals for overlap is
288
- not a significance test and is typically conservative: it ignores that
289
- both models get the same cases right or wrong for the same reasons (the
290
- same hard patients), which is exactly the correlation McNemar's test
291
- exploits for paired sensitivity/specificity elsewhere in this project
292
- (`scripts/compare.py`). DeLong's test is the AUC-scale analogue: it uses
293
- the covariance between the two classifiers' per-patient placement values,
294
- not just their marginal variances.
295
- """
296
- y_true = np.asarray(y_true_binary, dtype=int)
297
- score_a = np.asarray(score_a, dtype=float)
298
- score_b = np.asarray(score_b, dtype=float)
299
- pos, neg = y_true == 1, y_true == 0
300
-
301
- v10_a, v01_a = _delong_structural_components(score_a[pos], score_a[neg])
302
- v10_b, v01_b = _delong_structural_components(score_b[pos], score_b[neg])
303
- auc_a, auc_b = float(v10_a.mean()), float(v10_b.mean())
304
-
305
- s10 = np.cov(np.vstack([v10_a, v10_b]))
306
- s01 = np.cov(np.vstack([v01_a, v01_b]))
307
- s = s10 / len(v10_a) + s01 / len(v01_a)
308
-
309
- var_d = float(s[0, 0] + s[1, 1] - 2 * s[0, 1])
310
- if var_d <= 0:
311
- # Degenerate only when both classifiers agree on every placement
312
- # (identical scores, or one/zero cases in a class) -- there is no
313
- # detectable difference to test.
314
- return DeLongResult(auc_a, auc_b, 0.0, 1.0)
315
-
316
- from scipy import stats as scipy_stats
317
-
318
- z = (auc_a - auc_b) / np.sqrt(var_d)
319
- p = float(2 * (1 - scipy_stats.norm.cdf(abs(z))))
320
- return DeLongResult(auc_a, auc_b, float(z), p)
321
-
322
-
323
- def expected_calibration_error(y_true_binary, y_prob, *, n_bins: int = 15) -> float:
324
- """ECE: mean |confidence - accuracy| over equal-width probability bins.
325
-
326
- Modern networks are systematically overconfident (Guo et al., 2017), so raw
327
- softmax output is not a probability. This is the number that must fall after
328
- temperature scaling in Phase 5.
329
- """
330
- y_true = np.asarray(y_true_binary, dtype=int)
331
- y_prob = np.asarray(y_prob, dtype=float)
332
-
333
- edges = np.linspace(0.0, 1.0, n_bins + 1)
334
- ece = 0.0
335
- for lo, hi in zip(edges[:-1], edges[1:], strict=True):
336
- mask = (y_prob > lo) & (y_prob <= hi) if lo > 0 else (y_prob >= lo) & (y_prob <= hi)
337
- if not mask.any():
338
- continue
339
- ece += mask.mean() * abs(y_prob[mask].mean() - y_true[mask].mean())
340
- return float(ece)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/explain/__init__.py DELETED
File without changes
src/src/drdetect/explain/cam_variants.py DELETED
@@ -1,69 +0,0 @@
1
- """Four CAM methods, one interface, so they can be compared rather than assumed equivalent.
2
-
3
- docs/04_ROADMAP.md Phase 6 calls for Grad-CAM, Grad-CAM++, Score-CAM and
4
- Eigen-CAM "side by side" -- comparing them requires calling them the same
5
- way. Phase 2 shipped only Grad-CAM (drdetect.explain.gradcam); this module
6
- adds the other three without duplicating the eval-mode/target-layer handling
7
- already correct there.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import torch
13
- import torch.nn as nn
14
-
15
- from drdetect.explain.gradcam import default_target_layer
16
-
17
- __all__ = ["CAM_METHODS", "generate_cam_variant"]
18
-
19
- CAM_METHODS = ("gradcam", "gradcam++", "scorecam", "eigencam")
20
-
21
-
22
- def _cam_class(name: str):
23
- from pytorch_grad_cam import EigenCAM, GradCAM, GradCAMPlusPlus, ScoreCAM
24
-
25
- return {
26
- "gradcam": GradCAM,
27
- "gradcam++": GradCAMPlusPlus,
28
- "scorecam": ScoreCAM,
29
- "eigencam": EigenCAM,
30
- }[name]
31
-
32
-
33
- def generate_cam_variant(
34
- method: str,
35
- model: nn.Module,
36
- input_tensor: torch.Tensor,
37
- target_class: int,
38
- target_layer: nn.Module | None = None,
39
- ):
40
- """Grayscale heatmap in [0, 1] from the named method.
41
-
42
- Args:
43
- method: one of CAM_METHODS.
44
- target_class: explain the PREDICTED class -- see drdetect.explain.gradcam.generate_cam.
45
-
46
- EigenCAM and Score-CAM do not use gradients (Eigen-CAM is a pure
47
- activation decomposition; Score-CAM perturbs the input and reads off
48
- softmax scores), so they are computed under `torch.no_grad()`, unlike
49
- Grad-CAM/Grad-CAM++ which require gradients to reach the target layer.
50
- """
51
- from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget
52
-
53
- if method not in CAM_METHODS:
54
- raise ValueError(f"unknown CAM method {method!r}; expected one of {CAM_METHODS}")
55
-
56
- layer = target_layer or default_target_layer(model)
57
- cam_cls = _cam_class(method)
58
- needs_grad = method in ("gradcam", "gradcam++")
59
-
60
- was_training = model.training
61
- model.eval()
62
- try:
63
- with torch.set_grad_enabled(needs_grad), cam_cls(model=model, target_layers=[layer]) as cam:
64
- grayscale_cam = cam(
65
- input_tensor=input_tensor, targets=[ClassifierOutputTarget(target_class)]
66
- )
67
- return grayscale_cam[0]
68
- finally:
69
- model.train(was_training)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/explain/evidence.py DELETED
@@ -1,175 +0,0 @@
1
- """ICDR evidence table + templated natural-language rationale, and outline-based
2
- lesion-overlay rendering for the final report (docs/04_ROADMAP.md, Phase 6).
3
-
4
- Reuses `drdetect.fusion.features`'s model-running internals (the same tiled
5
- segmentation inference, OD/fovea localisation, and microaneurysm candidate
6
- scoring already built and evaluated for Phase 5's fusion head) rather than
7
- re-implementing lesion detection a second time -- this module only adds the
8
- per-instance/per-quadrant structure a human-readable report needs, on top of
9
- the aggregate scalar features Phase 5's fusion head consumes.
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- from pathlib import Path
15
-
16
- import cv2
17
- import numpy as np
18
-
19
- from drdetect.fusion.features import (
20
- LOCALIZATION_SIZE,
21
- LesionModels,
22
- _predict_od_fovea,
23
- _tile_predict,
24
- lesion_centroids,
25
- )
26
- from drdetect.segmentation.microaneurysms import score_candidates
27
- from drdetect.segmentation.quadrants import assign_quadrant
28
-
29
- __all__ = [
30
- "extract_lesion_evidence",
31
- "render_lesion_overlay",
32
- "build_icdr_rationale",
33
- "LESION_COLORS_RGB",
34
- ]
35
-
36
- _SEGMENTED_LESIONS = ["hard_exudates", "soft_exudates", "haemorrhages"]
37
- # RGB, since render_lesion_overlay draws on an RGB array throughout this module.
38
- LESION_COLORS_RGB = {
39
- "hard_exudates": (255, 215, 0), # amber
40
- "soft_exudates": (0, 191, 255), # light blue
41
- "haemorrhages": (255, 0, 0), # red
42
- "microaneurysms": (255, 0, 255), # magenta
43
- }
44
-
45
-
46
- def extract_lesion_evidence(image_path: str | Path, models: LesionModels) -> dict:
47
- """Per-lesion-type instance list (quadrant + size) and binary masks for
48
- rendering, from the same models `drdetect.fusion.features` uses for the
49
- fusion head's aggregate scalars."""
50
- image_bgr = cv2.imread(str(image_path), cv2.IMREAD_COLOR)
51
- image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
52
-
53
- od_xy, fovea_xy, _diameter = _predict_od_fovea(image_rgb, models)
54
- scale_x = LOCALIZATION_SIZE[1] / image_rgb.shape[1]
55
- scale_y = LOCALIZATION_SIZE[0] / image_rgb.shape[0]
56
-
57
- lesions: dict[str, dict] = {}
58
- masks: dict[str, np.ndarray] = {}
59
- for lesion_type in _SEGMENTED_LESIONS:
60
- module = getattr(models, lesion_type)
61
- prob_map = _tile_predict(module, image_rgb, models.device)
62
- binary_mask = prob_map > 0.5
63
- masks[lesion_type] = binary_mask
64
- centroids = lesion_centroids(prob_map, threshold=0.5)
65
- instances = [
66
- {"quadrant": assign_quadrant((cx * scale_x, cy * scale_y), od_xy, fovea_xy)}
67
- for cx, cy in centroids
68
- ]
69
- lesions[lesion_type] = {
70
- "count": len(instances),
71
- "area_pct": float(binary_mask.mean() * 100.0),
72
- "instances": instances,
73
- }
74
-
75
- dummy_mask = np.zeros(image_bgr.shape[:2], dtype=np.uint8)
76
- ma_result = score_candidates(
77
- image_bgr, dummy_mask, models.microaneurysm_classifier.model, device=str(models.device)
78
- )
79
- accepted = ma_result["scores"] > 0.95
80
- ma_instances = [
81
- {
82
- "quadrant": assign_quadrant((c.cx * scale_x, c.cy * scale_y), od_xy, fovea_xy),
83
- "xy": (float(c.cx), float(c.cy)),
84
- }
85
- for c, keep in zip(ma_result["candidates"], accepted, strict=True)
86
- if keep
87
- ]
88
- lesions["microaneurysms"] = {"count": len(ma_instances), "instances": ma_instances}
89
-
90
- return {"od_xy": od_xy, "fovea_xy": fovea_xy, "lesions": lesions, "masks": masks}
91
-
92
-
93
- def render_lesion_overlay(image_rgb: np.ndarray, evidence: dict) -> np.ndarray:
94
- """Outline contours, not filled blobs -- the roadmap's own stated reason:
95
- outlines beat blobs for clinical legibility (a filled overlay hides the
96
- tissue underneath it; an outline points at it without obscuring it).
97
-
98
- Microaneurysms are marked with small circles rather than outlines --
99
- they're candidate *points* from the classifier (drdetect.segmentation.
100
- microaneurysms), not a segmented region, so there's no contour to draw.
101
- """
102
- overlay = image_rgb.copy()
103
- for lesion_type, mask in evidence["masks"].items():
104
- color = LESION_COLORS_RGB[lesion_type]
105
- contours, _ = cv2.findContours(
106
- mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
107
- )
108
- cv2.drawContours(overlay, contours, -1, color, thickness=3)
109
-
110
- ma_color = LESION_COLORS_RGB["microaneurysms"]
111
- for inst in evidence["lesions"].get("microaneurysms", {}).get("instances", []):
112
- x, y = inst["xy"]
113
- cv2.circle(overlay, (int(round(x)), int(round(y))), radius=12, color=ma_color, thickness=2)
114
-
115
- return overlay
116
-
117
-
118
- _ICDR_GRADE_NAMES = {
119
- 0: "No apparent DR",
120
- 1: "Mild NPDR",
121
- 2: "Moderate NPDR",
122
- 3: "Severe NPDR",
123
- 4: "Proliferative DR",
124
- }
125
-
126
-
127
- def build_icdr_rationale(grade: int, evidence: dict) -> str:
128
- """A templated sentence grounding the predicted ICDR grade in the actual
129
- detected evidence -- not a restatement of the grade, an explanation of
130
- what supports it (or, honestly, what doesn't fully support it).
131
-
132
- Neovascularisation (the feature that actually defines grade 4, PDR) has
133
- no segmentation model in this project (docs/01's own scope-honesty
134
- note: "neovascularisation has no public pixel masks... document as
135
- future work, do not fabricate it") -- a grade-4 rationale says so
136
- explicitly rather than inventing NV evidence this pipeline cannot see.
137
- """
138
- lesions = evidence["lesions"]
139
- ma_n = lesions.get("microaneurysms", {}).get("count", 0)
140
- he_n = lesions.get("haemorrhages", {}).get("count", 0)
141
- ex_pct = lesions.get("hard_exudates", {}).get("area_pct", 0.0)
142
- se_pct = lesions.get("soft_exudates", {}).get("area_pct", 0.0)
143
-
144
- quadrants_with_he = {
145
- inst["quadrant"] for inst in lesions.get("haemorrhages", {}).get("instances", [])
146
- }
147
-
148
- evidence_parts = []
149
- if ma_n:
150
- evidence_parts.append(f"{ma_n} microaneurysm candidate(s)")
151
- if he_n:
152
- evidence_parts.append(
153
- f"{he_n} haemorrhage region(s) across {len(quadrants_with_he)} quadrant(s)"
154
- )
155
- if ex_pct > 0.01:
156
- evidence_parts.append(f"hard exudates covering {ex_pct:.2f}% of the image")
157
- if se_pct > 0.01:
158
- evidence_parts.append(
159
- f"soft exudates (cotton-wool spots) covering {se_pct:.2f}% of the image"
160
- )
161
-
162
- grade_name = _ICDR_GRADE_NAMES.get(grade, f"grade {grade}")
163
- if not evidence_parts:
164
- return f"ICDR grade {grade} ({grade_name}): no significant lesion evidence detected."
165
-
166
- body = "; ".join(evidence_parts)
167
- note = ""
168
- if grade == 4:
169
- note = (
170
- " Note: proliferative DR (grade 4) is defined by neovascularisation, which this "
171
- "pipeline does not detect (no public pixel masks exist to train against) -- this "
172
- "grade is the classifier's own image-level prediction, not evidence-backed for the "
173
- "specific feature that defines it."
174
- )
175
- return f"ICDR grade {grade} ({grade_name}): {body}.{note}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/explain/gradcam.py DELETED
@@ -1,97 +0,0 @@
1
- """Grad-CAM evidence overlay for the DR grader.
2
-
3
- Thin wrapper around `pytorch_grad_cam` rather than a hand-rolled hook, so the
4
- CAM implementation matches a maintained, widely-checked reference rather than
5
- a bespoke one. Phase 6 (docs/04_ROADMAP.md) is where CAM variants get compared
6
- side by side and run through Adebayo's sanity checks -- this module is only
7
- the single Grad-CAM used for Phase 2's end-to-end slice, not that comparison.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import cv2
13
- import numpy as np
14
- import torch
15
- import torch.nn as nn
16
-
17
- __all__ = ["default_target_layer", "generate_cam", "overlay_cam"]
18
-
19
-
20
- def default_target_layer(model: nn.Module) -> nn.Module:
21
- """The last activation before global pooling, for a timm EfficientNet.
22
-
23
- `bn2`, not `conv_head`. timm's EfficientNet fuses BatchNorm+SiLU into a
24
- single `BatchNormAct2d` module at `bn2`, so `conv_head`'s own output is
25
- the bare pre-activation conv -- unbounded, and roughly half-negative in
26
- practice. Grad-CAM/Grad-CAM++/Eigen-CAM mostly tolerate that (their
27
- weights can themselves be negative and partially cancel it out), but
28
- Score-CAM's weights are a softmax over class scores -- always positive --
29
- so weighting an already-negative activation map and then ReLU-ing the
30
- result (`base_cam.py`'s `compute_cam_per_layer`) can zero it out
31
- entirely. Measured directly: `conv_head` produced an all-zero Score-CAM
32
- heatmap on a real image; `bn2`, whose output has already passed through
33
- SiLU, did not. `bn2` is the correct choice for every method, not a
34
- Score-CAM-specific workaround -- it's simply the last point where the
35
- activation reflects what the network actually forward-propagates.
36
- """
37
- backbone = getattr(model, "backbone", model)
38
- if not hasattr(backbone, "bn2"):
39
- raise AttributeError(
40
- f"{type(backbone).__name__} has no `bn2`; pass target_layer explicitly."
41
- )
42
- return backbone.bn2
43
-
44
-
45
- def generate_cam(
46
- model: nn.Module,
47
- input_tensor: torch.Tensor,
48
- target_class: int,
49
- target_layer: nn.Module | None = None,
50
- ) -> np.ndarray:
51
- """Grad-CAM heatmap for one image, as a single HxW array in [0, 1].
52
-
53
- Args:
54
- model: the grader, in eval mode. Frozen BatchNorm does not interfere
55
- -- Grad-CAM only needs gradients w.r.t. activations, never an
56
- optimiser step.
57
- input_tensor: 1xCxHxW, already normalised exactly as at inference.
58
- target_class: which class's score to explain. For a grading report
59
- this should be the PREDICTED class, not the true label -- the
60
- report is explaining what the model said, not grading the CAM.
61
- target_layer: defaults to `default_target_layer(model)`.
62
- """
63
- from pytorch_grad_cam import GradCAM
64
- from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget
65
-
66
- layer = target_layer or default_target_layer(model)
67
- was_training = model.training
68
- model.eval()
69
- try:
70
- with GradCAM(model=model, target_layers=[layer]) as cam:
71
- grayscale_cam = cam(
72
- input_tensor=input_tensor, targets=[ClassifierOutputTarget(target_class)]
73
- )
74
- return grayscale_cam[0]
75
- finally:
76
- model.train(was_training)
77
-
78
-
79
- def overlay_cam(
80
- rgb_image: np.ndarray, grayscale_cam: np.ndarray, alpha: float = 0.45
81
- ) -> np.ndarray:
82
- """Blend a Grad-CAM heatmap onto its source image.
83
-
84
- Args:
85
- rgb_image: HxWx3 uint8, the exact image the CAM was computed on
86
- (post-preprocessing) -- overlaying on a differently-cropped raw
87
- image would misalign the heatmap with the lesions it is marking.
88
- grayscale_cam: output of `generate_cam`, same H and W as `rgb_image`.
89
- alpha: heatmap opacity.
90
- """
91
- from pytorch_grad_cam.utils.image import show_cam_on_image
92
-
93
- if grayscale_cam.shape[:2] != rgb_image.shape[:2]:
94
- grayscale_cam = cv2.resize(grayscale_cam, (rgb_image.shape[1], rgb_image.shape[0]))
95
- float_rgb = rgb_image.astype(np.float32) / 255.0
96
- overlay = show_cam_on_image(float_rgb, grayscale_cam, use_rgb=True, image_weight=1 - alpha)
97
- return overlay
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/explain/localization_metrics.py DELETED
@@ -1,55 +0,0 @@
1
- """CAM-vs-ground-truth localisation quality: pointing game + IoU.
2
-
3
- Phase 6's own exit criterion (docs/04_ROADMAP.md): "a table of saliency
4
- methods x (sanity-check pass/fail, pointing-game accuracy, IoU)". This
5
- module computes the pointing-game and IoU halves of that table, against
6
- IDRiD's real per-lesion-type pixel masks -- the sanity-check half already
7
- exists in drdetect.explain.sanity_checks (docs/08_PHASE6_RESULTS.md).
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import cv2
13
- import numpy as np
14
-
15
- __all__ = ["upsample_cam", "pointing_game_hit", "cam_mask_iou"]
16
-
17
-
18
- def upsample_cam(cam: np.ndarray, target_shape: tuple[int, int]) -> np.ndarray:
19
- """Resize a CAM (native resolution: the model's input size, e.g. 512x512)
20
- up to `target_shape` (height, width) -- typically a lesion mask's own
21
- full resolution. A CAM is already a smooth, interpolated heatmap, so
22
- upsampling it preserves the mask's own pixel precision, rather than
23
- downsampling the mask and losing the smallest lesions -- microaneurysms
24
- are already known, from this project's own Phase 3 findings, to vanish
25
- below ~1024px."""
26
- h, w = target_shape
27
- return cv2.resize(cam, (w, h), interpolation=cv2.INTER_LINEAR)
28
-
29
-
30
- def pointing_game_hit(cam: np.ndarray, mask: np.ndarray) -> bool:
31
- """True if the CAM's single point of maximum attention falls on a
32
- positive mask pixel -- the standard pointing-game definition (Zhang et
33
- al. 2018), threshold-free by construction."""
34
- y, x = np.unravel_index(np.argmax(cam), cam.shape)
35
- return bool(mask[y, x] > 0)
36
-
37
-
38
- def cam_mask_iou(cam: np.ndarray, mask: np.ndarray, threshold: float = 0.5) -> float:
39
- """IoU between the CAM thresholded at a fixed value and the binary
40
- lesion mask.
41
-
42
- Expect this to read low for genuinely sparse lesion types
43
- (haemorrhages and microaneurysms are well under 0.1% of image pixels,
44
- see drdetect.segmentation.metrics) even for a CAM correctly pointing at
45
- the right general region -- Grad-CAM produces a smooth, diffuse
46
- attention region by construction, not a pixel-precise segmentation, so
47
- IoU against a sparse mask is a much harder bar than the pointing game.
48
- NaN, not 0, when the union is empty (both CAM-positive and mask are
49
- empty) -- 0 would misrepresent "nothing to compare" as "total miss".
50
- """
51
- binary_cam = cam > threshold
52
- binary_mask = mask.astype(bool)
53
- intersection = np.logical_and(binary_cam, binary_mask).sum()
54
- union = np.logical_or(binary_cam, binary_mask).sum()
55
- return float(intersection / union) if union > 0 else float("nan")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/explain/sanity_checks.py DELETED
@@ -1,157 +0,0 @@
1
- """Adebayo-style sanity checks for saliency methods.
2
-
3
- Adebayo et al. 2018 (NeurIPS), "Sanity Checks for Saliency Maps": several
4
- widely-used saliency methods are empirically insensitive to the model's
5
- learned parameters -- they produce nearly the same map whether the model is
6
- trained or has its weights replaced with noise. A method that fails this is
7
- not explaining the MODEL; it behaves closer to an edge detector applied to
8
- the input, dressed up as an explanation. Nobody knew whether that applied
9
- here until it was checked -- this project's own culture treats an unchecked
10
- assumption as no different from a wrong one.
11
-
12
- Two checks, both from the paper:
13
-
14
- Model parameter randomisation (`model_randomization_test`) -- cascading:
15
- progressively randomise the model from the OUTPUT layer backward
16
- toward the input, and measure how much each CAM method's heatmap
17
- changes at each step, against its own original heatmap. A method that
18
- PASSES should diverge quickly; a method whose heatmap barely moves as
19
- the whole model turns to noise is not using what the model learned.
20
-
21
- Data randomisation (`compare_against_shuffled_labels`) -- compare CAMs
22
- from the real trained model against CAMs from an otherwise-identical
23
- model trained on the SAME images with shuffled labels. A method that
24
- PASSES should look different between the two, since the two models
25
- learned genuinely different things; a method that looks similar
26
- regardless of what the underlying model actually learned is not
27
- sensitive to that.
28
-
29
- Similarity is reported as both Spearman rank correlation and SSIM, matching
30
- the two metrics the original paper used -- rank correlation alone can be
31
- fooled by a saliency map that reorders pixels similarly but at a different
32
- spatial scale, which SSIM is sensitive to and rank correlation is not.
33
- """
34
-
35
- from __future__ import annotations
36
-
37
- import copy
38
- from dataclasses import dataclass
39
-
40
- import numpy as np
41
- import torch
42
- import torch.nn as nn
43
-
44
- from drdetect.explain.cam_variants import CAM_METHODS, generate_cam_variant
45
-
46
- __all__ = [
47
- "CascadeStep",
48
- "cam_similarity",
49
- "cascading_randomization_layers",
50
- "model_randomization_test",
51
- "compare_against_shuffled_labels",
52
- ]
53
-
54
-
55
- @dataclass(frozen=True)
56
- class CascadeStep:
57
- layer_randomized: str
58
- spearman: dict[str, float]
59
- ssim: dict[str, float]
60
-
61
-
62
- def cam_similarity(cam_a: np.ndarray, cam_b: np.ndarray) -> tuple[float, float]:
63
- """(Spearman rank correlation, SSIM) between two same-shape grayscale CAMs."""
64
- from scipy.stats import spearmanr
65
- from skimage.metrics import structural_similarity
66
-
67
- rho = float(spearmanr(cam_a.ravel(), cam_b.ravel()).statistic)
68
- ssim = float(structural_similarity(cam_a, cam_b, data_range=1.0))
69
- return rho, ssim
70
-
71
-
72
- def cascading_randomization_layers(backbone: nn.Module) -> list[tuple[str, list[nn.Module]]]:
73
- """Named layer groups of a timm EfficientNet backbone, OUTPUT-to-INPUT order.
74
-
75
- This order is the point: Adebayo's cascading test randomises from the
76
- output backward, one layer group at a time, so that at step k the model
77
- is "the real model, except everything from the output back through
78
- group k is now noise." Reversed (input-to-output) would be a different,
79
- weaker test -- early layers dominate less of what a CAM at `bn2`
80
- (drdetect.explain.gradcam.default_target_layer) actually reflects.
81
- """
82
- layers = [
83
- ("classifier", [backbone.classifier]),
84
- ("conv_head+bn2", [backbone.conv_head, backbone.bn2]),
85
- ]
86
- for i in reversed(range(len(backbone.blocks))):
87
- layers.append((f"blocks[{i}]", [backbone.blocks[i]]))
88
- layers.append(("conv_stem+bn1", [backbone.conv_stem, backbone.bn1]))
89
- return layers
90
-
91
-
92
- def _reset_in_place(modules: list[nn.Module]) -> None:
93
- for module in modules:
94
- for child in module.modules():
95
- if hasattr(child, "reset_parameters"):
96
- child.reset_parameters()
97
-
98
-
99
- def model_randomization_test(
100
- model: nn.Module,
101
- input_tensor: torch.Tensor,
102
- target_class: int,
103
- methods: tuple[str, ...] = CAM_METHODS,
104
- ) -> list[CascadeStep]:
105
- """Cascading model-randomisation sanity check across every CAM method at once.
106
-
107
- Works on a deep copy -- the caller's trained model is never mutated.
108
- Every method's CAM is computed against the SAME progressively-randomised
109
- model at each step, so the comparison at a given step is apples to apples
110
- across methods, not just against each method's own baseline.
111
- """
112
- randomized = copy.deepcopy(model)
113
- backbone = getattr(randomized, "backbone", randomized)
114
- layer_groups = cascading_randomization_layers(backbone)
115
-
116
- originals = {
117
- m: generate_cam_variant(m, randomized, input_tensor, target_class) for m in methods
118
- }
119
-
120
- steps = []
121
- for name, modules in layer_groups:
122
- _reset_in_place(modules)
123
- spearman, ssim = {}, {}
124
- for method in methods:
125
- cam = generate_cam_variant(method, randomized, input_tensor, target_class)
126
- rho, s = cam_similarity(originals[method], cam)
127
- spearman[method] = rho
128
- ssim[method] = s
129
- steps.append(CascadeStep(layer_randomized=name, spearman=spearman, ssim=ssim))
130
- return steps
131
-
132
-
133
- def compare_against_shuffled_labels(
134
- real_model: nn.Module,
135
- shuffled_model: nn.Module,
136
- input_tensor: torch.Tensor,
137
- target_class_real: int,
138
- target_class_shuffled: int,
139
- methods: tuple[str, ...] = CAM_METHODS,
140
- ) -> dict[str, tuple[float, float]]:
141
- """Data-randomisation check: CAM(real model) vs CAM(shuffled-label model), same image.
142
-
143
- Args:
144
- target_class_real / target_class_shuffled: each model's OWN predicted
145
- class for this image -- the two models were trained on different
146
- label associations and will usually disagree, and each CAM must
147
- explain what its own model actually predicted.
148
-
149
- Returns: {method: (spearman, ssim)}.
150
- """
151
- return {
152
- method: cam_similarity(
153
- generate_cam_variant(method, real_model, input_tensor, target_class_real),
154
- generate_cam_variant(method, shuffled_model, input_tensor, target_class_shuffled),
155
- )
156
- for method in methods
157
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/fusion/__init__.py DELETED
@@ -1 +0,0 @@
1
- """Phase 5: fusing the grading model's CNN embedding with Phase 4's lesion evidence."""
 
 
src/src/drdetect/fusion/embedding.py DELETED
@@ -1,75 +0,0 @@
1
- """CNN embedding extraction from the canonical grading checkpoint, for
2
- Phase 5's fusion head.
3
-
4
- Uses `baseline_effb0_512_fold0` (QWK 0.8930), not the 1024px checkpoint --
5
- Phase 3's own conclusion was "the baseline stands" (docs/07_PHASE3_RESULTS.md),
6
- and Result 14 retracted the 1024px checkpoint's causal story (its edge over
7
- the baseline is now suspected to be a backend artifact, not resolution).
8
- Building the fusion head on a result the project already walked back would
9
- undercut that finding.
10
-
11
- Reuses the checkpoint-loading convention scripts/evaluate.py and
12
- scripts/calibrate.py already established (strip the Lightning `model.`
13
- prefix, load into a bare `FundusGrader`) rather than inventing a new one.
14
-
15
- **Images come from the Ben-Graham-preprocessed cache
16
- (`data/processed/aptos_512/`), not the raw APTOS files** -- unlike
17
- `drdetect.fusion.features`, which deliberately uses raw images because the
18
- Phase 4 lesion models were trained on IDRiD's un-enhanced originals. The
19
- grading model was trained on the preprocessed cache, so its embedding is
20
- only meaningful computed on the same distribution it actually learned from.
21
- Two different "the right image for this model" answers for two different
22
- models feeding the same fusion vector -- not an inconsistency.
23
- """
24
-
25
- from __future__ import annotations
26
-
27
- from pathlib import Path
28
-
29
- import torch
30
-
31
- from drdetect.data.dataset import build_transforms
32
- from drdetect.grading.model import FundusGrader, build_model
33
-
34
- __all__ = ["load_grading_backbone", "extract_cnn_embedding", "EMBEDDING_DIM"]
35
-
36
- EMBEDDING_DIM = 1280 # efficientnet_b0's pre-classifier feature width
37
-
38
-
39
- def load_grading_backbone(
40
- checkpoint: str, *, backbone: str = "efficientnet_b0", device: torch.device
41
- ) -> FundusGrader:
42
- model = build_model(backbone, num_outputs=5, pretrained=False, freeze_bn=True)
43
- ckpt = torch.load(checkpoint, map_location="cpu", weights_only=False)
44
- state = ckpt.get("state_dict", ckpt)
45
- state = {k.removeprefix("model."): v for k, v in state.items() if k.startswith("model.")}
46
- model.load_state_dict(state)
47
- return model.to(device).eval()
48
-
49
-
50
- def extract_cnn_embedding(
51
- image_path: str | Path, model: FundusGrader, device: torch.device, *, size: int = 512
52
- ) -> torch.Tensor:
53
- """The 1280-d pre-classifier pooled feature (`forward_head(...,
54
- pre_logits=True)`), not the 5-way logits `model(x)` would give."""
55
- embedding, _logits = extract_cnn_embedding_and_logits(image_path, model, device, size=size)
56
- return embedding
57
-
58
-
59
- def extract_cnn_embedding_and_logits(
60
- image_path: str | Path, model: FundusGrader, device: torch.device, *, size: int = 512
61
- ) -> tuple[torch.Tensor, torch.Tensor]:
62
- """Both the fusion head's input (the embedding) and the baseline
63
- grading-only prediction (the 5-way logits) from one forward pass --
64
- used when comparing fusion against grading-alone on the same images, so
65
- the baseline's own prediction doesn't need a second pass over the model."""
66
- import cv2
67
-
68
- transform = build_transforms(size, train=False)
69
- image = cv2.cvtColor(cv2.imread(str(image_path), cv2.IMREAD_COLOR), cv2.COLOR_BGR2RGB)
70
- tensor = transform(image=image)["image"].unsqueeze(0).to(device)
71
- with torch.no_grad():
72
- features = model.backbone.forward_features(tensor)
73
- embedding = model.backbone.forward_head(features, pre_logits=True)
74
- logits = model.backbone.forward_head(features)
75
- return embedding.squeeze(0).cpu(), logits.squeeze(0).cpu()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/fusion/features.py DELETED
@@ -1,260 +0,0 @@
1
- """Lesion feature extraction: runs Phase 4's models as inference engines over
2
- images that have no lesion ground truth of their own (APTOS), to build the
3
- per-image feature vector Phase 5's fusion head concats with the grading
4
- model's CNN embedding.
5
-
6
- **Cross-dataset domain shift, faced honestly, not hidden**: every model used
7
- here was trained and validated exclusively on IDRiD (2848x4288, one capture
8
- device/protocol). APTOS images range from 1050x1050 to 3216x2136 across
9
- several different capture devices, with no resolution normalisation applied
10
- before inference -- a cheap sanity check (the hard-exudate model against 4
11
- images per DR grade) showed mean predicted probability rising with grade
12
- even so (grade 0: 0.0000-0.0025 positive-pixel fraction at threshold 0.5;
13
- grade 3: 0.023-0.114), which is why this was judged worth building rather
14
- than assumed broken -- but it was never validated against real APTOS lesion
15
- ground truth (APTOS ships none), so treat these as a genuine, undemonstrated
16
- hypothesis under the fusion head's own McNemar test, not a calibrated
17
- measurement the way IDRiD-evaluated numbers elsewhere in this project are.
18
-
19
- IDRiD's own grading images ("B. Disease Grading") are byte-identical to its
20
- localisation images ("C. Localization") -- real OD/fovea/lesion ground truth
21
- would be available there. Deliberately not used: docs/04_ROADMAP.md Phase 8
22
- reserves IDRiD (with Messidor-2) as the locked, evaluate-once final test
23
- set, and using it to build or tune the fusion head now would contaminate it.
24
- """
25
-
26
- from __future__ import annotations
27
-
28
- from dataclasses import dataclass
29
- from pathlib import Path
30
-
31
- import cv2
32
- import numpy as np
33
- import torch
34
-
35
- from drdetect.segmentation.localization import (
36
- LocalizationModule,
37
- heatmap_argmax,
38
- working_res_od_diameter,
39
- )
40
- from drdetect.segmentation.microaneurysms import ClassifierModule, PatchClassifier, score_candidates
41
- from drdetect.segmentation.model import build_segmentation_model
42
- from drdetect.segmentation.module import SegmentationModule
43
- from drdetect.segmentation.quadrants import assign_quadrant
44
-
45
- __all__ = [
46
- "LesionModels",
47
- "load_lesion_models",
48
- "extract_lesion_features",
49
- "lesion_centroids",
50
- "FEATURE_NAMES",
51
- "QUADRANT_ORDER",
52
- ]
53
-
54
- LOCALIZATION_SIZE = (512, 768) # (height, width), matches training
55
- SEGMENTATION_PATCH = 512
56
- MA_TUNED_THRESHOLD = 0.95 # docs/15's val-tuned operating point, frozen there
57
-
58
- _MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
59
- _STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
60
-
61
- QUADRANT_ORDER = [
62
- "superior-foveal-side",
63
- "inferior-foveal-side",
64
- "superior-disc-side",
65
- "inferior-disc-side",
66
- ]
67
- FEATURE_NAMES = [
68
- "hard_exudate_mean_prob",
69
- "soft_exudate_mean_prob",
70
- "haemorrhage_mean_prob",
71
- "microaneurysm_count",
72
- "distance_to_fovea_mean",
73
- *[f"quadrant_frac_{q}" for q in QUADRANT_ORDER],
74
- ]
75
-
76
-
77
- @dataclass
78
- class LesionModels:
79
- localization: LocalizationModule
80
- hard_exudates: SegmentationModule
81
- soft_exudates: SegmentationModule
82
- haemorrhages: SegmentationModule
83
- microaneurysm_classifier: ClassifierModule
84
- device: torch.device
85
-
86
-
87
- def _load_segmentation(checkpoint: str, device: torch.device) -> SegmentationModule:
88
- model = build_segmentation_model("resnet34", pretrained=False, classes=1)
89
- module = SegmentationModule.load_from_checkpoint(checkpoint, model=model, map_location=device)
90
- return module.eval().to(device)
91
-
92
-
93
- def load_lesion_models(
94
- *,
95
- localization_checkpoint: str,
96
- hard_exudate_checkpoint: str,
97
- soft_exudate_checkpoint: str,
98
- haemorrhage_checkpoint: str,
99
- microaneurysm_checkpoint: str,
100
- device: torch.device,
101
- ) -> LesionModels:
102
- loc_model = build_segmentation_model("resnet34", pretrained=False, classes=2)
103
- localization = (
104
- LocalizationModule.load_from_checkpoint(
105
- localization_checkpoint, model=loc_model, map_location=device
106
- )
107
- .eval()
108
- .to(device)
109
- )
110
-
111
- ma_model = PatchClassifier()
112
- microaneurysm_classifier = (
113
- ClassifierModule.load_from_checkpoint(
114
- microaneurysm_checkpoint, model=ma_model, map_location=device
115
- )
116
- .eval()
117
- .to(device)
118
- )
119
-
120
- return LesionModels(
121
- localization=localization,
122
- hard_exudates=_load_segmentation(hard_exudate_checkpoint, device),
123
- soft_exudates=_load_segmentation(soft_exudate_checkpoint, device),
124
- haemorrhages=_load_segmentation(haemorrhage_checkpoint, device),
125
- microaneurysm_classifier=microaneurysm_classifier,
126
- device=device,
127
- )
128
-
129
-
130
- def _predict_od_fovea(
131
- image_rgb: np.ndarray, models: LesionModels
132
- ) -> tuple[tuple[float, float], tuple[float, float], float]:
133
- """Returns (od_xy, fovea_xy, od_diameter), all in LOCALIZATION_SIZE's
134
- resized pixel space (not the input image's own resolution)."""
135
- resized = cv2.resize(image_rgb, (LOCALIZATION_SIZE[1], LOCALIZATION_SIZE[0]))
136
- rgb = (resized.astype(np.float32) / 255.0 - _MEAN) / _STD
137
- tensor = torch.from_numpy(rgb.transpose(2, 0, 1)).float().unsqueeze(0).to(models.device)
138
- with torch.no_grad():
139
- logits = models.localization(tensor)
140
- probs = torch.sigmoid(logits).squeeze(0).cpu().numpy()
141
- od_xy = heatmap_argmax(probs[0])
142
- fovea_xy = heatmap_argmax(probs[1])
143
- diameter = working_res_od_diameter(LOCALIZATION_SIZE)
144
- return od_xy, fovea_xy, diameter
145
-
146
-
147
- def _tile_predict(
148
- module: SegmentationModule, image_rgb: np.ndarray, device: torch.device
149
- ) -> np.ndarray:
150
- """Same non-overlapping reflect-padded tiling as
151
- scripts/evaluate_segmentation.py's tile_predict, inlined here rather than
152
- imported from a script module so library code doesn't depend on scripts/."""
153
- patch_size = SEGMENTATION_PATCH
154
- h, w = image_rgb.shape[:2]
155
- ph = (patch_size - h % patch_size) % patch_size
156
- pw = (patch_size - w % patch_size) % patch_size
157
- padded = cv2.copyMakeBorder(image_rgb, 0, ph, 0, pw, cv2.BORDER_REFLECT_101)
158
- ph_full, pw_full = padded.shape[:2]
159
-
160
- prob_map = np.zeros((ph_full, pw_full), dtype=np.float32)
161
- tiles, positions = [], []
162
- for top in range(0, ph_full, patch_size):
163
- for left in range(0, pw_full, patch_size):
164
- tiles.append(padded[top : top + patch_size, left : left + patch_size])
165
- positions.append((top, left))
166
-
167
- batch_size = 4
168
- with torch.no_grad():
169
- for start in range(0, len(tiles), batch_size):
170
- chunk = tiles[start : start + batch_size]
171
- arrs = [
172
- ((t.astype(np.float32) / 255.0 - _MEAN) / _STD).transpose(2, 0, 1) for t in chunk
173
- ]
174
- batch = torch.from_numpy(np.stack(arrs)).float().to(device)
175
- logits = module(batch)
176
- probs = torch.sigmoid(logits).squeeze(1).cpu().numpy()
177
- for (top, left), prob in zip(positions[start : start + batch_size], probs, strict=True):
178
- prob_map[top : top + patch_size, left : left + patch_size] = prob
179
- return prob_map[:h, :w]
180
-
181
-
182
- def lesion_centroids(prob_map: np.ndarray, threshold: float = 0.5) -> list[tuple[float, float]]:
183
- """Connected-component centroids of a thresholded probability map -- a
184
- handful of per-lesion centroids is what quadrant/distance features need,
185
- not per-pixel detail."""
186
- binary = (prob_map > threshold).astype(np.uint8)
187
- if binary.sum() == 0:
188
- return []
189
- n, _labels, stats, centroids = cv2.connectedComponentsWithStats(binary, connectivity=8)
190
- return [tuple(centroids[i]) for i in range(1, n) if stats[i, cv2.CC_STAT_AREA] >= 3]
191
-
192
-
193
- def extract_lesion_features(image_path: str | Path, models: LesionModels) -> dict[str, float]:
194
- """One image -> the flat feature dict Phase 5's fusion head concats with
195
- the grading CNN's embedding. Every model runs at its own native working
196
- convention (full-resolution tiling for the three DeepLabV3+ segmentation
197
- models, a fixed 512x768 resize for localisation, full-resolution
198
- candidate generation for microaneurysms) -- see this module's own
199
- docstring for the cross-dataset resolution caveat that follows from that.
200
- """
201
- image_bgr = cv2.imread(str(image_path), cv2.IMREAD_COLOR)
202
- image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
203
-
204
- od_xy, fovea_xy, od_diameter = _predict_od_fovea(image_rgb, models)
205
-
206
- he_prob = _tile_predict(models.hard_exudates, image_rgb, models.device)
207
- se_prob = _tile_predict(models.soft_exudates, image_rgb, models.device)
208
- ha_prob = _tile_predict(models.haemorrhages, image_rgb, models.device)
209
-
210
- # Segmentation ran at the image's OWN resolution; localisation predicts in
211
- # LOCALIZATION_SIZE's resized space. Rescale lesion centroids into that
212
- # same space before computing distance/quadrant features, since od_xy/
213
- # fovea_xy live there.
214
- scale_x = LOCALIZATION_SIZE[1] / image_rgb.shape[1]
215
- scale_y = LOCALIZATION_SIZE[0] / image_rgb.shape[0]
216
-
217
- all_centroids: list[tuple[float, float]] = []
218
- for prob_map in (he_prob, se_prob, ha_prob):
219
- for cx, cy in lesion_centroids(prob_map):
220
- all_centroids.append((cx * scale_x, cy * scale_y))
221
-
222
- # score_candidates expects a ground-truth mask (it's normally used for
223
- # scored evaluation, see evaluate_microaneurysms.py) purely to compute
224
- # labels/recall bookkeeping this call never reads -- an all-zero dummy
225
- # mask makes that bookkeeping cheap and correct-by-construction (0
226
- # recovered of 0 true instances) without a separate inference-only code
227
- # path for a function that otherwise does exactly what's needed here.
228
- dummy_mask = np.zeros(image_bgr.shape[:2], dtype=np.uint8)
229
- ma_result = score_candidates(
230
- image_bgr, dummy_mask, models.microaneurysm_classifier.model, device=str(models.device)
231
- )
232
- accepted = ma_result["scores"] > MA_TUNED_THRESHOLD
233
- ma_count = int(accepted.sum())
234
- for c, keep in zip(ma_result["candidates"], accepted, strict=True):
235
- if keep:
236
- all_centroids.append((c.cx * scale_x, c.cy * scale_y))
237
-
238
- if all_centroids:
239
- distances = [
240
- float(np.linalg.norm(np.array(c) - np.array(fovea_xy))) / od_diameter
241
- for c in all_centroids
242
- ]
243
- distance_to_fovea_mean = float(np.mean(distances))
244
- quadrant_counts = dict.fromkeys(QUADRANT_ORDER, 0)
245
- for c in all_centroids:
246
- quadrant_counts[assign_quadrant(c, od_xy, fovea_xy)] += 1
247
- total = len(all_centroids)
248
- quadrant_fracs = {q: quadrant_counts[q] / total for q in QUADRANT_ORDER}
249
- else:
250
- distance_to_fovea_mean = float("nan")
251
- quadrant_fracs = dict.fromkeys(QUADRANT_ORDER, 0.0)
252
-
253
- return {
254
- "hard_exudate_mean_prob": float(he_prob.mean()),
255
- "soft_exudate_mean_prob": float(se_prob.mean()),
256
- "haemorrhage_mean_prob": float(ha_prob.mean()),
257
- "microaneurysm_count": float(ma_count),
258
- "distance_to_fovea_mean": distance_to_fovea_mean,
259
- **{f"quadrant_frac_{q}": quadrant_fracs[q] for q in QUADRANT_ORDER},
260
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/fusion/model.py DELETED
@@ -1,35 +0,0 @@
1
- """The fusion head itself: concat(CNN embedding, lesion features) -> ordinal head.
2
-
3
- A small MLP, not another CNN -- the input is already a fixed-length vector
4
- (the grading backbone's pooled embedding plus a handful of scalar lesion
5
- features), not an image needing spatial processing.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import torch
11
- import torch.nn as nn
12
-
13
- __all__ = ["FusionHead"]
14
-
15
-
16
- class FusionHead(nn.Module):
17
- def __init__(
18
- self,
19
- *,
20
- embedding_dim: int,
21
- lesion_dim: int,
22
- hidden_dim: int = 128,
23
- num_outputs: int = 4,
24
- dropout: float = 0.3,
25
- ):
26
- super().__init__()
27
- self.net = nn.Sequential(
28
- nn.Linear(embedding_dim + lesion_dim, hidden_dim),
29
- nn.ReLU(inplace=True),
30
- nn.Dropout(dropout),
31
- nn.Linear(hidden_dim, num_outputs),
32
- )
33
-
34
- def forward(self, x: torch.Tensor) -> torch.Tensor:
35
- return self.net(x)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/grading/__init__.py DELETED
File without changes
src/src/drdetect/grading/losses.py DELETED
@@ -1,330 +0,0 @@
1
- """Ordinal losses for DR grading.
2
-
3
- Why cross-entropy is the wrong loss here
4
- ----------------------------------------
5
- DR grades are ordered: 0 < 1 < 2 < 3 < 4. Cross-entropy treats them as
6
- unordered labels, so predicting 4 for a true grade 1 costs exactly as much as
7
- predicting 2. The loss-minimising strategy under that objective is to hedge
8
- toward the majority middle class.
9
-
10
- The Phase 1 baseline did precisely that. From its confusion matrix, errors
11
- collapsed onto grade 2: 29 of 74 grade-1 cases, 21 of 39 grade-3, and 22 of 59
12
- grade-4 were all predicted Moderate. See docs/06_PHASE1_RESULTS.md.
13
-
14
- Three alternatives are provided.
15
-
16
- `CornLoss` -- rank-consistent ordinal regression (Shi, Cao & Raschka, 2023).
17
- The model emits K-1 logits; logit j models P(y > j | y > j-1). Because the
18
- probabilities chain multiplicatively, predictions are rank-monotonic *by
19
- construction*: the model cannot output "probably worse than grade 2 but not
20
- worse than grade 1". Each task trains only on the conditional subset, which
21
- is what distinguishes CORN from the earlier CORAL and removes its shared-bias
22
- restriction.
23
-
24
- `OrdinalRegressionLoss` -- single continuous output, thresholds fitted after
25
- training. This is the approach behind most winning Kaggle DR solutions. It
26
- optimises QWK almost directly, at the cost of a post-hoc threshold search.
27
-
28
- `DistanceWeightedCE` -- ordinary cross-entropy with soft targets whose mass
29
- decays with ordinal distance. The least invasive option: it keeps the K-way
30
- head and only changes the targets.
31
- """
32
-
33
- from __future__ import annotations
34
-
35
- import numpy as np
36
- import torch
37
- import torch.nn as nn
38
- import torch.nn.functional as F
39
-
40
- __all__ = [
41
- "CornLoss",
42
- "decode_output",
43
- "naive_referable_cut",
44
- "OrdinalRegressionLoss",
45
- "DistanceWeightedCE",
46
- "corn_probabilities",
47
- "corn_predict",
48
- "corn_task_pos_weights",
49
- "regression_predict",
50
- "fit_thresholds",
51
- "build_loss",
52
- "outputs_for_loss",
53
- ]
54
-
55
-
56
- class CornLoss(nn.Module):
57
- """Conditional ordinal regression, rank-consistent by construction.
58
-
59
- Expects `num_classes - 1` logits. Task j is trained only on samples with
60
- y >= j, so each binary problem is genuinely conditional.
61
- """
62
-
63
- def __init__(
64
- self,
65
- num_classes: int = 5,
66
- class_weights: torch.Tensor | None = None,
67
- task_pos_weights: torch.Tensor | None = None,
68
- ):
69
- super().__init__()
70
- self.num_classes = num_classes
71
- self.register_buffer(
72
- "class_weights",
73
- class_weights if class_weights is not None else torch.ones(num_classes),
74
- )
75
- self.register_buffer(
76
- "task_pos_weights",
77
- task_pos_weights if task_pos_weights is not None else torch.ones(num_classes - 1),
78
- )
79
-
80
- def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
81
- if logits.shape[1] != self.num_classes - 1:
82
- raise ValueError(
83
- f"CornLoss expects {self.num_classes - 1} logits, got {logits.shape[1]}. "
84
- f"Build the model with num_outputs={self.num_classes - 1}."
85
- )
86
-
87
- losses, total = [], 0.0
88
- for j in range(self.num_classes - 1):
89
- subset = targets >= j # conditional on having exceeded j-1
90
- if not subset.any():
91
- continue
92
- binary = (targets[subset] > j).float()
93
- w = self.class_weights[targets[subset]]
94
- bce = F.binary_cross_entropy_with_logits(
95
- logits[subset, j],
96
- binary,
97
- weight=w,
98
- pos_weight=self.task_pos_weights[j],
99
- reduction="sum",
100
- )
101
- losses.append(bce)
102
- total += float(w.sum())
103
-
104
- if not losses:
105
- return logits.sum() * 0.0 # keeps the graph connected
106
- return torch.stack(losses).sum() / max(total, 1.0)
107
-
108
-
109
- def corn_task_pos_weights(labels, num_classes: int = 5) -> list[float]:
110
- """Per-task positive weights that rebalance CORN's conditional subsets.
111
-
112
- CORN trains task j only on samples with y >= j, asking whether y > j. Those
113
- subsets inherit the label distribution and are usually NOT balanced. Measured
114
- on the APTOS training split:
115
-
116
- task j=0 2929 samples, 50.7% positive balanced
117
- task j=1 1485 samples, 80.1% positive badly skewed
118
- task j=2 1189 samples, 32.8% positive skewed
119
- task j=3 390 samples, 60.5% positive balanced
120
-
121
- Task j=1 asks "given grade >= 1, is it worse than 1?" and is 80% positive,
122
- because grade 1 is only 296 of 1485 samples with grade >= 1. Unweighted, the
123
- loss-minimising answer is almost always yes, which pushes grade-1 cases into
124
- grade 2. Measured consequence: unweighted CORN sent 49 of 74 grade-1
125
- validation cases to grade 2, against 29 for plain cross-entropy, and grade-1
126
- recall fell from 0.541 to 0.297.
127
-
128
- Returns n_negative / n_positive per task, the standard BCE `pos_weight`,
129
- which makes each conditional task cost-balanced.
130
- """
131
- y = np.asarray(labels)
132
- weights = []
133
- for j in range(num_classes - 1):
134
- subset = y[y >= j]
135
- pos = int((subset > j).sum())
136
- neg = int(len(subset) - pos)
137
- weights.append(float(neg / pos) if pos > 0 else 1.0)
138
- return weights
139
-
140
-
141
- def corn_probabilities(logits: torch.Tensor) -> torch.Tensor:
142
- """Cumulative probabilities P(y > j) from CORN logits, via the chain rule.
143
-
144
- The cumulative product is what guarantees monotonicity: P(y>j) can never
145
- exceed P(y>j-1), because it is that value multiplied by a sigmoid.
146
- """
147
- return torch.cumprod(torch.sigmoid(logits), dim=1)
148
-
149
-
150
- def corn_predict(logits: torch.Tensor) -> torch.Tensor:
151
- """Predicted grade = number of thresholds exceeded."""
152
- return (corn_probabilities(logits) > 0.5).sum(dim=1)
153
-
154
-
155
- def corn_class_probabilities(logits: torch.Tensor) -> torch.Tensor:
156
- """Per-class probabilities from cumulative ones, for referable scoring.
157
-
158
- P(y=k) = P(y>k-1) - P(y>k), with P(y>-1)=1 and P(y>K-1)=0.
159
- """
160
- cum = corn_probabilities(logits)
161
- ones = torch.ones_like(cum[:, :1])
162
- zeros = torch.zeros_like(cum[:, :1])
163
- upper = torch.cat([ones, cum], dim=1)
164
- lower = torch.cat([cum, zeros], dim=1)
165
- return (upper - lower).clamp(min=0.0)
166
-
167
-
168
- class OrdinalRegressionLoss(nn.Module):
169
- """Single-output regression on the grade, with SmoothL1.
170
-
171
- SmoothL1 rather than MSE: MSE lets a handful of grade-0-predicted-as-4
172
- outliers dominate the gradient, which at batch 4 is destabilising.
173
- """
174
-
175
- def __init__(self, beta: float = 1.0):
176
- super().__init__()
177
- self.beta = beta
178
-
179
- def forward(self, output: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
180
- return F.smooth_l1_loss(output.squeeze(-1), targets.float(), beta=self.beta)
181
-
182
-
183
- def regression_predict(output: torch.Tensor, thresholds: list[float] | None = None) -> torch.Tensor:
184
- """Discretise a continuous prediction using cut-points."""
185
- if thresholds is None:
186
- thresholds = [0.5, 1.5, 2.5, 3.5]
187
- x = output.squeeze(-1)
188
- pred = torch.zeros_like(x, dtype=torch.long)
189
- for t in thresholds:
190
- pred += (x > t).long()
191
- return pred
192
-
193
-
194
- def fit_thresholds(predictions, targets, *, n_classes: int = 5, n_rounds: int = 8) -> list[float]:
195
- """Coordinate-ascent search for cut-points maximising QWK.
196
-
197
- **Fit on validation, never on test.** The returned thresholds are part of the
198
- model and must be frozen alongside the weights.
199
- """
200
- from drdetect.eval.metrics import quadratic_weighted_kappa
201
-
202
- preds = np.asarray(predictions, dtype=float).ravel()
203
- targs = np.asarray(targets, dtype=int).ravel()
204
- thresholds = [i + 0.5 for i in range(n_classes - 1)]
205
-
206
- def score(th: list[float]) -> float:
207
- digitised = np.zeros_like(preds, dtype=int)
208
- for t in th:
209
- digitised += (preds > t).astype(int)
210
- return quadratic_weighted_kappa(targs, digitised, n_classes=n_classes)
211
-
212
- best = score(thresholds)
213
- for _ in range(n_rounds):
214
- improved = False
215
- for i in range(len(thresholds)):
216
- lo = thresholds[i - 1] + 0.05 if i > 0 else preds.min() - 0.5
217
- hi = thresholds[i + 1] - 0.05 if i < len(thresholds) - 1 else preds.max() + 0.5
218
- if hi <= lo:
219
- continue
220
- for candidate in np.linspace(lo, hi, 40):
221
- trial = list(thresholds)
222
- trial[i] = float(candidate)
223
- s = score(trial)
224
- if s > best:
225
- best, thresholds, improved = s, trial, True
226
- if not improved:
227
- break
228
- return thresholds
229
-
230
-
231
- class DistanceWeightedCE(nn.Module):
232
- """Cross-entropy against soft targets that decay with ordinal distance.
233
-
234
- Target mass for class k given true grade y is proportional to
235
- exp(-|k - y| / temperature), so predicting an adjacent grade is penalised
236
- far less than predicting a distant one -- the property plain CE lacks.
237
- """
238
-
239
- def __init__(
240
- self,
241
- num_classes: int = 5,
242
- temperature: float = 0.7,
243
- class_weights: torch.Tensor | None = None,
244
- ):
245
- super().__init__()
246
- self.num_classes = num_classes
247
- self.temperature = temperature
248
- grid = torch.arange(num_classes).float()
249
- soft = torch.exp(-(grid[None, :] - grid[:, None]).abs() / temperature)
250
- self.register_buffer("soft_targets", soft / soft.sum(dim=1, keepdim=True))
251
- self.register_buffer(
252
- "class_weights",
253
- class_weights if class_weights is not None else torch.ones(num_classes),
254
- )
255
-
256
- def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
257
- soft = self.soft_targets[targets]
258
- per_sample = -(soft * F.log_softmax(logits, dim=1)).sum(dim=1)
259
- w = self.class_weights[targets]
260
- return (per_sample * w).sum() / w.sum().clamp(min=1e-8)
261
-
262
-
263
- def outputs_for_loss(loss_name: str, num_classes: int = 5) -> int:
264
- """How many head outputs a given loss requires."""
265
- return {
266
- "ce": num_classes,
267
- "distance_ce": num_classes,
268
- "corn": num_classes - 1,
269
- "regression": 1,
270
- }[loss_name]
271
-
272
-
273
- def build_loss(
274
- name: str,
275
- *,
276
- num_classes: int = 5,
277
- class_weights: list[float] | None = None,
278
- task_pos_weights: list[float] | None = None,
279
- ):
280
- w = torch.tensor(class_weights, dtype=torch.float) if class_weights else None
281
- if name == "ce":
282
- return nn.CrossEntropyLoss(weight=w)
283
- if name == "corn":
284
- tw = (
285
- torch.tensor(task_pos_weights, dtype=torch.float)
286
- if task_pos_weights is not None
287
- else None
288
- )
289
- return CornLoss(num_classes, class_weights=w, task_pos_weights=tw)
290
- if name == "regression":
291
- return OrdinalRegressionLoss()
292
- if name == "distance_ce":
293
- return DistanceWeightedCE(num_classes, class_weights=w)
294
- raise ValueError(f"unknown loss {name!r}; expected ce, corn, regression or distance_ce")
295
-
296
-
297
- def decode_output(output: torch.Tensor, loss_name: str) -> tuple[np.ndarray, np.ndarray]:
298
- """Map a raw head output to (predicted grade, referable score).
299
-
300
- Single source of truth for decoding, shared by the training module and the
301
- evaluation script. Duplicating this logic once caused an evaluation to load a
302
- 5-output head against a 4-output CORN checkpoint; keeping one implementation
303
- makes that class of mismatch impossible.
304
-
305
- Each loss parameterises the head differently:
306
- ce / distance_ce -- K-way softmax; referable = sum of P(class >= 2)
307
- corn -- K-1 chained sigmoids; referable = P(y > 1) directly
308
- regression -- one continuous value, which itself ranks severity
309
-
310
- The referable score need only be monotone in severity; threshold selection
311
- handles the scale.
312
- """
313
- if loss_name == "corn":
314
- cum = corn_probabilities(output) # P(y > j), monotone by construction
315
- return (cum > 0.5).sum(dim=1).numpy(), cum[:, 1].numpy()
316
-
317
- if loss_name == "regression":
318
- return regression_predict(output).numpy(), output.squeeze(-1).numpy()
319
-
320
- probs = torch.softmax(output, dim=1).numpy()
321
- return probs.argmax(axis=1), probs[:, 2:].sum(axis=1)
322
-
323
-
324
- def naive_referable_cut(loss_name: str) -> float:
325
- """The 'no calibration' cut point, whose scale depends on the head.
326
-
327
- Only a progress signal during training -- the reported operating point is
328
- chosen for target sensitivity in scripts/evaluate.py.
329
- """
330
- return 1.5 if loss_name == "regression" else 0.5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/grading/model.py DELETED
@@ -1,92 +0,0 @@
1
- """DR grading backbones.
2
-
3
- Two decisions here come from measurements rather than convention.
4
-
5
- Frozen BatchNorm
6
- ----------------
7
- Benchmarking on this project's target machine showed batch size is capped at 4
8
- by available RAM (docs/05_PROTOTYPE_SCOPE.md section 6). EfficientNet uses
9
- BatchNorm, whose running statistics are estimated per micro-batch -- at batch 4
10
- they are noisy, and gradient accumulation does NOT help, because accumulation
11
- batches the optimiser step, not the normalisation. Freezing BN to use the
12
- pretrained ImageNet statistics is the standard fix for small-batch fine-tuning
13
- and is the default here.
14
-
15
- Ordinal-ready head
16
- ------------------
17
- Phase 1 is a plain cross-entropy baseline, deliberately. But DR grades are
18
- ordinal, and Phase 3 replaces the loss. `num_outputs` is therefore separated
19
- from `num_classes` so the same backbone serves a 5-way softmax, a 1-output
20
- regression head, or a 4-output CORAL head without rewriting the model.
21
- """
22
-
23
- from __future__ import annotations
24
-
25
- import torch
26
- import torch.nn as nn
27
-
28
- __all__ = ["build_model", "freeze_batchnorm", "count_parameters"]
29
-
30
-
31
- def freeze_batchnorm(model: nn.Module) -> int:
32
- """Put every BatchNorm into eval mode and stop its affine parameters training.
33
-
34
- Returns the number of layers frozen, for logging -- a silent no-op here
35
- would be indistinguishable from a working call.
36
- """
37
- frozen = 0
38
- for module in model.modules():
39
- if isinstance(module, nn.modules.batchnorm._BatchNorm):
40
- module.eval()
41
- module.weight.requires_grad_(False)
42
- module.bias.requires_grad_(False)
43
- frozen += 1
44
- return frozen
45
-
46
-
47
- class FundusGrader(nn.Module):
48
- def __init__(self, backbone: nn.Module, freeze_bn: bool = True):
49
- super().__init__()
50
- self.backbone = backbone
51
- self.freeze_bn = freeze_bn
52
- if freeze_bn:
53
- self.n_frozen_bn = freeze_batchnorm(self.backbone)
54
-
55
- def forward(self, x: torch.Tensor) -> torch.Tensor:
56
- return self.backbone(x)
57
-
58
- def train(self, mode: bool = True):
59
- """Keep frozen BN in eval mode even when the module is set to train.
60
-
61
- Without this override, `model.train()` silently re-enables running-
62
- statistic updates and undoes the freeze -- a bug that produces no error
63
- and only shows up as unstable validation metrics.
64
- """
65
- super().train(mode)
66
- if self.freeze_bn:
67
- for module in self.backbone.modules():
68
- if isinstance(module, nn.modules.batchnorm._BatchNorm):
69
- module.eval()
70
- return self
71
-
72
-
73
- def build_model(
74
- name: str = "efficientnet_b0",
75
- *,
76
- num_outputs: int = 5,
77
- pretrained: bool = True,
78
- freeze_bn: bool = True,
79
- drop_rate: float = 0.2,
80
- ) -> FundusGrader:
81
- import timm
82
-
83
- backbone = timm.create_model(
84
- name, pretrained=pretrained, num_classes=num_outputs, drop_rate=drop_rate
85
- )
86
- return FundusGrader(backbone, freeze_bn=freeze_bn)
87
-
88
-
89
- def count_parameters(model: nn.Module) -> tuple[int, int]:
90
- total = sum(p.numel() for p in model.parameters())
91
- trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
92
- return total, trainable
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/grading/module.py DELETED
@@ -1,256 +0,0 @@
1
- """Lightning module for DR grading.
2
-
3
- Phase 1 is a plain cross-entropy baseline. It is deliberately un-tuned: its job
4
- is to produce a number to beat, and every later improvement is measured as a
5
- delta against it.
6
-
7
- Per-class recall is logged every epoch, not only aggregate QWK. On APTOS, grade
8
- 3 is 5.3% of the data and grade 1 is 10.1%; a model can post a respectable QWK
9
- while completely failing those classes, and aggregate metrics will not say so.
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import lightning as L
15
- import numpy as np
16
- import torch
17
- import torch.nn as nn
18
-
19
- from drdetect.eval.metrics import (
20
- binary_scores,
21
- quadratic_weighted_kappa,
22
- referable_labels,
23
- sensitivity_at_specificity_floor,
24
- youden_j,
25
- )
26
- from drdetect.grading.losses import build_loss, decode_output, naive_referable_cut
27
-
28
- __all__ = ["GradingModule"]
29
-
30
- CLASS_NAMES = ["No DR", "Mild", "Moderate", "Severe", "PDR"]
31
-
32
-
33
- class GradingModule(L.LightningModule):
34
- def __init__(
35
- self,
36
- model: nn.Module,
37
- *,
38
- lr: float = 1e-4,
39
- weight_decay: float = 1e-4,
40
- num_classes: int = 5,
41
- loss_name: str = "ce",
42
- class_weights: list[float] | None = None,
43
- task_pos_weights: list[float] | None = None,
44
- warmup_epochs: int = 3,
45
- max_epochs: int = 40,
46
- spec_floor: float = 0.85,
47
- ):
48
- super().__init__()
49
- # `model` is a live module; saving it into the checkpoint hyperparameters
50
- # would serialise the whole network twice.
51
- self.save_hyperparameters(ignore=["model"])
52
- self.model = model
53
- self.num_classes = num_classes
54
- self.loss_name = loss_name
55
- self.criterion = build_loss(
56
- loss_name,
57
- num_classes=num_classes,
58
- class_weights=class_weights,
59
- task_pos_weights=task_pos_weights,
60
- )
61
-
62
- self._val_logits: list[torch.Tensor] = []
63
- self._val_targets: list[torch.Tensor] = []
64
- self._first_epoch_loss: float | None = None
65
- self._grad_norms: list[float] = []
66
-
67
- @property
68
- def _epoch(self) -> int:
69
- """current_epoch without requiring an attached trainer.
70
-
71
- Used only in error messages. A guard that raises AttributeError while
72
- constructing its own diagnostic is worse than no guard.
73
- """
74
- try:
75
- return int(self.current_epoch)
76
- except (AttributeError, RuntimeError):
77
- return -1
78
-
79
- def forward(self, x):
80
- return self.model(x)
81
-
82
- def training_step(self, batch, batch_idx):
83
- x, y = batch
84
- loss = self.criterion(self(x), y)
85
- if not torch.isfinite(loss):
86
- raise RuntimeError(
87
- f"Training loss is {loss.item()} at epoch {self._epoch}, step "
88
- f"{batch_idx}. Diverged -- lower --lr or check the data pipeline."
89
- )
90
- self.log("train/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
91
- return loss
92
-
93
- def on_train_epoch_end(self):
94
- """Abort a diverged run immediately rather than after hours.
95
-
96
- Small-batch training (batch 4, forced here by available RAM) produces
97
- noisy gradients; with too high a learning rate the loss can explode by
98
- two orders of magnitude in a single epoch. Measured on this project:
99
- train loss went 0.786 -> 239.35 when warmup ended at lr 3e-4, and the
100
- model collapsed to predicting grade 0 for everything. Detecting that in
101
- one epoch instead of forty is the difference between five minutes and
102
- two and a half hours.
103
- """
104
- if self._grad_norms:
105
- import numpy as _np
106
-
107
- arr = _np.asarray(self._grad_norms)
108
- clip = getattr(self.trainer, "gradient_clip_val", None) or 0.0
109
- self.log("train/grad_norm", float(arr.mean()))
110
- self.log("train/grad_norm_median", float(_np.median(arr)))
111
- if clip:
112
- self.log("train/grad_clipped_frac", float((arr > clip).mean()))
113
- self._grad_norms.clear()
114
-
115
- loss = self.trainer.callback_metrics.get("train/loss")
116
- if loss is None:
117
- return
118
- loss = float(loss)
119
- if self._first_epoch_loss is None:
120
- self._first_epoch_loss = loss
121
- return
122
- if loss > max(10.0 * self._first_epoch_loss, 5.0):
123
- raise RuntimeError(
124
- f"Training diverged: loss {loss:.2f} at epoch {self._epoch} vs "
125
- f"{self._first_epoch_loss:.3f} at epoch 0.\n"
126
- f"With batch size 4 this usually means the learning rate is too high. "
127
- f"Try --lr 5e-5, and confirm gradient clipping is enabled."
128
- )
129
-
130
- def on_before_optimizer_step(self, optimizer):
131
- """Collect the PRE-clip gradient norm; logging happens at epoch end.
132
-
133
- This hook fires before configure_gradient_clipping, so it sees the
134
- unclipped gradient. It is the direct instrument for the epoch-3 question:
135
- clipping acts on the accumulation MEAN, whose norm is smaller than a
136
- 4-sample gradient's, so clip 1.0 binds less often at higher accumulation.
137
- Without it, "the clip bound less" and "the noise was lower" are
138
- indistinguishable in the logs.
139
-
140
- The values are BUFFERED rather than logged here. Logging new metric keys
141
- mid-epoch makes CSVLogger flush rows, discover keys it has not seen, and
142
- then fail in _rewrite_with_new_header with "dict contains fields not in
143
- fieldnames" -- which killed two runs after one epoch. Emitting them from
144
- on_train_epoch_end keeps every train/* key on the same cadence.
145
- """
146
- from lightning.pytorch.utilities import grad_norm
147
-
148
- total = grad_norm(self, norm_type=2).get("grad_2.0_norm_total")
149
- if total is not None:
150
- self._grad_norms.append(float(total))
151
-
152
- def validation_step(self, batch, batch_idx):
153
- x, y = batch
154
- logits = self(x)
155
- loss = self.criterion(logits, y)
156
- self.log("val/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
157
- self._val_logits.append(logits.detach().float().cpu())
158
- self._val_targets.append(y.detach().cpu())
159
-
160
- def decode(self, output: torch.Tensor) -> tuple[np.ndarray, np.ndarray]:
161
- """Delegate to the shared decoder so training and evaluation cannot drift."""
162
- return decode_output(output, self.loss_name)
163
-
164
- def on_validation_epoch_end(self):
165
- if not self._val_logits:
166
- return
167
-
168
- logits = torch.cat(self._val_logits)
169
- targets = torch.cat(self._val_targets).numpy()
170
- preds, p_ref = self.decode(logits)
171
-
172
- self.log("val/qwk", quadratic_weighted_kappa(targets, preds), prog_bar=True)
173
- self.log("val/acc", float((preds == targets).mean()))
174
-
175
- # Referable DR (grade >= 2) at the default 0.5 operating point. The
176
- # calibrated, sensitivity-targeted threshold is chosen in Phase 5 -- this
177
- # is only a progress signal, not the reported operating point.
178
- y_ref = referable_labels(targets)
179
- # 0.5 is a naive cut and is only a progress signal; the reported
180
- # operating point is chosen for target sensitivity in scripts/evaluate.py.
181
- cut = naive_referable_cut(self.loss_name)
182
- scores = binary_scores(y_ref, (p_ref >= cut).astype(int))
183
- self.log("val/sensitivity_referable", scores.sensitivity, prog_bar=True)
184
- self.log("val/specificity_referable", scores.specificity)
185
-
186
- # Sensitivity-at-a-specificity-floor, and Youden's J for comparison.
187
- # Plain sensitivity is not safe to select on: checked against this
188
- # project's own logs, val/sensitivity_referable peaks at the exact
189
- # epoch that collapses to predicting referable for nearly everyone
190
- # (grade-2 recall ~1.0, specificity ~0.80) in 3 of 4 CE runs. The
191
- # floor-constrained version operationalises this project's own stated
192
- # target (sens >90%, spec >85%) instead of a symmetric combination, so
193
- # it cannot be satisfied by that shortcut. See
194
- # drdetect.eval.metrics.sensitivity_at_specificity_floor.
195
- #
196
- # The floor is configurable (train.py --spec-floor) because 0.85
197
- # saturates on this task: it compressed 16 epochs spanning 0.106 QWK
198
- # into a 0.013 band, and ModelCheckpoint then selected on noise
199
- # (docs/07_PHASE3_RESULTS.md, Result 9). The logged key stays
200
- # "val/sens_at_spec85" regardless of the floor actually used, for
201
- # continuity with --monitor's fixed choices and every existing CSV --
202
- # the TRUE floor for a given run is always in that run's
203
- # summary.json config, not in this key's name.
204
- self.log(
205
- "val/sens_at_spec85",
206
- sensitivity_at_specificity_floor(y_ref, p_ref, spec_floor=self.hparams.spec_floor),
207
- prog_bar=True,
208
- )
209
- self.log("val/youden_j", youden_j(y_ref, p_ref, threshold=cut))
210
-
211
- # Per-class recall: aggregate QWK can look fine while a rare class is
212
- # never predicted at all.
213
- for cls in range(self.num_classes):
214
- mask = targets == cls
215
- recall = float((preds[mask] == cls).mean()) if mask.any() else float("nan")
216
- self.log(f"val/recall_{cls}_{CLASS_NAMES[cls].replace(' ', '')}", recall)
217
-
218
- # Macro-averaged recall: the unweighted mean over classes, so a rare
219
- # class counts as much as a common one. Logged as a selection
220
- # alternative to QWK, which on this data tracks grade-2 recall
221
- # (r=+0.51 over 70 epochs) and, for balanced CORN, was strongly
222
- # ANTI-correlated with grade-1 recall (r=-0.70). Selecting that run on
223
- # QWK cost 0.100 macro-recall to gain 0.0097 QWK.
224
- recalls = [
225
- float((preds[targets == c] == c).mean())
226
- for c in range(self.num_classes)
227
- if (targets == c).any()
228
- ]
229
- self.log("val/macro_recall", float(np.mean(recalls)) if recalls else 0.0)
230
-
231
- self._val_logits.clear()
232
- self._val_targets.clear()
233
-
234
- def configure_optimizers(self):
235
- # Only parameters that actually require grad -- frozen BN affine weights
236
- # must not be handed to the optimiser, or AdamW allocates moment buffers
237
- # for tensors it will never update.
238
- params = [p for p in self.model.parameters() if p.requires_grad]
239
- optimiser = torch.optim.AdamW(
240
- params, lr=self.hparams.lr, weight_decay=self.hparams.weight_decay
241
- )
242
-
243
- warmup = max(self.hparams.warmup_epochs, 0)
244
- total = max(self.hparams.max_epochs, warmup + 1)
245
-
246
- def lr_lambda(epoch: int) -> float:
247
- if epoch < warmup:
248
- return (epoch + 1) / (warmup + 1)
249
- progress = (epoch - warmup) / max(total - warmup, 1)
250
- return float(0.5 * (1.0 + np.cos(np.pi * min(progress, 1.0))))
251
-
252
- scheduler = torch.optim.lr_scheduler.LambdaLR(optimiser, lr_lambda)
253
- return {
254
- "optimizer": optimiser,
255
- "lr_scheduler": {"scheduler": scheduler, "interval": "epoch"},
256
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/quality/__init__.py DELETED
File without changes
src/src/drdetect/quality/assessment.py DELETED
@@ -1,127 +0,0 @@
1
- """Handcrafted fundus image quality gate.
2
-
3
- This is the "handcrafted quality features" half of Phase 2's quality stage
4
- (docs/04_ROADMAP.md), not the learned half. The roadmap also calls for an
5
- EfficientNet-B0 classifier trained on EyeQ; that needs a new dataset download
6
- and a training run and is deliberately deferred. Everything here is an
7
- uncalibrated heuristic, not a validated clinical cutoff -- the thresholds are
8
- defensible starting points from classical blur/exposure-detection practice,
9
- not numbers measured against this project's own data. Replace them with the
10
- EyeQ classifier before trusting this gate on real field images, the same way
11
- `apply_clahe`'s clip_limit is flagged as a knob to ablate, not a fact.
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- from dataclasses import dataclass, field
17
-
18
- import cv2
19
- import numpy as np
20
-
21
- from drdetect.enhance.preprocessing import circle_crop, crop_from_gray
22
-
23
- __all__ = ["QualityResult", "assess_quality"]
24
-
25
- # Laplacian variance below this, on the FOV resized to a fixed working size,
26
- # reads as blurry. Variance of the Laplacian scales with image size and FOV
27
- # fraction, which is why the FOV is isolated and resized before measuring it
28
- # -- otherwise the same physical sharpness would score differently depending
29
- # on the camera's raw resolution.
30
- #
31
- # 80.0 (a generic blur-detection tutorial default) was the first value tried
32
- # here, and it rejected 28 of 30 real APTOS training images -- images that
33
- # were sharp enough for an ophthalmologist to grade. That default was tuned
34
- # for a different normalisation scale, not this one. 15.0 is recalibrated
35
- # against this project's own pipeline on a 60-image APTOS sample (measured
36
- # range 7-108, with a visible cluster of genuinely-degraded outliers at 7-13
37
- # and a continuous spread from ~17 up); it is still an eyeballed cut on a
38
- # small sample, not the learned EyeQ classifier the roadmap calls for.
39
- _BLUR_WORKING_SIZE = 512
40
- _MIN_SHARPNESS = 15.0
41
-
42
- # Mean intensity of the FOV, on a 0-255 scale. Outside this band the image is
43
- # globally under- or over-exposed rather than locally noisy. Lower bound has
44
- # a margin below the measured real-data minimum (36 on the same 60-image
45
- # sample); upper bound is untested against real overexposed captures.
46
- _MIN_MEAN_BRIGHTNESS = 30.0
47
- _MAX_MEAN_BRIGHTNESS = 225.0
48
-
49
- # Fraction of FOV pixels that are crushed (near-black) or blown out
50
- # (near-white). High values mean local exposure failure even when the mean
51
- # brightness looks fine.
52
- _MAX_DARK_FRACTION = 0.45
53
- _MAX_BRIGHT_FRACTION = 0.15
54
-
55
- # Fraction of the full frame the detected FOV must occupy. Too small usually
56
- # means the camera missed the retina (misalignment, eyelash occlusion) rather
57
- # than a genuinely small pupil.
58
- _MIN_FOV_FRACTION = 0.25
59
-
60
-
61
- @dataclass(frozen=True)
62
- class QualityResult:
63
- usable: bool
64
- reasons: list[str] = field(default_factory=list)
65
- sharpness: float = 0.0
66
- mean_brightness: float = 0.0
67
- dark_fraction: float = 0.0
68
- bright_fraction: float = 0.0
69
- fov_fraction: float = 0.0
70
-
71
-
72
- def assess_quality(image: np.ndarray) -> QualityResult:
73
- """Gate a raw (unpreprocessed) fundus photo before grading.
74
-
75
- Args:
76
- image: HxWx3 uint8 RGB, as captured -- not yet Ben Graham-normalised.
77
- Judging exposure and blur on the enhanced image would be
78
- circular: `ben_graham` actively rewrites local contrast.
79
-
80
- Returns:
81
- QualityResult with `usable=False` and `reasons` naming every check
82
- that failed, not just the first -- a rejected image should tell the
83
- operator what to fix on recapture, not just that something is wrong.
84
- """
85
- fov = crop_from_gray(image)
86
- if fov.size == 0 or min(fov.shape[:2]) < 8:
87
- return QualityResult(usable=False, reasons=["no retinal field of view detected"])
88
-
89
- gray = cv2.cvtColor(fov, cv2.COLOR_RGB2GRAY)
90
-
91
- working = cv2.resize(
92
- gray, (_BLUR_WORKING_SIZE, _BLUR_WORKING_SIZE), interpolation=cv2.INTER_AREA
93
- )
94
- sharpness = float(cv2.Laplacian(working, cv2.CV_64F).var())
95
-
96
- mean_brightness = float(gray.mean())
97
- dark_fraction = float((gray < 20).mean())
98
- bright_fraction = float((gray > 245).mean())
99
-
100
- circle = circle_crop(image)
101
- fov_fraction = float((cv2.cvtColor(circle, cv2.COLOR_RGB2GRAY) > 0).sum()) / (
102
- image.shape[0] * image.shape[1]
103
- )
104
-
105
- reasons = []
106
- if sharpness < _MIN_SHARPNESS:
107
- reasons.append(f"image too blurry (sharpness {sharpness:.0f} < {_MIN_SHARPNESS:.0f})")
108
- if mean_brightness < _MIN_MEAN_BRIGHTNESS:
109
- reasons.append(f"underexposed (mean brightness {mean_brightness:.0f})")
110
- if mean_brightness > _MAX_MEAN_BRIGHTNESS:
111
- reasons.append(f"overexposed (mean brightness {mean_brightness:.0f})")
112
- if dark_fraction > _MAX_DARK_FRACTION:
113
- reasons.append(f"too much of the field is near-black ({dark_fraction:.0%})")
114
- if bright_fraction > _MAX_BRIGHT_FRACTION:
115
- reasons.append(f"too much of the field is blown out ({bright_fraction:.0%})")
116
- if fov_fraction < _MIN_FOV_FRACTION:
117
- reasons.append(f"retinal field of view too small ({fov_fraction:.0%} of frame)")
118
-
119
- return QualityResult(
120
- usable=not reasons,
121
- reasons=reasons,
122
- sharpness=sharpness,
123
- mean_brightness=mean_brightness,
124
- dark_fraction=dark_fraction,
125
- bright_fraction=bright_fraction,
126
- fov_fraction=fov_fraction,
127
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/segmentation/__init__.py DELETED
File without changes
src/src/drdetect/segmentation/dataset.py DELETED
@@ -1,174 +0,0 @@
1
- """IDRiD lesion segmentation dataset: on-the-fly patch sampling from full-resolution images.
2
-
3
- IDRiD images are 2848x4288 -- about 17x the pixel count of the 1024px grading
4
- images this project trains at elsewhere, and holding even one at full
5
- resolution through a segmentation decoder is not something this project's
6
- measured memory budget (docs/05_PROTOTYPE_SCOPE.md) supports. The roadmap's
7
- own plan is "patch sampling at full resolution" (docs/04_ROADMAP.md, Phase 4),
8
- not downscaling -- a hard exudate here is 0.07% of the image by pixel count
9
- (measured directly, IDRiD_41_EX.tif), so shrinking the image shrinks the
10
- lesion out of existence before the model ever sees it.
11
-
12
- Patches are drawn fresh every `__getitem__` call, not pre-extracted once,
13
- biased toward lesion-containing regions (`lesion_patch_prob`) -- a uniformly
14
- random 512x512 crop of a 2848x4288 image with <0.1% positive pixels would
15
- return an all-background patch almost every time, and a model trained on
16
- "predict background" batches learns exactly that.
17
- """
18
-
19
- from __future__ import annotations
20
-
21
- from dataclasses import dataclass
22
- from pathlib import Path
23
-
24
- import albumentations as A
25
- import cv2
26
- import numpy as np
27
- from albumentations.pytorch import ToTensorV2
28
- from torch.utils.data import Dataset
29
-
30
- __all__ = [
31
- "LesionImagePair",
32
- "find_idrid_lesion_pairs",
33
- "IDRiDLesionDataset",
34
- "build_patch_transforms",
35
- ]
36
-
37
- LESION_FOLDERS = {
38
- "microaneurysms": "1. Microaneurysms",
39
- "haemorrhages": "2. Haemorrhages",
40
- "hard_exudates": "3. Hard Exudates",
41
- "soft_exudates": "4. Soft Exudates",
42
- "optic_disc": "5. Optic Disc",
43
- }
44
- # IDRiD's own mask filename suffixes, per lesion type -- not derivable from
45
- # the folder name alone (e.g. "Hard Exudates" -> "_EX", not "_HardExudates").
46
- LESION_SUFFIXES = {
47
- "microaneurysms": "_MA",
48
- "haemorrhages": "_HE",
49
- "hard_exudates": "_EX",
50
- "soft_exudates": "_SE",
51
- "optic_disc": "_OD",
52
- }
53
-
54
-
55
- @dataclass(frozen=True)
56
- class LesionImagePair:
57
- image_id: str
58
- image_path: Path
59
- mask_path: Path
60
-
61
-
62
- def find_idrid_lesion_pairs(
63
- idrid_root: str | Path, lesion: str, split: str
64
- ) -> list[LesionImagePair]:
65
- """Match IDRiD images to their mask for one lesion type and split.
66
-
67
- Args:
68
- idrid_root: e.g. "data/raw/idrid".
69
- lesion: a key of LESION_FOLDERS.
70
- split: "train" or "test".
71
- """
72
- if lesion not in LESION_FOLDERS:
73
- raise ValueError(f"unknown lesion {lesion!r}; expected one of {sorted(LESION_FOLDERS)}")
74
- root = Path(idrid_root)
75
- split_dir = "a. Training Set" if split == "train" else "b. Testing Set"
76
- image_dir = root / "A. Segmentation" / "1. Original Images" / split_dir
77
- mask_dir = (
78
- root
79
- / "A. Segmentation"
80
- / "2. All Segmentation Groundtruths"
81
- / split_dir
82
- / LESION_FOLDERS[lesion]
83
- )
84
-
85
- pairs = []
86
- for image_path in sorted(image_dir.glob("*.jpg")):
87
- mask_path = mask_dir / f"{image_path.stem}{LESION_SUFFIXES[lesion]}.tif"
88
- if mask_path.exists():
89
- pairs.append(
90
- LesionImagePair(
91
- image_id=image_path.stem, image_path=image_path, mask_path=mask_path
92
- )
93
- )
94
- return pairs
95
-
96
-
97
- def build_patch_transforms(patch_size: int, train: bool) -> A.Compose:
98
- """Same reasoning as drdetect.data.dataset.build_transforms: flips/rotation
99
- are label-preserving for a fundus with no canonical orientation, and no
100
- blur/noise, since it would erase small lesions the same way it would
101
- erase microaneurysms in the grading pipeline."""
102
- normalise = A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225))
103
- if not train:
104
- return A.Compose([normalise, ToTensorV2()])
105
- return A.Compose(
106
- [
107
- A.HorizontalFlip(p=0.5),
108
- A.VerticalFlip(p=0.5),
109
- A.RandomRotate90(p=0.5),
110
- A.RandomBrightnessContrast(brightness_limit=0.15, contrast_limit=0.15, p=0.5),
111
- normalise,
112
- ToTensorV2(),
113
- ]
114
- )
115
-
116
-
117
- class IDRiDLesionDataset(Dataset):
118
- """One lesion type, patch-sampled on the fly.
119
-
120
- `patches_per_image` fixes an epoch's length to something meaningful for a
121
- 54-image training set -- without it, `len(dataset) == len(pairs)` would
122
- make one epoch see 54 patches total, most of a 2848x4288 image never
123
- sampled at all.
124
- """
125
-
126
- def __init__(
127
- self,
128
- pairs: list[LesionImagePair],
129
- *,
130
- patch_size: int = 512,
131
- train: bool = True,
132
- patches_per_image: int = 20,
133
- lesion_patch_prob: float = 0.8,
134
- seed: int = 42,
135
- ):
136
- self.pairs = pairs
137
- self.patch_size = patch_size
138
- self.train = train
139
- self.patches_per_image = patches_per_image
140
- self.lesion_patch_prob = lesion_patch_prob
141
- self.transform = build_patch_transforms(patch_size, train)
142
- self._rng = np.random.default_rng(seed)
143
-
144
- def __len__(self) -> int:
145
- return len(self.pairs) * self.patches_per_image
146
-
147
- def _load(self, pair: LesionImagePair) -> tuple[np.ndarray, np.ndarray]:
148
- image = cv2.imread(str(pair.image_path), cv2.IMREAD_COLOR)
149
- image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
150
- mask = cv2.imread(str(pair.mask_path), cv2.IMREAD_GRAYSCALE)
151
- mask = (mask > 0).astype(np.uint8)
152
- return image, mask
153
-
154
- def _sample_patch(self, image: np.ndarray, mask: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
155
- h, w = mask.shape
156
- p = self.patch_size
157
- ys, xs = np.where(mask > 0)
158
- want_lesion_patch = len(ys) > 0 and self._rng.random() < self.lesion_patch_prob
159
- if want_lesion_patch:
160
- i = self._rng.integers(0, len(ys))
161
- cy, cx = int(ys[i]), int(xs[i])
162
- top = int(np.clip(cy - p // 2 + self._rng.integers(-p // 4, p // 4 + 1), 0, h - p))
163
- left = int(np.clip(cx - p // 2 + self._rng.integers(-p // 4, p // 4 + 1), 0, w - p))
164
- else:
165
- top = int(self._rng.integers(0, max(h - p, 1)))
166
- left = int(self._rng.integers(0, max(w - p, 1)))
167
- return image[top : top + p, left : left + p], mask[top : top + p, left : left + p]
168
-
169
- def __getitem__(self, idx: int):
170
- pair = self.pairs[idx % len(self.pairs)]
171
- image, mask = self._load(pair)
172
- patch_img, patch_mask = self._sample_patch(image, mask)
173
- out = self.transform(image=patch_img, mask=patch_mask)
174
- return out["image"], out["mask"].float().unsqueeze(0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/segmentation/localization.py DELETED
@@ -1,289 +0,0 @@
1
- """IDRiD optic disc / fovea localisation: heatmap regression, not segmentation.
2
-
3
- A genuinely different task from lesion or vessel segmentation -- the target
4
- is a single (x, y) point per structure, not a per-pixel mask. Following the
5
- roadmap's own specified approach ("heatmap regression"), each target point
6
- becomes a 2D Gaussian blob at working resolution; the model predicts a
7
- 2-channel heatmap (OD, fovea) and inference reads the point back off as the
8
- argmax of each channel.
9
-
10
- IDRiD's "C. Localization" task ships a genuine official split -- 413
11
- training / 103 test images, each in its own CSV with (x, y) pixel
12
- coordinates at full (2848x4288) resolution, for both the optic disc and the
13
- fovea. This is the largest, cleanest-labelled subset of IDRiD used in this
14
- project so far (segmentation's hard-exudate harness has 81 images total;
15
- this one has 516).
16
-
17
- OD-diameter normalisation: the roadmap's success criterion is "mean
18
- localisation error < 0.5 x OD diameter", but per-image OD diameter isn't
19
- available for the localisation set itself -- only the disjoint 81-image
20
- segmentation set ships OD masks, and the two subsets use different filename
21
- numbering (IDRiD_01..54 vs IDRiD_001..413/103), so they cannot be joined by
22
- ID. The mean OD diameter measured directly from the 54 real segmentation
23
- masks (527.7px at full 2848x4288 resolution) is used as a fixed proxy
24
- constant instead of a per-image value or an invented one.
25
- """
26
-
27
- from __future__ import annotations
28
-
29
- import csv
30
- import math
31
- from dataclasses import dataclass
32
- from pathlib import Path
33
-
34
- import albumentations as A
35
- import cv2
36
- import lightning as L
37
- import numpy as np
38
- import torch
39
- import torch.nn as nn
40
- from albumentations.pytorch import ToTensorV2
41
- from torch.utils.data import Dataset
42
-
43
- __all__ = [
44
- "IDRiDLocalizationPair",
45
- "find_idrid_localization_pairs",
46
- "build_localization_transforms",
47
- "make_gaussian_heatmap",
48
- "heatmap_argmax",
49
- "IDRiDLocalizationDataset",
50
- "LocalizationModule",
51
- "MEAN_OD_DIAMETER_PX_FULL_RES",
52
- "working_res_od_diameter",
53
- "euclidean",
54
- ]
55
-
56
- # Measured directly from the 54 real IDRiD segmentation-task OD masks (see
57
- # module docstring for why this is a cross-subset proxy, not a per-image
58
- # ground truth value).
59
- MEAN_OD_DIAMETER_PX_FULL_RES = 527.7
60
- _FULL_RES_H = 2848
61
-
62
-
63
- @dataclass(frozen=True)
64
- class IDRiDLocalizationPair:
65
- image_id: str
66
- image_path: Path
67
- od_xy: tuple[float, float]
68
- fovea_xy: tuple[float, float]
69
-
70
-
71
- def _read_coordinate_csv(path: Path) -> dict[str, tuple[float, float]]:
72
- """IDRiD's markup CSVs ship ~50 trailing empty columns per row and a
73
- header row with inconsistent spacing ("X- Coordinate", "Y - Coordinate")
74
- -- read positionally (columns 0-2), not by header name."""
75
- coords = {}
76
- with open(path, newline="") as f:
77
- rows = list(csv.reader(f))
78
- for row in rows[1:]:
79
- if not row or not row[0].strip():
80
- continue
81
- coords[row[0].strip()] = (float(row[1]), float(row[2]))
82
- return coords
83
-
84
-
85
- def find_idrid_localization_pairs(
86
- idrid_root: str | Path, split: str
87
- ) -> list[IDRiDLocalizationPair]:
88
- """Args: idrid_root e.g. "data/raw/idrid"; split "train" or "test"."""
89
- root = Path(idrid_root) / "C. Localization"
90
- split_dir = "a. Training Set" if split == "train" else "b. Testing Set"
91
- split_label = "Training Set" if split == "train" else "Testing Set"
92
- image_dir = root / "1. Original Images" / split_dir
93
-
94
- od_csv = (
95
- root
96
- / "2. Groundtruths"
97
- / "1. Optic Disc Center Location"
98
- / f"{'a' if split == 'train' else 'b'}. IDRiD_OD_Center_{split_label}_Markups.csv"
99
- )
100
- fovea_csv = (
101
- root
102
- / "2. Groundtruths"
103
- / "2. Fovea Center Location"
104
- / f"IDRiD_Fovea_Center_{split_label}_Markups.csv"
105
- )
106
- od_coords = _read_coordinate_csv(od_csv)
107
- fovea_coords = _read_coordinate_csv(fovea_csv)
108
-
109
- pairs = []
110
- for image_path in sorted(image_dir.glob("*.jpg")):
111
- image_id = image_path.stem
112
- if image_id in od_coords and image_id in fovea_coords:
113
- pairs.append(
114
- IDRiDLocalizationPair(
115
- image_id=image_id,
116
- image_path=image_path,
117
- od_xy=od_coords[image_id],
118
- fovea_xy=fovea_coords[image_id],
119
- )
120
- )
121
- return pairs
122
-
123
-
124
- def build_localization_transforms(size: tuple[int, int], train: bool) -> A.Compose:
125
- """`size` is (height, width). Flips are included, unlike segmentation's
126
- IDRiD transforms sharing the same rotation-invariance reasoning does NOT
127
- apply here -- flipping DOES change which anatomical side is which, but
128
- that's fine: the (x, y) keypoints are flipped consistently with the
129
- image by albumentations' own keypoint handling, and the model is trained
130
- to predict two independent points, not a rotation-invariant blob."""
131
- normalise = A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225))
132
- keypoint_params = A.KeypointParams(format="xy", remove_invisible=False)
133
- if not train:
134
- return A.Compose(
135
- [A.Resize(*size), normalise, ToTensorV2()], keypoint_params=keypoint_params
136
- )
137
- return A.Compose(
138
- [
139
- A.Resize(*size),
140
- A.HorizontalFlip(p=0.5),
141
- A.VerticalFlip(p=0.5),
142
- A.RandomBrightnessContrast(brightness_limit=0.15, contrast_limit=0.15, p=0.5),
143
- normalise,
144
- ToTensorV2(),
145
- ],
146
- keypoint_params=keypoint_params,
147
- )
148
-
149
-
150
- def make_gaussian_heatmap(h: int, w: int, cx: float, cy: float, sigma: float) -> np.ndarray:
151
- """A single-channel float32 Gaussian blob peaking at 1.0 at (cx, cy)."""
152
- ys, xs = np.mgrid[0:h, 0:w]
153
- heatmap = np.exp(-(((xs - cx) ** 2 + (ys - cy) ** 2) / (2.0 * sigma**2)))
154
- return heatmap.astype(np.float32)
155
-
156
-
157
- def heatmap_argmax(heatmap: np.ndarray) -> tuple[float, float]:
158
- """(x, y) of a 2D array's peak."""
159
- y, x = np.unravel_index(np.argmax(heatmap), heatmap.shape)
160
- return float(x), float(y)
161
-
162
-
163
- class IDRiDLocalizationDataset(Dataset):
164
- def __init__(
165
- self,
166
- pairs: list[IDRiDLocalizationPair],
167
- *,
168
- size: tuple[int, int] = (512, 768),
169
- train: bool = True,
170
- sigma: float = 12.0,
171
- ):
172
- self.pairs = pairs
173
- self.size = size
174
- self.sigma = sigma
175
- self.transform = build_localization_transforms(size, train)
176
-
177
- def __len__(self) -> int:
178
- return len(self.pairs)
179
-
180
- def __getitem__(self, idx: int):
181
- pair = self.pairs[idx]
182
- image = cv2.cvtColor(cv2.imread(str(pair.image_path), cv2.IMREAD_COLOR), cv2.COLOR_BGR2RGB)
183
- keypoints = [pair.od_xy, pair.fovea_xy]
184
-
185
- out = self.transform(image=image, keypoints=keypoints)
186
- h, w = self.size
187
- (od_x, od_y), (fov_x, fov_y) = out["keypoints"]
188
-
189
- od_heatmap = make_gaussian_heatmap(h, w, od_x, od_y, self.sigma)
190
- fovea_heatmap = make_gaussian_heatmap(h, w, fov_x, fov_y, self.sigma)
191
- heatmaps = torch.from_numpy(np.stack([od_heatmap, fovea_heatmap]))
192
-
193
- targets_xy = torch.tensor([[od_x, od_y], [fov_x, fov_y]], dtype=torch.float32)
194
- return out["image"], heatmaps, targets_xy
195
-
196
-
197
- def working_res_od_diameter(size: tuple[int, int]) -> float:
198
- """MEAN_OD_DIAMETER_PX_FULL_RES rescaled to a `size=(h, w)` working
199
- resolution, assuming the uniform-scale resize `build_localization_transforms`
200
- actually performs (height ratio == width ratio, since the working
201
- aspect ratio is chosen to match IDRiD's real one)."""
202
- scale = size[0] / _FULL_RES_H
203
- return MEAN_OD_DIAMETER_PX_FULL_RES * scale
204
-
205
-
206
- def euclidean(a: tuple[float, float], b: tuple[float, float]) -> float:
207
- return math.hypot(a[0] - b[0], a[1] - b[1])
208
-
209
-
210
- class LocalizationModule(L.LightningModule):
211
- """MSE between predicted (sigmoid) heatmaps and target Gaussians --
212
- standard for heatmap-regression landmark detection. Validation reads the
213
- point back off each predicted channel's argmax and reports Euclidean
214
- error in OD-diameter units, the roadmap's own stated success unit.
215
- """
216
-
217
- def __init__(
218
- self,
219
- model: nn.Module,
220
- *,
221
- lr: float = 1e-4,
222
- weight_decay: float = 1e-4,
223
- working_od_diameter: float = 1.0,
224
- max_epochs: int = 40,
225
- ):
226
- super().__init__()
227
- self.save_hyperparameters(ignore=["model"])
228
- self.model = model
229
- self._val_pred_heatmaps: list[torch.Tensor] = []
230
- self._val_target_xy: list[torch.Tensor] = []
231
-
232
- def forward(self, x):
233
- return self.model(x)
234
-
235
- def _loss(self, logits: torch.Tensor, target_heatmaps: torch.Tensor) -> torch.Tensor:
236
- probs = torch.sigmoid(logits)
237
- return nn.functional.mse_loss(probs, target_heatmaps)
238
-
239
- def training_step(self, batch, batch_idx):
240
- x, heatmaps, _ = batch
241
- logits = self(x)
242
- loss = self._loss(logits, heatmaps)
243
- if not torch.isfinite(loss):
244
- raise RuntimeError(f"Localisation loss is {loss.item()} at step {batch_idx}. Diverged.")
245
- self.log("train/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
246
- return loss
247
-
248
- def validation_step(self, batch, batch_idx):
249
- x, heatmaps, targets_xy = batch
250
- logits = self(x)
251
- loss = self._loss(logits, heatmaps)
252
- self.log("val/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
253
- self._val_pred_heatmaps.append(torch.sigmoid(logits).detach().float().cpu())
254
- self._val_target_xy.append(targets_xy.detach().cpu())
255
-
256
- def on_validation_epoch_end(self):
257
- if not self._val_pred_heatmaps:
258
- return
259
- preds = torch.cat(self._val_pred_heatmaps).numpy()
260
- targets = torch.cat(self._val_target_xy).numpy()
261
-
262
- od_errors, fovea_errors = [], []
263
- diameter = self.hparams.working_od_diameter
264
- for i in range(preds.shape[0]):
265
- od_pred = heatmap_argmax(preds[i, 0])
266
- fovea_pred = heatmap_argmax(preds[i, 1])
267
- od_errors.append(euclidean(od_pred, tuple(targets[i, 0])) / diameter)
268
- fovea_errors.append(euclidean(fovea_pred, tuple(targets[i, 1])) / diameter)
269
-
270
- od_mean = float(np.mean(od_errors))
271
- fovea_mean = float(np.mean(fovea_errors))
272
- self.log("val/od_error_diameters", od_mean, prog_bar=True)
273
- self.log("val/fovea_error_diameters", fovea_mean, prog_bar=True)
274
- self.log("val/mean_error_diameters", (od_mean + fovea_mean) / 2.0, prog_bar=True)
275
-
276
- self._val_pred_heatmaps.clear()
277
- self._val_target_xy.clear()
278
-
279
- def configure_optimizers(self):
280
- optimiser = torch.optim.AdamW(
281
- self.model.parameters(), lr=self.hparams.lr, weight_decay=self.hparams.weight_decay
282
- )
283
- scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
284
- optimiser, T_max=self.hparams.max_epochs
285
- )
286
- return {
287
- "optimizer": optimiser,
288
- "lr_scheduler": {"scheduler": scheduler, "interval": "epoch"},
289
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/segmentation/metrics.py DELETED
@@ -1,131 +0,0 @@
1
- """Pixel-level segmentation metrics.
2
-
3
- AUPRC, not AUROC, is the metric of record here -- the roadmap says why
4
- (docs/04_ROADMAP.md, Phase 4): "AUROC is meaningless at <0.1% positive
5
- pixels." Measured directly on this project's own data (IDRiD_41_EX.tif):
6
- 0.068% positive. AUROC's false-positive-rate denominator is dominated by the
7
- ~99.9% true negatives regardless of model quality, so a model that is barely
8
- better than chance can still post a high AUROC; AUPRC's precision term is not
9
- protected by that denominator and actually penalises a flood of false
10
- positives.
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- import numpy as np
16
-
17
- __all__ = [
18
- "pixel_auprc",
19
- "pixel_auroc",
20
- "dice_coefficient",
21
- "best_dice_threshold",
22
- "best_f1_threshold",
23
- ]
24
-
25
-
26
- def pixel_auprc(y_true: np.ndarray, y_score: np.ndarray) -> float:
27
- """Average precision over every pixel passed in, pooled -- not averaged
28
- per image first. Per-image AUPRC is unstable (often undefined) for an
29
- image with zero or a handful of positive pixels; pooling across images
30
- before scoring is what IDRiD-scale lesion evaluation actually needs.
31
-
32
- Args:
33
- y_true: any shape, values in {0, 1} (or bool).
34
- y_score: same shape, predicted probability in [0, 1].
35
- """
36
- from sklearn.metrics import average_precision_score
37
-
38
- y_true = np.asarray(y_true).ravel().astype(int)
39
- y_score = np.asarray(y_score).ravel().astype(float)
40
- if y_true.sum() == 0:
41
- raise ValueError("no positive pixels in y_true -- AUPRC is undefined without any")
42
- return float(average_precision_score(y_true, y_score))
43
-
44
-
45
- def pixel_auroc(y_true: np.ndarray, y_score: np.ndarray) -> float:
46
- """AUROC, pooled the same way as pixel_auprc. Meaningless at IDRiD-lesion-scale
47
- imbalance (<0.1% positive, see pixel_auprc's own docstring) but the roadmap
48
- names it explicitly as the vessel-segmentation target metric -- vessels are
49
- a measured ~7.5% of DRIVE pixels, not extreme enough for AUROC's
50
- false-positive-rate denominator to be swamped by trivial true negatives the
51
- way it is for lesions."""
52
- from sklearn.metrics import roc_auc_score
53
-
54
- y_true = np.asarray(y_true).ravel().astype(int)
55
- y_score = np.asarray(y_score).ravel().astype(float)
56
- if y_true.sum() == 0 or y_true.sum() == len(y_true):
57
- raise ValueError("AUROC is undefined with only one class present in y_true")
58
- return float(roc_auc_score(y_true, y_score))
59
-
60
-
61
- def dice_coefficient(y_true: np.ndarray, y_pred_binary: np.ndarray, *, eps: float = 1e-7) -> float:
62
- """2*|A∩B| / (|A|+|B|) at a fixed threshold -- reported alongside AUPRC,
63
- not instead of it, since Dice needs a threshold AUPRC does not."""
64
- y_true = np.asarray(y_true).ravel().astype(bool)
65
- y_pred = np.asarray(y_pred_binary).ravel().astype(bool)
66
- intersection = np.logical_and(y_true, y_pred).sum()
67
- return float((2.0 * intersection + eps) / (y_true.sum() + y_pred.sum() + eps))
68
-
69
-
70
- def best_dice_threshold(
71
- y_true: np.ndarray, y_score: np.ndarray, *, thresholds: np.ndarray | None = None
72
- ) -> tuple[float, float]:
73
- """Threshold that maximises Dice, swept on a fixed grid (not every unique
74
- score value, unlike drdetect.eval.metrics.choose_threshold_for_sensitivity
75
- -- pixel-level score arrays here run into the hundreds of millions of
76
- values, and a per-unique-value sweep at that scale does not finish).
77
-
78
- **Call this on the VALIDATION set only** -- e.g. the internal fold's held-
79
- out images, never the 27 official IDRiD test images. Freeze the returned
80
- threshold and apply it to the test set with dice_coefficient(), the same
81
- selection/evaluation separation drdetect.eval.metrics already documents
82
- for the grading model's operating point.
83
-
84
- Fixed 0.5 is not a safe default here: an undertrained model's sigmoid
85
- outputs can sit almost entirely below 0.5 even where its pixel ranking is
86
- good, so Dice@0.5 can read close to zero while AUPRC (threshold-free)
87
- reads far higher -- measured directly on a 2-epoch checkpoint (AUPRC
88
- 0.409, Dice@0.5 0.037) versus the fully-trained one (AUPRC 0.830, Dice@0.5
89
- 0.703) on the identical 27 test images.
90
-
91
- Returns:
92
- (best_threshold, best_dice) from the grid.
93
- """
94
- y_true = np.asarray(y_true).ravel()
95
- y_score = np.asarray(y_score).ravel()
96
- if thresholds is None:
97
- thresholds = np.linspace(0.025, 0.975, 39)
98
-
99
- best_threshold, best_dice = 0.5, -1.0
100
- for t in thresholds:
101
- dice = dice_coefficient(y_true, y_score > t)
102
- if dice > best_dice:
103
- best_threshold, best_dice = float(t), dice
104
- return best_threshold, best_dice
105
-
106
-
107
- def best_f1_threshold(
108
- y_true: np.ndarray, y_score: np.ndarray, *, thresholds: np.ndarray | None = None
109
- ) -> tuple[float, float]:
110
- """Same grid-sweep pattern as `best_dice_threshold`, scored with F1
111
- instead of Dice -- used where examples are discrete items (microaneurysm
112
- *candidates*, one score each) rather than pixels, so a pixel-overlap
113
- ratio doesn't apply but the precision/recall trade-off it's standing in
114
- for still does. Tune on validation only, freeze, apply once to test --
115
- same separation `best_dice_threshold` already documents.
116
- """
117
- y_true = np.asarray(y_true).ravel().astype(bool)
118
- y_score = np.asarray(y_score).ravel()
119
- if thresholds is None:
120
- thresholds = np.linspace(0.025, 0.975, 39)
121
-
122
- best_threshold, best_f1 = 0.5, -1.0
123
- for t in thresholds:
124
- pred = y_score > t
125
- tp = int(np.logical_and(y_true, pred).sum())
126
- fp = int(np.logical_and(~y_true, pred).sum())
127
- fn = int(np.logical_and(y_true, ~pred).sum())
128
- f1 = (2 * tp) / (2 * tp + fp + fn) if (2 * tp + fp + fn) > 0 else 0.0
129
- if f1 > best_f1:
130
- best_threshold, best_f1 = float(t), f1
131
- return best_threshold, best_f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/segmentation/microaneurysms.py DELETED
@@ -1,495 +0,0 @@
1
- """Microaneurysm detection: candidate generation + small-CNN classification.
2
-
3
- The roadmap (docs/04_ROADMAP.md, Phase 4) is explicit that this lesion type
4
- does *not* use the same DeepLabV3+ pixel-segmentation harness as the other
5
- four: "morphological top-hat + matched filter candidate generation -> small
6
- CNN classifier. Do not expect plain segmentation to work here." Measured
7
- directly from all 81 IDRiD microaneurysm masks (train+test): 3,497 instances,
8
- equivalent-diameter median 17.3px, p5 10.0px, p95 29.9px at full (2848x4288)
9
- resolution -- resolvable at full res, but this project's own Phase 3 findings
10
- (docs/07_PHASE3_RESULTS.md) already established that lesions this small are
11
- destroyed by the working resolutions (384-1024px) used elsewhere in this
12
- project, and a patch-sampled decoder trained at 512px (the other lesions'
13
- harness) would be learning from images where a microaneurysm is ~2px wide.
14
-
15
- Candidate generation runs a black top-hat (highlights small dark blobs
16
- against a brighter surround -- microaneurysms are dark red dots) on the green
17
- channel at full resolution, where the classes of interest are still several
18
- pixels wide. This is a classical, deterministic, untrained step -- it proposes
19
- candidates, it does not decide; the small CNN afterwards is what actually
20
- distinguishes a true microaneurysm from a vessel cross-section, noise speckle,
21
- or the countless other things a black top-hat also lights up.
22
- """
23
-
24
- from __future__ import annotations
25
-
26
- import gc
27
- from dataclasses import dataclass
28
-
29
- import cv2
30
- import lightning as L
31
- import numpy as np
32
- import torch
33
- import torch.nn as nn
34
- import torch.nn.functional as F
35
- from torch.utils.data import Dataset
36
-
37
- __all__ = [
38
- "Candidate",
39
- "tophat_response",
40
- "find_candidates",
41
- "label_candidates",
42
- "generation_recall",
43
- "extract_patch",
44
- "build_candidate_examples",
45
- "PatchDataset",
46
- "PatchClassifier",
47
- "score_candidates",
48
- "ClassifierModule",
49
- ]
50
-
51
- DEFAULT_PATCH_SIZE = 33
52
-
53
-
54
- @dataclass(frozen=True)
55
- class Candidate:
56
- cx: float
57
- cy: float
58
- area: int
59
-
60
-
61
- def tophat_response(image_bgr: np.ndarray, *, disk_radius: int = 16) -> np.ndarray:
62
- """Black top-hat (closing - image) on the median-denoised green channel.
63
-
64
- `disk_radius=16` (33px-diameter structuring element): the disk must be
65
- larger than a microaneurysm for the closing step to erase it and leave a
66
- peak in the top-hat response -- 33px comfortably exceeds the measured p95
67
- equivalent diameter (29.9px) without being so large it also erases small
68
- real vessel gaps, which would flood the candidate set with vessel debris.
69
- """
70
- green = image_bgr[:, :, 1]
71
- green = cv2.medianBlur(green, 3)
72
- ksize = 2 * disk_radius + 1
73
- kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (ksize, ksize))
74
- return cv2.morphologyEx(green, cv2.MORPH_BLACKHAT, kernel)
75
-
76
-
77
- def find_candidates(
78
- image_bgr: np.ndarray,
79
- *,
80
- disk_radius: int = 16,
81
- response_percentile: float = 95.0,
82
- min_area: int = 3,
83
- max_area: int = 1500,
84
- min_gray: float = 15.0,
85
- ) -> list[Candidate]:
86
- """Percentile-threshold the top-hat response, then connected-component it
87
- into candidate blobs.
88
-
89
- **Not Otsu, measured, not assumed**: Otsu was the first thing tried here,
90
- and it fails badly on this response. Checked directly on IDRiD_01: Otsu
91
- picks a threshold of 4 (out of a max response of 71), marking 33.8% of
92
- the *entire image* as foreground -- because the top-hat response over a
93
- 12.2-megapixel fundus image is a skewed, long-tailed distribution
94
- dominated by low-level texture and JPEG noise, not the clean bimodal
95
- signal Otsu assumes. The 33.8% "foreground" merges into huge connected
96
- blobs (one component covered 1.76M px), which then fail `max_area` and
97
- get discarded entirely -- taking any real microaneurysm sitting inside
98
- them down with it. Measured recall on IDRiD_01 with Otsu: 3/18 true
99
- instances. A fixed percentile of the response's own distribution sidesteps
100
- the bimodality assumption entirely and adapts per-image; swept on 5 real
101
- training images (IDRiD_01/02/10/20/33), the 95th percentile recovered
102
- 91-100% of true instances per image (100%, 97.6%, 92.1%, 91.3%, 93.7%)
103
- vs. Otsu's 16.7% on IDRiD_01 (3/18) -- at a candidate count Otsu actually
104
- made *worse*, not better, despite being far more permissive.
105
-
106
- `min_area=3, max_area=1500`: measured true-instance area at full res
107
- ranges 13-2231px (median 234, p95 704) -- the lower bound is set below
108
- the smallest real instance to avoid discarding true microaneurysms before
109
- the classifier even sees them (a generation-stage miss can never be
110
- recovered later; a false positive can still be rejected), the upper
111
- bound well above p95 to keep merged/clustered annotations reachable while
112
- still rejecting obviously-too-large blobs (a stray haemorrhage or vessel
113
- segment surviving the top-hat).
114
-
115
- `min_gray=15.0`: IDRiD images carry a near-black margin outside the
116
- circular fundus; without this check, JPEG ringing at that boundary
117
- produces top-hat "candidates" that are pure imaging artifact, not tissue.
118
- """
119
- response = tophat_response(image_bgr, disk_radius=disk_radius)
120
- threshold = float(np.percentile(response, response_percentile))
121
- binary = (response > threshold).astype(np.uint8) * 255
122
- n, _labels, stats, centroids = cv2.connectedComponentsWithStats(binary, connectivity=8)
123
- gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
124
-
125
- candidates = []
126
- for i in range(1, n): # 0 is the background component
127
- area = int(stats[i, cv2.CC_STAT_AREA])
128
- if area < min_area or area > max_area:
129
- continue
130
- cx, cy = centroids[i]
131
- if gray[int(round(cy)), int(round(cx))] < min_gray:
132
- continue
133
- candidates.append(Candidate(cx=float(cx), cy=float(cy), area=area))
134
- return candidates
135
-
136
-
137
- def label_candidates(candidates: list[Candidate], mask: np.ndarray) -> np.ndarray:
138
- """True where a candidate's centroid lands on a positive mask pixel."""
139
- labels = np.zeros(len(candidates), dtype=bool)
140
- h, w = mask.shape[:2]
141
- for i, c in enumerate(candidates):
142
- x, y = int(round(c.cx)), int(round(c.cy))
143
- if 0 <= y < h and 0 <= x < w:
144
- labels[i] = mask[y, x] > 0
145
- return labels
146
-
147
-
148
- def generation_recall(candidates: list[Candidate], mask: np.ndarray) -> tuple[int, int]:
149
- """(instances with >=1 candidate landing inside them, total true instances).
150
-
151
- This is the ceiling end-to-end recall can ever reach: the classifier only
152
- accepts or rejects candidates the generator already proposed, so a true
153
- microaneurysm the generator never proposed is unrecoverable downstream.
154
- Reported separately from the classifier's own recall so a low end-to-end
155
- number can be attributed to the right stage.
156
- """
157
- binary_mask = (mask > 0).astype(np.uint8)
158
- n_components, comp_labels = cv2.connectedComponents(binary_mask, connectivity=8)
159
- n_true = n_components - 1
160
- if n_true == 0:
161
- return 0, 0
162
- h, w = mask.shape[:2]
163
- recovered_ids: set[int] = set()
164
- for c in candidates:
165
- x, y = int(round(c.cx)), int(round(c.cy))
166
- if 0 <= y < h and 0 <= x < w:
167
- comp_id = int(comp_labels[y, x])
168
- if comp_id > 0:
169
- recovered_ids.add(comp_id)
170
- return len(recovered_ids), n_true
171
-
172
-
173
- def extract_patch(image_bgr: np.ndarray, cx: float, cy: float, size: int) -> np.ndarray:
174
- """A `size`x`size` crop centred on (cx, cy), reflect-padded at borders
175
- (a candidate near the image edge is common -- fundus lesions don't avoid
176
- the periphery -- and reflect padding keeps the patch's local texture
177
- statistics sane instead of introducing a hard black edge a real
178
- microaneurysm patch would never have).
179
-
180
- Pads only the small overhang beyond the image edge, not the whole image.
181
- An earlier version called `cv2.copyMakeBorder` on the full `image_bgr`
182
- first and cropped afterwards -- measured directly, that allocates a full
183
- ~36MB padded copy of the *entire* 2848x4288 image on every single call,
184
- and this is called once per kept candidate (positives plus up to
185
- hundreds of negatives, per image). Across even a handful of images that
186
- drove this project's development machine to a 39GB peak memory
187
- footprint and an aborted process; cropping first and padding only the
188
- (at most `size`-pixel) overhang produces bit-identical output at a
189
- fraction of a percent of the cost.
190
- """
191
- h, w = image_bgr.shape[:2]
192
- half = size // 2
193
- x, y = int(round(cx)), int(round(cy))
194
- top, left = y - half, x - half
195
- bottom, right = top + size, left + size
196
-
197
- top_clip, left_clip = max(top, 0), max(left, 0)
198
- bottom_clip, right_clip = min(bottom, h), min(right, w)
199
- crop = image_bgr[top_clip:bottom_clip, left_clip:right_clip]
200
-
201
- pad_top, pad_left = top_clip - top, left_clip - left
202
- pad_bottom, pad_right = bottom - bottom_clip, right - right_clip
203
- if pad_top or pad_left or pad_bottom or pad_right:
204
- crop = cv2.copyMakeBorder(
205
- crop, pad_top, pad_bottom, pad_left, pad_right, cv2.BORDER_REFLECT_101
206
- )
207
- return crop
208
-
209
-
210
- def build_candidate_examples(
211
- pairs,
212
- *,
213
- disk_radius: int = 16,
214
- response_percentile: float = 95.0,
215
- patch_size: int = DEFAULT_PATCH_SIZE,
216
- negative_ratio: float = 10.0,
217
- max_negatives_per_image: int = 300,
218
- seed: int = 42,
219
- ) -> tuple[np.ndarray, np.ndarray, dict]:
220
- """Run candidate generation + ground-truth matching across a list of
221
- `LesionImagePair`s (see drdetect.segmentation.dataset), returning
222
- `(patches[N,size,size,3] uint8 BGR, labels[N] int64, stats)`.
223
-
224
- Negatives are heavily subsampled per image: a full-resolution top-hat
225
- routinely proposes far more spurious blobs than there are true
226
- microaneurysms in an image, and keeping every one would both blow up
227
- memory and swamp the true positives the way naive whole-image BCE
228
- already does for pixel segmentation (drdetect.segmentation.module). Kept
229
- at `negative_ratio` times that image's positive count, floored at 50 so
230
- an image with zero recovered positives still contributes hard-negative
231
- signal rather than none, capped at `max_negatives_per_image` so no single
232
- image dominates the pool.
233
- """
234
- rng = np.random.default_rng(seed)
235
- all_patches: list[np.ndarray] = []
236
- all_labels: list[bool] = []
237
- total_recovered, total_true = 0, 0
238
- per_image = []
239
-
240
- for pair in pairs:
241
- image = cv2.imread(str(pair.image_path))
242
- mask = cv2.imread(str(pair.mask_path), cv2.IMREAD_GRAYSCALE)
243
- candidates = find_candidates(
244
- image, disk_radius=disk_radius, response_percentile=response_percentile
245
- )
246
- labels = label_candidates(candidates, mask)
247
- recovered, n_true = generation_recall(candidates, mask)
248
- total_recovered += recovered
249
- total_true += n_true
250
-
251
- pos_idx = np.flatnonzero(labels)
252
- neg_idx = np.flatnonzero(~labels)
253
- n_neg_keep = min(len(neg_idx), max(50, int(len(pos_idx) * negative_ratio)))
254
- n_neg_keep = min(n_neg_keep, max_negatives_per_image)
255
- neg_keep = (
256
- rng.choice(neg_idx, size=n_neg_keep, replace=False) if n_neg_keep > 0 else neg_idx[:0]
257
- )
258
- keep_idx = np.concatenate([pos_idx, neg_keep])
259
-
260
- for idx in keep_idx:
261
- c = candidates[idx]
262
- all_patches.append(extract_patch(image, c.cx, c.cy, patch_size))
263
- all_labels.append(bool(labels[idx]))
264
-
265
- per_image.append(
266
- {
267
- "image_id": pair.image_id,
268
- "n_candidates": len(candidates),
269
- "n_positive_candidates": int(labels.sum()),
270
- "n_recovered_instances": recovered,
271
- "n_true_instances": n_true,
272
- "n_examples_kept": int(len(keep_idx)),
273
- }
274
- )
275
- # Measured directly: candidate generation on one full-resolution
276
- # (2848x4288) IDRiD image peaks around 640MB (connectedComponentsWithStats'
277
- # internal bookkeeping over tens of thousands of components, on top of
278
- # the image/response/binary/labels/gray arrays it holds at once). That
279
- # is fine for one image; left to Python's own GC timing across a full
280
- # train+val pair list it measurably compounded into severe system-wide
281
- # memory pressure on this project's 16GB development machine. An
282
- # explicit del + collect between images is cheap insurance against that.
283
- del image, mask, candidates, labels
284
- gc.collect()
285
-
286
- if all_patches:
287
- patches = np.stack(all_patches).astype(np.uint8)
288
- else:
289
- patches = np.zeros((0, patch_size, patch_size, 3), dtype=np.uint8)
290
- labels_arr = np.array(all_labels, dtype=np.int64)
291
-
292
- stats = {
293
- "n_images": len(pairs),
294
- "n_true_instances": total_true,
295
- "n_recovered_instances": total_recovered,
296
- "generation_recall": (total_recovered / total_true) if total_true else float("nan"),
297
- "n_candidates_total": int(sum(p["n_candidates"] for p in per_image)),
298
- "n_positive_candidates": int(labels_arr.sum()),
299
- "n_examples_kept": int(len(labels_arr)),
300
- "per_image": per_image,
301
- }
302
- return patches, labels_arr, stats
303
-
304
-
305
- _IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
306
- _IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
307
-
308
-
309
- class PatchDataset(Dataset):
310
- """Pre-extracted candidate patches, normalised on the fly.
311
-
312
- Patches are tiny (33x33x3 uint8 by default, ~3KB each) and already
313
- extracted by `build_candidate_examples` -- unlike `IDRiDLesionDataset`,
314
- there's no full-resolution image I/O left to amortise per `__getitem__`,
315
- so a plain in-memory array is simpler than another on-the-fly sampler.
316
- """
317
-
318
- def __init__(self, patches: np.ndarray, labels: np.ndarray, *, train: bool = True):
319
- self.patches = patches
320
- self.labels = labels
321
- self.train = train
322
-
323
- def __len__(self) -> int:
324
- return len(self.labels)
325
-
326
- def __getitem__(self, idx: int) -> tuple[torch.Tensor, torch.Tensor]:
327
- patch = self.patches[idx]
328
- if self.train:
329
- if np.random.rand() < 0.5:
330
- patch = patch[:, ::-1]
331
- if np.random.rand() < 0.5:
332
- patch = patch[::-1, :]
333
- patch = np.rot90(patch, k=int(np.random.randint(4)))
334
- rgb = np.ascontiguousarray(patch[:, :, ::-1]).astype(np.float32) / 255.0
335
- rgb = (rgb - _IMAGENET_MEAN) / _IMAGENET_STD
336
- tensor = torch.from_numpy(rgb.transpose(2, 0, 1).copy()).float()
337
- label = torch.tensor(float(self.labels[idx]), dtype=torch.float32)
338
- return tensor, label
339
-
340
-
341
- class PatchClassifier(nn.Module):
342
- """Small CNN: a candidate patch in, one accept/reject logit out.
343
-
344
- Deliberately tiny (~26K params) next to the 22.4M-param DeepLabV3+ used
345
- for pixel segmentation elsewhere in this project (drdetect.segmentation.
346
- model) -- the roadmap specifies a "small CNN classifier" because the
347
- input here is a fixed, already-localised, tiny patch needing only a yes/no
348
- answer, not a full image needing spatial decoding back to pixel masks.
349
- """
350
-
351
- def __init__(self, in_channels: int = 3):
352
- super().__init__()
353
- self.features = nn.Sequential(
354
- nn.Conv2d(in_channels, 16, 3, padding=1),
355
- nn.BatchNorm2d(16),
356
- nn.ReLU(inplace=True),
357
- nn.MaxPool2d(2),
358
- nn.Conv2d(16, 32, 3, padding=1),
359
- nn.BatchNorm2d(32),
360
- nn.ReLU(inplace=True),
361
- nn.MaxPool2d(2),
362
- nn.Conv2d(32, 64, 3, padding=1),
363
- nn.BatchNorm2d(64),
364
- nn.ReLU(inplace=True),
365
- nn.AdaptiveAvgPool2d(1),
366
- )
367
- self.classifier = nn.Linear(64, 1)
368
-
369
- def forward(self, x: torch.Tensor) -> torch.Tensor:
370
- features = self.features(x).flatten(1)
371
- return self.classifier(features).squeeze(-1)
372
-
373
-
374
- def score_candidates(
375
- image_bgr: np.ndarray,
376
- mask: np.ndarray,
377
- model: nn.Module,
378
- *,
379
- device: str = "cpu",
380
- disk_radius: int = 16,
381
- response_percentile: float = 95.0,
382
- patch_size: int = DEFAULT_PATCH_SIZE,
383
- batch_size: int = 256,
384
- ) -> dict:
385
- """Run generation + classification on one image end to end, in batches --
386
- used by scripts/evaluate_microaneurysms.py once per official test image.
387
-
388
- Unlike `build_candidate_examples` (which subsamples negatives for
389
- training), this keeps *every* candidate: evaluation needs the true,
390
- unsampled precision/recall/false-positive-rate the deployed pipeline
391
- would actually see, not a class-balanced training view of it. Patches are
392
- extracted and scored one batch at a time rather than materialising every
393
- candidate patch for the image up front -- a single full-resolution IDRiD
394
- image can propose tens of thousands of candidates.
395
- """
396
- candidates = find_candidates(
397
- image_bgr, disk_radius=disk_radius, response_percentile=response_percentile
398
- )
399
- labels = label_candidates(candidates, mask)
400
- recovered, n_true = generation_recall(candidates, mask)
401
-
402
- scores = np.zeros(len(candidates), dtype=np.float32)
403
- model.eval()
404
- with torch.no_grad():
405
- for start in range(0, len(candidates), batch_size):
406
- batch = candidates[start : start + batch_size]
407
- patches = np.stack([extract_patch(image_bgr, c.cx, c.cy, patch_size) for c in batch])
408
- rgb = patches[:, :, :, ::-1].astype(np.float32) / 255.0
409
- rgb = (rgb - _IMAGENET_MEAN) / _IMAGENET_STD
410
- tensor = torch.from_numpy(rgb.transpose(0, 3, 1, 2).copy()).float().to(device)
411
- logits = model(tensor)
412
- scores[start : start + len(batch)] = torch.sigmoid(logits).cpu().numpy()
413
-
414
- return {
415
- "candidates": candidates,
416
- "scores": scores,
417
- "labels": labels,
418
- "n_recovered": recovered,
419
- "n_true": n_true,
420
- }
421
-
422
-
423
- class ClassifierModule(L.LightningModule):
424
- """BCE-only (no Dice term): unlike pixel segmentation, there is no
425
- spatial overlap to score here -- each example is one candidate, one
426
- label, so plain weighted BCE is the whole loss, with `pos_weight` set
427
- from the same-style empirical measurement used throughout this project
428
- (the true ratio in the actually-sampled, negative-subsampled candidate
429
- pool, not a theoretical one)."""
430
-
431
- def __init__(
432
- self,
433
- model: nn.Module,
434
- *,
435
- lr: float = 1e-3,
436
- weight_decay: float = 1e-4,
437
- pos_weight: float = 1.0,
438
- max_epochs: int = 30,
439
- ):
440
- super().__init__()
441
- self.save_hyperparameters(ignore=["model"])
442
- self.model = model
443
- self.register_buffer("_pos_weight", torch.tensor(pos_weight))
444
- self._val_logits: list[torch.Tensor] = []
445
- self._val_targets: list[torch.Tensor] = []
446
-
447
- def forward(self, x):
448
- return self.model(x)
449
-
450
- def training_step(self, batch, batch_idx):
451
- x, y = batch
452
- logits = self(x)
453
- loss = F.binary_cross_entropy_with_logits(logits, y, pos_weight=self._pos_weight)
454
- if not torch.isfinite(loss):
455
- raise RuntimeError(f"Classifier loss is {loss.item()} at step {batch_idx}. Diverged.")
456
- self.log("train/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
457
- return loss
458
-
459
- def validation_step(self, batch, batch_idx):
460
- x, y = batch
461
- logits = self(x)
462
- loss = F.binary_cross_entropy_with_logits(logits, y, pos_weight=self._pos_weight)
463
- self.log("val/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
464
- self._val_logits.append(logits.detach().float().cpu())
465
- self._val_targets.append(y.detach().cpu())
466
-
467
- def on_validation_epoch_end(self):
468
- if not self._val_logits:
469
- return
470
- logits = torch.cat(self._val_logits)
471
- targets = torch.cat(self._val_targets)
472
- probs = torch.sigmoid(logits).numpy()
473
- targets_np = targets.numpy()
474
-
475
- if targets_np.sum() > 0:
476
- # pixel_auprc is a generic ravel + average_precision_score despite
477
- # its name -- reused as-is rather than duplicated for candidates.
478
- from drdetect.segmentation.metrics import pixel_auprc
479
-
480
- self.log("val/auprc", pixel_auprc(targets_np, probs), prog_bar=True)
481
- self.log("val/accuracy", float(((probs > 0.5) == (targets_np > 0.5)).mean()))
482
- self._val_logits.clear()
483
- self._val_targets.clear()
484
-
485
- def configure_optimizers(self):
486
- optimiser = torch.optim.AdamW(
487
- self.model.parameters(), lr=self.hparams.lr, weight_decay=self.hparams.weight_decay
488
- )
489
- scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
490
- optimiser, T_max=self.hparams.max_epochs
491
- )
492
- return {
493
- "optimizer": optimiser,
494
- "lr_scheduler": {"scheduler": scheduler, "interval": "epoch"},
495
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/segmentation/model.py DELETED
@@ -1,47 +0,0 @@
1
- """Lesion segmentation backbone.
2
-
3
- DeepLabV3+ (Chen et al. 2018), not plain U-Net: the roadmap names both as
4
- options, and DeepLabV3+'s atrous spatial pyramid pooling gives it multi-scale
5
- context that plain U-Net's decoder lacks -- useful here because IDRiD's
6
- lesion classes span very different physical sizes (a single-pixel
7
- microaneurysm vs. a hard-exudate cluster), even though this module only
8
- trains hard exudates so far.
9
-
10
- Uses segmentation_models_pytorch (already a declared but, until now, unused
11
- dependency -- same situation grad-cam and reportlab were in before Phase 2),
12
- not a hand-rolled decoder, for the same reason drdetect.grading.model uses
13
- timm instead of a hand-rolled EfficientNet: a maintained, widely-checked
14
- reference implementation over a bespoke one.
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- import torch.nn as nn
20
-
21
- __all__ = ["build_segmentation_model"]
22
-
23
-
24
- def build_segmentation_model(
25
- encoder_name: str = "resnet34",
26
- *,
27
- pretrained: bool = True,
28
- classes: int = 1,
29
- ) -> nn.Module:
30
- """Binary (or multi-class) lesion segmentation model.
31
-
32
- `classes=1`: one lesion type per model, matching how this project's IDRiD
33
- masks ship -- a separate binary mask file per lesion type, not one
34
- multi-class mask. Training one model per lesion type (rather than a
35
- shared multi-head model) also means a lesion with very little training
36
- signal (soft exudates, per the roadmap's own scope-cut list) cannot drag
37
- down a better-supported class's gradient.
38
- """
39
- import segmentation_models_pytorch as smp
40
-
41
- return smp.DeepLabV3Plus(
42
- encoder_name=encoder_name,
43
- encoder_weights="imagenet" if pretrained else None,
44
- in_channels=3,
45
- classes=classes,
46
- activation=None, # raw logits out; loss/inference apply sigmoid explicitly
47
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/segmentation/module.py DELETED
@@ -1,120 +0,0 @@
1
- """Lightning module for lesion segmentation.
2
-
3
- BCE + Dice, not plain BCE: at <0.1% positive pixels (measured, see
4
- dataset.py), unweighted BCE's gradient is dominated by the trivial
5
- "predict background everywhere" solution long before it ever learns the
6
- lesion class -- the same class-imbalance failure mode this project already
7
- diagnosed once for CORN's conditional subsets (docs/07_PHASE3_RESULTS.md,
8
- Result 2), showing up again in a different part of the pipeline. Dice loss
9
- is imbalance-robust by construction (it scores overlap ratio, not per-pixel
10
- classification), so combining it with BCE keeps BCE's well-behaved gradients
11
- while Dice supplies the signal BCE alone would drown out.
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import lightning as L
17
- import torch
18
- import torch.nn as nn
19
- import torch.nn.functional as F
20
-
21
- from drdetect.segmentation.metrics import dice_coefficient, pixel_auprc
22
-
23
- __all__ = ["SegmentationModule", "soft_dice_loss"]
24
-
25
-
26
- def soft_dice_loss(
27
- logits: torch.Tensor, targets: torch.Tensor, *, eps: float = 1.0
28
- ) -> torch.Tensor:
29
- """Differentiable Dice loss on probabilities, not thresholded predictions.
30
-
31
- `eps=1.0` (not 1e-7): with this few positive pixels per patch, a tiny eps
32
- lets a patch with zero true positives and zero predicted positives produce
33
- a huge, meaningless gradient from floating-point noise in the ratio. A
34
- Dice-smoothing epsilon of 1.0 is standard practice for exactly this
35
- regime, not an arbitrary choice.
36
- """
37
- probs = torch.sigmoid(logits)
38
- intersection = (probs * targets).sum(dim=(1, 2, 3))
39
- union = probs.sum(dim=(1, 2, 3)) + targets.sum(dim=(1, 2, 3))
40
- dice = (2.0 * intersection + eps) / (union + eps)
41
- return 1.0 - dice.mean()
42
-
43
-
44
- class SegmentationModule(L.LightningModule):
45
- def __init__(
46
- self,
47
- model: nn.Module,
48
- *,
49
- lr: float = 1e-4,
50
- weight_decay: float = 1e-4,
51
- pos_weight: float = 1.0,
52
- dice_weight: float = 1.0,
53
- max_epochs: int = 40,
54
- ):
55
- super().__init__()
56
- self.save_hyperparameters(ignore=["model"])
57
- self.model = model
58
- self.register_buffer("_pos_weight", torch.tensor(pos_weight))
59
-
60
- self._val_logits: list[torch.Tensor] = []
61
- self._val_targets: list[torch.Tensor] = []
62
-
63
- def forward(self, x):
64
- return self.model(x)
65
-
66
- def _loss(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
67
- bce = F.binary_cross_entropy_with_logits(logits, targets, pos_weight=self._pos_weight)
68
- dice = soft_dice_loss(logits, targets)
69
- return bce + self.hparams.dice_weight * dice
70
-
71
- def training_step(self, batch, batch_idx):
72
- x, y = batch
73
- logits = self(x)
74
- loss = self._loss(logits, y)
75
- if not torch.isfinite(loss):
76
- raise RuntimeError(f"Segmentation loss is {loss.item()} at step {batch_idx}. Diverged.")
77
- self.log("train/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
78
- return loss
79
-
80
- def validation_step(self, batch, batch_idx):
81
- x, y = batch
82
- logits = self(x)
83
- loss = self._loss(logits, y)
84
- self.log("val/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
85
- self._val_logits.append(logits.detach().float().cpu())
86
- self._val_targets.append(y.detach().cpu())
87
-
88
- def on_validation_epoch_end(self):
89
- if not self._val_logits:
90
- return
91
- logits = torch.cat(self._val_logits)
92
- targets = torch.cat(self._val_targets)
93
- probs = torch.sigmoid(logits).numpy()
94
- targets_np = targets.numpy()
95
-
96
- if targets_np.sum() > 0:
97
- auprc = pixel_auprc(targets_np, probs)
98
- self.log("val/auprc", auprc, prog_bar=True)
99
- dice = dice_coefficient(targets_np, probs > 0.5)
100
- self.log("val/dice", dice, prog_bar=True)
101
- else:
102
- # A validation split with zero positive patches this epoch --
103
- # log nothing rather than a fabricated 0.0 that would look like a
104
- # real (bad) score instead of an undefined one.
105
- pass
106
-
107
- self._val_logits.clear()
108
- self._val_targets.clear()
109
-
110
- def configure_optimizers(self):
111
- optimiser = torch.optim.AdamW(
112
- self.model.parameters(), lr=self.hparams.lr, weight_decay=self.hparams.weight_decay
113
- )
114
- scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
115
- optimiser, T_max=self.hparams.max_epochs
116
- )
117
- return {
118
- "optimizer": optimiser,
119
- "lr_scheduler": {"scheduler": scheduler, "interval": "epoch"},
120
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/segmentation/quadrants.py DELETED
@@ -1,69 +0,0 @@
1
- """Quadrant mapping from the OD-fovea axis.
2
-
3
- Needed for ICDR's 4-2-1 rule (severe NPDR: haemorrhages in 4 quadrants, or
4
- venous beading in 2, or IRMA in 1), which counts lesions per retinal
5
- quadrant. This module defines the four quadrants geometrically, as the
6
- roadmap specifies: one dividing line through the optic disc along the
7
- OD-fovea axis, one perpendicular to it through the same point.
8
-
9
- **Labelling deliberately avoids true anatomical nasal/temporal names.**
10
- Whether the "away from fovea" side of the OD is nasal or temporal depends on
11
- which eye (OD/OS) the image is of, and neither IDRiD nor this project's
12
- other datasets carry reliable per-image eye-laterality labels to resolve
13
- that. The labels used here -- "foveal-side" / "disc-side" for the axis-
14
- parallel direction, "superior" / "inferior" for the perpendicular one --
15
- describe the geometry precisely without asserting an anatomical mapping
16
- this project cannot verify. A deployment with real laterality metadata
17
- could remap these to true nasal/temporal in one place (`QUADRANT_LABELS`)
18
- without touching the geometry.
19
- """
20
-
21
- from __future__ import annotations
22
-
23
- import numpy as np
24
-
25
- __all__ = ["quadrant_axes", "assign_quadrant", "QUADRANT_LABELS"]
26
-
27
- QUADRANT_LABELS = {
28
- (1, 1): "superior-foveal-side",
29
- (1, -1): "inferior-foveal-side",
30
- (-1, 1): "superior-disc-side",
31
- (-1, -1): "inferior-disc-side",
32
- }
33
-
34
-
35
- def quadrant_axes(
36
- od_xy: tuple[float, float], fovea_xy: tuple[float, float]
37
- ) -> tuple[np.ndarray, np.ndarray]:
38
- """Unit vectors (along_axis, perpendicular_axis) through the OD, where
39
- along_axis points from OD toward the fovea. Raises if OD and fovea
40
- coincide (undefined axis) -- that should never happen for real data and
41
- signals a genuine upstream bug (e.g. a broken localisation checkpoint)
42
- if it does.
43
- """
44
- od = np.asarray(od_xy, dtype=float)
45
- fovea = np.asarray(fovea_xy, dtype=float)
46
- axis = fovea - od
47
- norm = np.linalg.norm(axis)
48
- if norm < 1e-6:
49
- raise ValueError(f"OD and fovea coincide ({od_xy} == {fovea_xy}) -- cannot define an axis.")
50
- along = axis / norm
51
- # Perpendicular, image coordinates (y grows downward): rotate -90 degrees
52
- # so positive `perp` points toward smaller y (up the image, superior).
53
- perp = np.array([along[1], -along[0]])
54
- return along, perp
55
-
56
-
57
- def assign_quadrant(
58
- point_xy: tuple[float, float], od_xy: tuple[float, float], fovea_xy: tuple[float, float]
59
- ) -> str:
60
- """Which of the 4 quadrants `point_xy` falls in, relative to the OD as
61
- origin and the OD-fovea line as one dividing axis. See module docstring
62
- for why the labels describe geometry, not asserted anatomy."""
63
- along, perp = quadrant_axes(od_xy, fovea_xy)
64
- od = np.asarray(od_xy, dtype=float)
65
- v = np.asarray(point_xy, dtype=float) - od
66
-
67
- along_sign = 1 if np.dot(v, along) >= 0 else -1
68
- perp_sign = 1 if np.dot(v, perp) >= 0 else -1
69
- return QUADRANT_LABELS[(along_sign, perp_sign)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/segmentation/vessels.py DELETED
@@ -1,230 +0,0 @@
1
- """DRIVE vessel segmentation: dataset, model, and FOV-aware Lightning module.
2
-
3
- Deliberately separate from dataset.py/module.py (the IDRiD lesion harness),
4
- not sharing a base class with them, for reasons that are architectural, not
5
- just organisational:
6
-
7
- * DRIVE images are small and uniform (584x565) -- full images are trained
8
- on directly, no patch sampling.
9
- * Vessels are a measured ~7.5% of pixels, not IDRiD's <0.1% -- AUROC is a
10
- meaningful metric here (see metrics.pixel_auroc), where it is not for
11
- lesions.
12
- * Every DRIVE image has a field-of-view mask, and pixels outside it (the
13
- black surround around the circular retinal image) are not tissue at
14
- all -- they must be excluded from both loss and metrics, or a model that
15
- trivially gets the black border right looks better than it is. IDRiD's
16
- lesion harness has no equivalent concept.
17
-
18
- DRIVE's own public distribution ships vessel ground truth (`1st_manual/`)
19
- only for the 20 "training" images -- the 20 "test" images have no publicly
20
- released annotations (confirmed directly: `data/raw/drive/test/` contains
21
- only `images/` and `mask/`, no manual-annotation directory). All 20 labelled
22
- images are therefore used for k-fold cross-validation; there is no separate
23
- locked test split for this lesion type the way IDRiD has one for hard
24
- exudates.
25
- """
26
-
27
- from __future__ import annotations
28
-
29
- from dataclasses import dataclass
30
- from pathlib import Path
31
-
32
- import albumentations as A
33
- import cv2
34
- import lightning as L
35
- import numpy as np
36
- import torch
37
- import torch.nn.functional as F
38
- from albumentations.pytorch import ToTensorV2
39
- from torch.utils.data import Dataset
40
-
41
- from drdetect.segmentation.metrics import dice_coefficient, pixel_auroc
42
-
43
- __all__ = [
44
- "DriveVesselPair",
45
- "find_drive_vessel_pairs",
46
- "DriveVesselDataset",
47
- "VesselSegmentationModule",
48
- ]
49
-
50
-
51
- @dataclass(frozen=True)
52
- class DriveVesselPair:
53
- image_id: str
54
- image_path: Path
55
- mask_path: Path
56
- fov_path: Path
57
-
58
-
59
- def find_drive_vessel_pairs(drive_root: str | Path) -> list[DriveVesselPair]:
60
- """The 20 DRIVE "training" images -- the only ones with public vessel
61
- ground truth. Numbered 21-40 in DRIVE's own convention."""
62
- root = Path(drive_root) / "training"
63
- image_dir, mask_dir, fov_dir = root / "images", root / "1st_manual", root / "mask"
64
- pairs = []
65
- for image_path in sorted(image_dir.glob("*.tif")):
66
- image_id = image_path.stem.split("_")[0]
67
- mask_path = mask_dir / f"{image_id}_manual1.gif"
68
- fov_path = fov_dir / f"{image_id}_training_mask.gif"
69
- if mask_path.exists() and fov_path.exists():
70
- pairs.append(
71
- DriveVesselPair(
72
- image_id=image_id, image_path=image_path, mask_path=mask_path, fov_path=fov_path
73
- )
74
- )
75
- return pairs
76
-
77
-
78
- def _pad_to_multiple(arr: np.ndarray, multiple: int = 32, *, reflect: bool) -> np.ndarray:
79
- """Pad to a SQUARE size (both axes padded to the same target, from the
80
- larger of the two), not just each axis to its own nearest multiple.
81
-
82
- DRIVE images are rectangular (584x565) and `RandomRotate90` is one of
83
- this dataset's augmentations -- a 90 degree turn swaps height and width,
84
- so a rectangular pad target produces a *different* output shape for a
85
- rotated image than an unrotated one in the same batch, and
86
- `torch.stack` cannot collate a batch of mismatched shapes. Padding both
87
- axes to one shared square target makes every rotation shape-invariant.
88
- """
89
- h, w = arr.shape[:2]
90
- target = -(-max(h, w) // multiple) * multiple # round up to the next multiple
91
- ph, pw = target - h, target - w
92
- border = cv2.BORDER_REFLECT_101 if reflect else cv2.BORDER_CONSTANT
93
- return cv2.copyMakeBorder(arr, 0, ph, 0, pw, border, value=0)
94
-
95
-
96
- def build_vessel_transforms(train: bool) -> A.Compose:
97
- """Same reasoning as the grading and lesion pipelines: flips/rotation are
98
- label-preserving for a fundus with no canonical orientation; no blur,
99
- since vessels include capillary-scale structures a blur would erase."""
100
- normalise = A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225))
101
- if not train:
102
- return A.Compose([normalise, ToTensorV2()], additional_targets={"fov": "mask"})
103
- return A.Compose(
104
- [
105
- A.HorizontalFlip(p=0.5),
106
- A.VerticalFlip(p=0.5),
107
- A.RandomRotate90(p=0.5),
108
- normalise,
109
- ToTensorV2(),
110
- ],
111
- additional_targets={"fov": "mask"},
112
- )
113
-
114
-
115
- class DriveVesselDataset(Dataset):
116
- """One full (padded) DRIVE image per item -- no patch sampling, unlike
117
- the IDRiD lesion dataset, since these images are already small enough
118
- to train on whole."""
119
-
120
- def __init__(self, pairs: list[DriveVesselPair], *, train: bool = True):
121
- self.pairs = pairs
122
- self.transform = build_vessel_transforms(train)
123
-
124
- def __len__(self) -> int:
125
- return len(self.pairs)
126
-
127
- def __getitem__(self, idx: int):
128
- pair = self.pairs[idx]
129
- image = cv2.cvtColor(cv2.imread(str(pair.image_path), cv2.IMREAD_COLOR), cv2.COLOR_BGR2RGB)
130
- mask = (cv2.imread(str(pair.mask_path), cv2.IMREAD_GRAYSCALE) > 0).astype(np.uint8)
131
- fov = (cv2.imread(str(pair.fov_path), cv2.IMREAD_GRAYSCALE) > 0).astype(np.uint8)
132
-
133
- # Reflect-pad the image (real content), zero-pad mask/FOV (synthetic
134
- # border pixels are never vessel and never inside the field of view,
135
- # so this makes the padding automatically excluded everywhere the
136
- # FOV mask is already used to exclude the real black surround).
137
- image = _pad_to_multiple(image, reflect=True)
138
- mask = _pad_to_multiple(mask, reflect=False)
139
- fov = _pad_to_multiple(fov, reflect=False)
140
-
141
- out = self.transform(image=image, mask=mask, fov=fov)
142
- return out["image"], out["mask"].float().unsqueeze(0), out["fov"].float().unsqueeze(0)
143
-
144
-
145
- class VesselSegmentationModule(L.LightningModule):
146
- def __init__(
147
- self,
148
- model,
149
- *,
150
- lr: float = 1e-4,
151
- weight_decay: float = 1e-4,
152
- pos_weight: float = 1.0,
153
- dice_weight: float = 1.0,
154
- max_epochs: int = 40,
155
- ):
156
- super().__init__()
157
- self.save_hyperparameters(ignore=["model"])
158
- self.model = model
159
- self.register_buffer("_pos_weight", torch.tensor(pos_weight))
160
- self._val_probs: list[torch.Tensor] = []
161
- self._val_targets: list[torch.Tensor] = []
162
- self._val_fov: list[torch.Tensor] = []
163
-
164
- def forward(self, x):
165
- return self.model(x)
166
-
167
- def _loss(self, logits: torch.Tensor, targets: torch.Tensor, fov: torch.Tensor) -> torch.Tensor:
168
- bce_per_pixel = F.binary_cross_entropy_with_logits(
169
- logits, targets, pos_weight=self._pos_weight, reduction="none"
170
- )
171
- bce = (bce_per_pixel * fov).sum() / fov.sum().clamp(min=1.0)
172
-
173
- probs = torch.sigmoid(logits) * fov
174
- targets_in_fov = targets * fov
175
- intersection = (probs * targets_in_fov).sum(dim=(1, 2, 3))
176
- union = probs.sum(dim=(1, 2, 3)) + targets_in_fov.sum(dim=(1, 2, 3))
177
- dice_loss = 1.0 - ((2.0 * intersection + 1.0) / (union + 1.0)).mean()
178
-
179
- return bce + self.hparams.dice_weight * dice_loss
180
-
181
- def training_step(self, batch, batch_idx):
182
- x, y, fov = batch
183
- logits = self(x)
184
- loss = self._loss(logits, y, fov)
185
- if not torch.isfinite(loss):
186
- raise RuntimeError(
187
- f"Vessel segmentation loss is {loss.item()} at step {batch_idx}. Diverged."
188
- )
189
- self.log("train/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
190
- return loss
191
-
192
- def validation_step(self, batch, batch_idx):
193
- x, y, fov = batch
194
- logits = self(x)
195
- loss = self._loss(logits, y, fov)
196
- self.log("val/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
197
- self._val_probs.append(torch.sigmoid(logits).detach().float().cpu())
198
- self._val_targets.append(y.detach().cpu())
199
- self._val_fov.append(fov.detach().cpu())
200
-
201
- def on_validation_epoch_end(self):
202
- if not self._val_probs:
203
- return
204
- probs = torch.cat(self._val_probs).numpy()
205
- targets = torch.cat(self._val_targets).numpy()
206
- fov = torch.cat(self._val_fov).numpy().astype(bool)
207
-
208
- in_fov_probs = probs[fov]
209
- in_fov_targets = targets[fov]
210
- if in_fov_targets.sum() > 0:
211
- auroc = pixel_auroc(in_fov_targets, in_fov_probs)
212
- self.log("val/auroc", auroc, prog_bar=True)
213
- dice = dice_coefficient(in_fov_targets, in_fov_probs > 0.5)
214
- self.log("val/dice", dice, prog_bar=True)
215
-
216
- self._val_probs.clear()
217
- self._val_targets.clear()
218
- self._val_fov.clear()
219
-
220
- def configure_optimizers(self):
221
- optimiser = torch.optim.AdamW(
222
- self.model.parameters(), lr=self.hparams.lr, weight_decay=self.hparams.weight_decay
223
- )
224
- scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
225
- optimiser, T_max=self.hparams.max_epochs
226
- )
227
- return {
228
- "optimizer": optimiser,
229
- "lr_scheduler": {"scheduler": scheduler, "interval": "epoch"},
230
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/src/drdetect/serve/__init__.py DELETED
File without changes
src/src/drdetect/serve/api.py DELETED
@@ -1,123 +0,0 @@
1
- """FastAPI service wrapping the Phase 2 pipeline (docs/04_ROADMAP.md, Phase 9).
2
-
3
- CPU-only, single global model instance loaded once at startup -- the same
4
- design constraint `scripts/predict.py` and `load_grader` already state
5
- explicitly: a district server is not assumed to have a GPU. This is a
6
- research prototype, not a medical device (docs/01_PROJECT_ANALYSIS.md §11);
7
- every response carries that disclaimer rather than only the PDF report doing so.
8
-
9
- Run locally:
10
- uvicorn drdetect.serve.api:app --host 0.0.0.0 --port 8000
11
-
12
- Configuration is via environment variables (not CLI flags) because this is
13
- meant to run inside a container (Dockerfile) where env vars are the natural
14
- knob, not argv:
15
- DRDETECT_CHECKPOINT path to the grading checkpoint (required)
16
- DRDETECT_BACKBONE default: efficientnet_b0
17
- DRDETECT_LOSS default: ce
18
- DRDETECT_SIZE default: 512
19
- """
20
-
21
- from __future__ import annotations
22
-
23
- import os
24
- from contextlib import asynccontextmanager
25
- from pathlib import Path
26
- from typing import Any
27
-
28
- import cv2
29
- import numpy as np
30
- from fastapi import FastAPI, File, HTTPException, UploadFile
31
- from pydantic import BaseModel
32
-
33
- from drdetect.calibration.temperature import load_temperature
34
- from drdetect.serve.pipeline import load_grader, run_pipeline
35
-
36
- NOT_A_MEDICAL_DEVICE = (
37
- "Research prototype only. NOT a medical device and NOT a substitute for "
38
- "clinical judgement. Every result requires confirmation by a qualified "
39
- "ophthalmologist before any care decision is made."
40
- )
41
-
42
- _state: dict[str, Any] = {}
43
-
44
-
45
- @asynccontextmanager
46
- async def lifespan(app: FastAPI):
47
- checkpoint = os.environ.get("DRDETECT_CHECKPOINT")
48
- if not checkpoint or not Path(checkpoint).exists():
49
- raise RuntimeError(
50
- "DRDETECT_CHECKPOINT env var must point to an existing checkpoint file. "
51
- f"Got: {checkpoint!r}"
52
- )
53
- backbone = os.environ.get("DRDETECT_BACKBONE", "efficientnet_b0")
54
- loss_name = os.environ.get("DRDETECT_LOSS", "ce")
55
- _state["size"] = int(os.environ.get("DRDETECT_SIZE", "512"))
56
- _state["loss_name"] = loss_name
57
- _state["checkpoint"] = checkpoint
58
- _state["model"] = load_grader(checkpoint, backbone=backbone, loss_name=loss_name, device="cpu")
59
- _state["temperature"] = load_temperature(checkpoint)
60
- yield
61
- _state.clear()
62
-
63
-
64
- app = FastAPI(
65
- title="drdetect: DR screening API",
66
- description=NOT_A_MEDICAL_DEVICE,
67
- version="0.1.0",
68
- lifespan=lifespan,
69
- )
70
-
71
-
72
- class GradeResponse(BaseModel):
73
- usable: bool
74
- quality_reasons: list[str]
75
- grade: int | None = None
76
- grade_name: str | None = None
77
- referable: bool | None = None
78
- confidence: float | None = None
79
- calibrated: bool = False
80
- class_probs: list[float] | None = None
81
- disclaimer: str = NOT_A_MEDICAL_DEVICE
82
-
83
-
84
- @app.get("/health")
85
- def health() -> dict:
86
- return {
87
- "status": "ok",
88
- "checkpoint": _state.get("checkpoint"),
89
- "disclaimer": NOT_A_MEDICAL_DEVICE,
90
- }
91
-
92
-
93
- @app.post("/grade", response_model=GradeResponse)
94
- async def grade(image: UploadFile = File(...)) -> GradeResponse: # noqa: B008 -- FastAPI's own required-file-upload idiom
95
- if "model" not in _state:
96
- raise HTTPException(status_code=503, detail="Model not loaded")
97
-
98
- raw_bytes = await image.read()
99
- arr = np.frombuffer(raw_bytes, dtype=np.uint8)
100
- bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
101
- if bgr is None:
102
- raise HTTPException(status_code=400, detail="Could not decode image")
103
- rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
104
-
105
- result = run_pipeline(
106
- rgb,
107
- _state["model"],
108
- loss_name=_state["loss_name"],
109
- size=_state["size"],
110
- device="cpu",
111
- temperature=_state["temperature"],
112
- )
113
-
114
- return GradeResponse(
115
- usable=result.quality.usable,
116
- quality_reasons=result.quality.reasons,
117
- grade=result.grade,
118
- grade_name=result.grade_name,
119
- referable=result.referable,
120
- confidence=result.confidence,
121
- calibrated=result.calibrated,
122
- class_probs=result.class_probs,
123
- )