adarshcod30 commited on
Commit
b379113
·
verified ·
1 Parent(s): d87a868

Deploy DR screening Gradio demo (app.py fetches weights from the model repo)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +17 -7
  2. app.py +32 -0
  3. requirements.txt +33 -0
  4. src/drdetect.egg-info/PKG-INFO +643 -0
  5. src/drdetect.egg-info/SOURCES.txt +20 -0
  6. src/drdetect.egg-info/dependency_links.txt +1 -0
  7. src/drdetect.egg-info/requires.txt +40 -0
  8. src/drdetect.egg-info/top_level.txt +1 -0
  9. src/drdetect/__init__.py +6 -0
  10. src/drdetect/calibration/__init__.py +0 -0
  11. src/drdetect/calibration/mc_dropout.py +33 -0
  12. src/drdetect/calibration/temperature.py +101 -0
  13. src/drdetect/data/__init__.py +0 -0
  14. src/drdetect/data/dataset.py +134 -0
  15. src/drdetect/data/manifest.py +107 -0
  16. src/drdetect/data/splits.py +349 -0
  17. src/drdetect/enhance/__init__.py +0 -0
  18. src/drdetect/enhance/preprocessing.py +131 -0
  19. src/drdetect/eval/__init__.py +0 -0
  20. src/drdetect/eval/metrics.py +340 -0
  21. src/drdetect/explain/__init__.py +0 -0
  22. src/drdetect/explain/cam_variants.py +69 -0
  23. src/drdetect/explain/evidence.py +175 -0
  24. src/drdetect/explain/gradcam.py +97 -0
  25. src/drdetect/explain/localization_metrics.py +55 -0
  26. src/drdetect/explain/sanity_checks.py +157 -0
  27. src/drdetect/fusion/__init__.py +1 -0
  28. src/drdetect/fusion/embedding.py +75 -0
  29. src/drdetect/fusion/features.py +260 -0
  30. src/drdetect/fusion/model.py +35 -0
  31. src/drdetect/grading/__init__.py +0 -0
  32. src/drdetect/grading/losses.py +330 -0
  33. src/drdetect/grading/model.py +92 -0
  34. src/drdetect/grading/module.py +256 -0
  35. src/drdetect/quality/__init__.py +0 -0
  36. src/drdetect/quality/assessment.py +127 -0
  37. src/drdetect/segmentation/__init__.py +0 -0
  38. src/drdetect/segmentation/dataset.py +174 -0
  39. src/drdetect/segmentation/localization.py +289 -0
  40. src/drdetect/segmentation/metrics.py +131 -0
  41. src/drdetect/segmentation/microaneurysms.py +495 -0
  42. src/drdetect/segmentation/model.py +47 -0
  43. src/drdetect/segmentation/module.py +120 -0
  44. src/drdetect/segmentation/quadrants.py +69 -0
  45. src/drdetect/segmentation/vessels.py +230 -0
  46. src/drdetect/serve/__init__.py +0 -0
  47. src/drdetect/serve/api.py +123 -0
  48. src/drdetect/serve/demo.py +104 -0
  49. src/drdetect/serve/pipeline.py +189 -0
  50. src/drdetect/serve/report.py +209 -0
README.md CHANGED
@@ -1,13 +1,23 @@
1
  ---
2
- title: Drdetect Dr Screening
3
- emoji: 💻
4
- colorFrom: indigo
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.26.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: DR Screening Demo
3
+ emoji: 👁️
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: gradio
7
+ sdk_version: "6.26.0"
 
8
  app_file: app.py
9
  pinned: false
10
+ license: other
11
  ---
12
 
13
+ # Diabetic Retinopathy Screening — Live Demo
14
+
15
+ **Research prototype only. NOT a medical device. NOT a diagnosis.** Upload a fundus photograph to
16
+ see this project's quality gate, ICDR grading, and Grad-CAM evidence run end to end.
17
+
18
+ Full project, every experiment, and the complete evidence trail (including this model's honest
19
+ external-validation limitations — read before trusting any output):
20
+ [github.com/adarshcod30/Diabetic-Retinopathy-Detection](https://github.com/adarshcod30/Diabetic-Retinopathy-Detection).
21
+
22
+ Model weights: [huggingface.co/adarshcod30/drdetect-dr-screening](https://huggingface.co/adarshcod30/drdetect-dr-screening)
23
+ (research use only — see that repo's own model card for the full license terms).
app.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HuggingFace Spaces entry point for the Gradio demo (docs/04_ROADMAP.md, Phase 9).
2
+
3
+ Not used for local development -- `scripts/demo.py` is the local launcher,
4
+ with a required `--checkpoint` flag pointing anywhere on disk. A Space has no
5
+ such flag: HF Spaces runs `python app.py` with no arguments, so this file
6
+ fetches the checkpoint from the separate model repo
7
+ (huggingface.co/adarshcod30/drdetect-dr-screening -- research-use-only, see
8
+ MODEL_CARD.md there) via `hf_hub_download` instead of bundling a 46MB weight
9
+ file into this Space's own repo. `hf_hub_download` caches the file locally
10
+ after the first fetch, so a Space restart does not re-download it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
19
+
20
+ MODEL_REPO = "adarshcod30/drdetect-dr-screening"
21
+
22
+ if __name__ == "__main__":
23
+ from huggingface_hub import hf_hub_download
24
+
25
+ checkpoint = hf_hub_download(repo_id=MODEL_REPO, filename="best.ckpt")
26
+
27
+ from drdetect.serve.demo import build_interface
28
+
29
+ # regression loss, not ce: it won the locked external evaluation decisively
30
+ # (docs/22_PHASE8_VALIDATION_RESULTS.md, referable AUC 0.924 vs 0.888, DeLong p=6.1e-10)
31
+ demo = build_interface(checkpoint, backbone="efficientnet_b0", loss_name="regression", size=512)
32
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Generated from pyproject.toml — prefer `pip install -e ".[dev]"`
2
+ torch>=2.2
3
+ torchvision>=0.17
4
+ lightning>=2.2
5
+ timm>=1.0
6
+ monai>=1.3
7
+ segmentation-models-pytorch>=0.3
8
+ opencv-python-headless>=4.9
9
+ scikit-image>=0.22
10
+ albumentations>=1.4
11
+ Pillow>=10.2
12
+ numpy>=1.26
13
+ pandas>=2.2
14
+ scipy>=1.12
15
+ scikit-learn>=1.4
16
+ statsmodels>=0.14
17
+ torchmetrics>=1.3
18
+ hydra-core>=1.3
19
+ omegaconf>=2.3
20
+ wandb>=0.16
21
+ grad-cam>=1.5
22
+ simpy>=4.1
23
+ reportlab>=4.1
24
+ fastapi>=0.110
25
+ uvicorn>=0.29
26
+ onnx>=1.16
27
+ onnxruntime>=1.17
28
+ gradio>=4.25
29
+ matplotlib>=3.8
30
+ seaborn>=0.13
31
+ tqdm>=4.66
32
+ kaggle>=1.6
33
+ python-dotenv>=1.0
src/drdetect.egg-info/PKG-INFO ADDED
@@ -0,0 +1,643 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1 @@
 
 
1
+
src/drdetect.egg-info/requires.txt ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1 @@
 
 
1
+ drdetect
src/drdetect/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
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/drdetect/calibration/__init__.py ADDED
File without changes
src/drdetect/calibration/mc_dropout.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/calibration/temperature.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/data/__init__.py ADDED
File without changes
src/drdetect/data/dataset.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/data/manifest.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/data/splits.py ADDED
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/enhance/__init__.py ADDED
File without changes
src/drdetect/enhance/preprocessing.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/eval/__init__.py ADDED
File without changes
src/drdetect/eval/metrics.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/explain/__init__.py ADDED
File without changes
src/drdetect/explain/cam_variants.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/explain/evidence.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/explain/gradcam.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/explain/localization_metrics.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/explain/sanity_checks.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/fusion/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Phase 5: fusing the grading model's CNN embedding with Phase 4's lesion evidence."""
src/drdetect/fusion/embedding.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/fusion/features.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/fusion/model.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/grading/__init__.py ADDED
File without changes
src/drdetect/grading/losses.py ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/grading/model.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/grading/module.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/quality/__init__.py ADDED
File without changes
src/drdetect/quality/assessment.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/segmentation/__init__.py ADDED
File without changes
src/drdetect/segmentation/dataset.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/segmentation/localization.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/segmentation/metrics.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/segmentation/microaneurysms.py ADDED
@@ -0,0 +1,495 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/segmentation/model.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/segmentation/module.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/segmentation/quadrants.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/segmentation/vessels.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/drdetect/serve/__init__.py ADDED
File without changes
src/drdetect/serve/api.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ )
src/drdetect/serve/demo.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local Gradio demo: upload a fundus photo, see the graded result live.
2
+
3
+ Unlike `scripts/predict.py` this runs on whatever accelerator is available
4
+ (MPS/CUDA/CPU) -- the CPU-only constraint is specifically about the
5
+ deployable CLI path, not an interactive local demo (docs/04_ROADMAP.md).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+
14
+ from drdetect.calibration.temperature import load_temperature
15
+ from drdetect.serve.pipeline import PredictionResult, load_grader, run_pipeline
16
+
17
+ __all__ = ["build_interface"]
18
+
19
+
20
+ def _format_summary(result: PredictionResult) -> str:
21
+ q = result.quality
22
+ lines = [
23
+ f"**Quality:** sharpness {q.sharpness:.0f} | brightness {q.mean_brightness:.0f} | "
24
+ f"field-of-view {q.fov_fraction:.0%}"
25
+ ]
26
+ if not q.usable:
27
+ lines.append("\n**REJECTED -- recapture requested.** Reasons:")
28
+ lines.extend(f"- {r}" for r in q.reasons)
29
+ return "\n".join(lines)
30
+
31
+ calib_label = "temperature-scaled" if result.calibrated else "uncalibrated"
32
+ lines.append(f"\n### Grade {result.grade}: {result.grade_name}")
33
+ lines.append(f"**Referable (grade >= 2):** {'YES' if result.referable else 'no'}")
34
+ if result.confidence is not None:
35
+ lines.append(f"**Confidence ({calib_label}):** {result.confidence:.2%}")
36
+ if result.class_probs:
37
+ from drdetect.grading.module import CLASS_NAMES
38
+
39
+ lines.append("\n| Grade | Probability |\n|---|---|")
40
+ for name, p in zip(CLASS_NAMES, result.class_probs, strict=True):
41
+ lines.append(f"| {name} | {p:.2%} |")
42
+ lines.append(
43
+ "\n*Research prototype only. Not a medical device. "
44
+ "Every case must be reviewed by a qualified clinician.*"
45
+ )
46
+ return "\n".join(lines)
47
+
48
+
49
+ def build_interface(
50
+ checkpoint: str | Path,
51
+ *,
52
+ backbone: str = "efficientnet_b0",
53
+ loss_name: str = "ce",
54
+ size: int = 512,
55
+ ):
56
+ import gradio as gr
57
+ import torch
58
+
59
+ device = (
60
+ "mps"
61
+ if torch.backends.mps.is_available()
62
+ else ("cuda" if torch.cuda.is_available() else "cpu")
63
+ )
64
+ model = load_grader(checkpoint, backbone=backbone, loss_name=loss_name, device=device)
65
+ temperature = load_temperature(checkpoint)
66
+
67
+ def infer(image: np.ndarray, force: bool):
68
+ if image is None:
69
+ return None, "Upload a fundus photo to begin."
70
+ result = run_pipeline(
71
+ image,
72
+ model,
73
+ loss_name=loss_name,
74
+ size=size,
75
+ device=device,
76
+ skip_quality_gate=force,
77
+ temperature=temperature,
78
+ )
79
+ overlay = result.cam_overlay if result.cam_overlay is not None else image
80
+ return overlay, _format_summary(result)
81
+
82
+ calib_note = (
83
+ f"Confidence is temperature-scaled (T={temperature:.3f})."
84
+ if temperature != 1.0
85
+ else "This checkpoint has not been calibrated -- confidence is raw softmax output."
86
+ )
87
+ with gr.Blocks(title="DR Screening Demo") as demo:
88
+ gr.Markdown(
89
+ "# Diabetic Retinopathy Screening -- Local Demo\n"
90
+ "Upload a fundus photo. This is a research prototype, not a diagnosis. "
91
+ f"{calib_note}"
92
+ )
93
+ with gr.Row():
94
+ with gr.Column():
95
+ image_in = gr.Image(type="numpy", label="Fundus photo")
96
+ force = gr.Checkbox(label="Grade anyway if quality gate rejects", value=False)
97
+ run_btn = gr.Button("Grade", variant="primary")
98
+ with gr.Column():
99
+ image_out = gr.Image(label="Grad-CAM evidence")
100
+ summary_out = gr.Markdown()
101
+
102
+ run_btn.click(infer, inputs=[image_in, force], outputs=[image_out, summary_out])
103
+
104
+ return demo
src/drdetect/serve/pipeline.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end single-image inference: quality gate -> grade -> Grad-CAM.
2
+
3
+ This is the Phase 2 vertical slice (docs/04_ROADMAP.md): image in, graded and
4
+ explained result out. `scripts/predict.py` and the Gradio demo are both thin
5
+ callers of `run_pipeline` so the two entry points cannot drift the way
6
+ training and evaluation once did over `decode_output` (docs/07_PHASE3_RESULTS.md).
7
+
8
+ Confidence is calibrated ONLY when the caller passes a `temperature` fitted
9
+ by `scripts/calibrate.py` (see `drdetect.calibration.temperature`) -- the
10
+ default, 1.0, is a no-op divisor that reports raw softmax output. Check
11
+ `PredictionResult.calibrated` before presenting a confidence number as a
12
+ validated probability rather than a raw model output; the two look
13
+ identical in this dataclass except for that flag.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING
21
+
22
+ import numpy as np
23
+ import torch
24
+
25
+ from drdetect.data.dataset import build_transforms
26
+ from drdetect.enhance.preprocessing import preprocess
27
+ from drdetect.eval.metrics import REFERABLE_THRESHOLD
28
+ from drdetect.explain.gradcam import generate_cam, overlay_cam
29
+ from drdetect.grading.losses import decode_output, outputs_for_loss
30
+ from drdetect.grading.model import build_model
31
+ from drdetect.grading.module import CLASS_NAMES
32
+ from drdetect.quality.assessment import QualityResult, assess_quality
33
+
34
+ if TYPE_CHECKING:
35
+ # Not a real import: drdetect.fusion.features pulls in lightning and
36
+ # segmentation_models_pytorch, which the Phase 2 vertical slice
37
+ # (scripts/predict.py, the demo) has no reason to load unless a caller
38
+ # actually asks for lesion evidence -- see add_lesion_evidence below,
39
+ # which imports it lazily at call time instead.
40
+ from drdetect.fusion.features import LesionModels
41
+
42
+ __all__ = ["PredictionResult", "load_grader", "run_pipeline", "add_lesion_evidence"]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class PredictionResult:
47
+ quality: QualityResult
48
+ preprocessed: np.ndarray | None = None
49
+ grade: int | None = None
50
+ grade_name: str | None = None
51
+ referable: bool | None = None
52
+ confidence: float | None = None
53
+ class_probs: list[float] | None = None
54
+ cam_overlay: np.ndarray | None = None
55
+ calibrated: bool = False
56
+ # Phase 6: populated only by `add_lesion_evidence`, since it needs 5 extra
57
+ # loaded models `run_pipeline` itself has no reason to require -- None
58
+ # here means "not computed", not "no lesions found".
59
+ lesion_overlay: np.ndarray | None = None
60
+ lesion_evidence: dict | None = None
61
+ icdr_rationale: str | None = None
62
+
63
+
64
+ def load_grader(
65
+ checkpoint: str | Path,
66
+ *,
67
+ backbone: str = "efficientnet_b0",
68
+ loss_name: str = "ce",
69
+ device: str = "cpu",
70
+ ) -> torch.nn.Module:
71
+ """Load a trained grader for inference, on CPU by default.
72
+
73
+ CPU-only is deliberate, not a fallback: the roadmap's exit criterion for
74
+ this script is "single image, CPU, no GPU required" -- a district
75
+ screening kiosk is not assumed to have one.
76
+ """
77
+ n_outputs = outputs_for_loss(loss_name)
78
+ model = build_model(backbone, num_outputs=n_outputs, pretrained=False, freeze_bn=True)
79
+ ckpt = torch.load(checkpoint, map_location="cpu", weights_only=False)
80
+ state = ckpt.get("state_dict", ckpt)
81
+ state = {k.removeprefix("model."): v for k, v in state.items() if k.startswith("model.")}
82
+ model.load_state_dict(state)
83
+ model.to(device).eval()
84
+ return model
85
+
86
+
87
+ def run_pipeline(
88
+ raw_image_rgb: np.ndarray,
89
+ model: torch.nn.Module,
90
+ *,
91
+ loss_name: str = "ce",
92
+ size: int = 512,
93
+ device: str = "cpu",
94
+ skip_quality_gate: bool = False,
95
+ temperature: float = 1.0,
96
+ ) -> PredictionResult:
97
+ """Run the full quality -> grade -> explain chain on one raw image.
98
+
99
+ Args:
100
+ raw_image_rgb: HxWx3 uint8 RGB, as captured -- not preprocessed.
101
+ model: from `load_grader`.
102
+ skip_quality_gate: force grading through even on a rejected image.
103
+ Used by the demo so a user can see *why* an image was flagged
104
+ instead of only being refused a result.
105
+ temperature: from `drdetect.calibration.temperature.load_temperature`
106
+ for this checkpoint, or the default 1.0 (a no-op divisor) if it
107
+ has never been calibrated. Dividing logits by T before softmax
108
+ changes reported confidence, never the predicted grade (argmax
109
+ is invariant to a positive rescaling) -- see
110
+ scripts/calibrate.py for what temperature scaling is and why.
111
+ """
112
+ quality = assess_quality(raw_image_rgb)
113
+ if not quality.usable and not skip_quality_gate:
114
+ return PredictionResult(quality=quality)
115
+
116
+ pre = preprocess(raw_image_rgb, size=size, use_ben_graham=True)
117
+ tensor = build_transforms(size, train=False)(image=pre)["image"].unsqueeze(0).to(device)
118
+
119
+ with torch.no_grad():
120
+ output = model(tensor)
121
+ preds, _p_ref = decode_output(output.cpu(), loss_name)
122
+ grade = int(preds[0])
123
+
124
+ class_probs = None
125
+ confidence = None
126
+ if loss_name in ("ce", "distance_ce"):
127
+ probs = torch.softmax(output / temperature, dim=1)[0].detach().cpu().tolist()
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(
135
+ quality=quality,
136
+ preprocessed=pre,
137
+ grade=grade,
138
+ grade_name=CLASS_NAMES[grade],
139
+ referable=grade >= REFERABLE_THRESHOLD,
140
+ confidence=confidence,
141
+ class_probs=class_probs,
142
+ cam_overlay=overlay,
143
+ calibrated=temperature != 1.0,
144
+ )
145
+
146
+
147
+ def add_lesion_evidence(
148
+ result: PredictionResult, image_path: str, lesion_models: LesionModels
149
+ ) -> PredictionResult:
150
+ """Phase 6: attach a lesion-overlay render, an ICDR evidence table, and a
151
+ templated rationale to an already-graded `PredictionResult`.
152
+
153
+ A separate step from `run_pipeline`, not folded into it, because it needs
154
+ 5 extra loaded models (`drdetect.fusion.features.load_lesion_models`) that
155
+ the Phase 2 vertical slice (`scripts/predict.py`, the Gradio demo) has no
156
+ reason to always pay the cost of loading. Takes a file path, not the
157
+ in-memory array `run_pipeline` works from, because the lesion models
158
+ apply their own preprocessing convention (raw images, full resolution,
159
+ tiled inference) that differs from the grading model's Ben-Graham cache
160
+ -- see `drdetect.fusion.embedding`'s own docstring for why these two
161
+ models deliberately see different versions of the same image.
162
+
163
+ No-ops (returns `result` unchanged) if the image was rejected by the
164
+ quality gate -- there's no grade to explain evidence for.
165
+ """
166
+ if result.grade is None:
167
+ return result
168
+
169
+ from dataclasses import replace
170
+
171
+ from drdetect.explain.evidence import (
172
+ build_icdr_rationale,
173
+ extract_lesion_evidence,
174
+ render_lesion_overlay,
175
+ )
176
+
177
+ evidence = extract_lesion_evidence(image_path, lesion_models)
178
+ overlay = render_lesion_overlay(_load_rgb(image_path), evidence)
179
+ rationale = build_icdr_rationale(result.grade, evidence)
180
+
181
+ return replace(
182
+ result, lesion_overlay=overlay, lesion_evidence=evidence, icdr_rationale=rationale
183
+ )
184
+
185
+
186
+ def _load_rgb(image_path: str):
187
+ import cv2
188
+
189
+ return cv2.cvtColor(cv2.imread(str(image_path), cv2.IMREAD_COLOR), cv2.COLOR_BGR2RGB)
src/drdetect/serve/report.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One-page PDF report: image, Grad-CAM evidence, grade, and a plain disclaimer.
2
+
3
+ The 30-second review target (docs/04_ROADMAP.md, Phase 6) is a property of
4
+ this layout that has not been measured yet -- Phase 6 is where it gets timed
5
+ against real reviewers. This module only produces the artefact to be timed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ from PIL import Image
14
+ from reportlab.lib import colors
15
+ from reportlab.lib.pagesizes import A4
16
+ from reportlab.lib.units import mm
17
+ from reportlab.pdfgen import canvas
18
+
19
+ from drdetect.serve.pipeline import PredictionResult
20
+
21
+ __all__ = ["build_report_pdf"]
22
+
23
+ _DISCLAIMER = (
24
+ "Research prototype output only. NOT a medical device and NOT a diagnosis. "
25
+ "Every flagged case must be reviewed by a qualified clinician before any "
26
+ "action is taken."
27
+ )
28
+
29
+
30
+ def _draw_lesion_legend(c: canvas.Canvas, x: float, y: float) -> float:
31
+ """A small colour key for the lesion-overlay panel -- an outline is only
32
+ legible if the viewer knows what each colour means."""
33
+ from drdetect.explain.evidence import LESION_COLORS_RGB
34
+
35
+ labels = {
36
+ "hard_exudates": "Hard exudates",
37
+ "soft_exudates": "Soft exudates",
38
+ "haemorrhages": "Haemorrhages",
39
+ "microaneurysms": "Microaneurysms",
40
+ }
41
+ c.setFont("Helvetica", 8)
42
+ for lesion_type, label in labels.items():
43
+ r, g, b = LESION_COLORS_RGB[lesion_type]
44
+ c.setFillColorRGB(r / 255, g / 255, b / 255)
45
+ c.rect(x, y - 7, 8, 8, fill=1, stroke=0)
46
+ c.setFillColor(colors.black)
47
+ c.drawString(x + 11, y - 6, label)
48
+ x += 11 + len(label) * 4.3 + 14
49
+ return y - 14
50
+
51
+
52
+ def _draw_wrapped(
53
+ c: canvas.Canvas, text: str, x: float, y: float, width: float, font_size: int = 9
54
+ ) -> float:
55
+ """Draw `text` wrapped to `width` points, returning the y below the last line."""
56
+ from reportlab.pdfbase.pdfmetrics import stringWidth
57
+
58
+ c.setFont("Helvetica", font_size)
59
+ words = text.split()
60
+ line = ""
61
+ for word in words:
62
+ trial = f"{line} {word}".strip()
63
+ if stringWidth(trial, "Helvetica", font_size) > width and line:
64
+ c.drawString(x, y, line)
65
+ y -= font_size + 2
66
+ line = word
67
+ else:
68
+ line = trial
69
+ if line:
70
+ c.drawString(x, y, line)
71
+ y -= font_size + 2
72
+ return y
73
+
74
+
75
+ def build_report_pdf(
76
+ result: PredictionResult,
77
+ image_name: str,
78
+ out_path: str | Path,
79
+ *,
80
+ checkpoint_name: str = "",
81
+ ) -> Path:
82
+ out_path = Path(out_path)
83
+ out_path.parent.mkdir(parents=True, exist_ok=True)
84
+
85
+ page_w, page_h = A4
86
+ margin = 18 * mm
87
+ c = canvas.Canvas(str(out_path), pagesize=A4)
88
+
89
+ c.setFont("Helvetica-Bold", 16)
90
+ c.drawString(margin, page_h - margin, "Diabetic Retinopathy Screening Report")
91
+ c.setFont("Helvetica", 9)
92
+ c.setFillColor(colors.grey)
93
+ c.drawString(margin, page_h - margin - 14, f"Image: {image_name}")
94
+ if checkpoint_name:
95
+ c.drawString(margin, page_h - margin - 26, f"Model checkpoint: {checkpoint_name}")
96
+ c.setFillColor(colors.black)
97
+
98
+ y = page_h - margin - 45
99
+
100
+ q = result.quality
101
+ if not q.usable:
102
+ c.setFillColor(colors.red)
103
+ c.setFont("Helvetica-Bold", 13)
104
+ c.drawString(margin, y, "IMAGE REJECTED -- RECAPTURE REQUIRED")
105
+ c.setFillColor(colors.black)
106
+ y -= 22
107
+ c.setFont("Helvetica-Bold", 10)
108
+ c.drawString(margin, y, "Reasons:")
109
+ y -= 14
110
+ for reason in q.reasons:
111
+ y = _draw_wrapped(c, f"- {reason}", margin, y, page_w - 2 * margin)
112
+ y -= 10
113
+ c.setFont("Helvetica-Bold", 10)
114
+ c.drawString(margin, y, "Quality metrics (for reference):")
115
+ y -= 14
116
+ else:
117
+ panels = [
118
+ (Image.fromarray(result.preprocessed.astype(np.uint8)), "Preprocessed image"),
119
+ (
120
+ Image.fromarray(result.cam_overlay.astype(np.uint8)),
121
+ "Grad-CAM evidence (predicted grade)",
122
+ ),
123
+ ]
124
+ if result.lesion_overlay is not None:
125
+ panels.append(
126
+ (
127
+ Image.fromarray(result.lesion_overlay.astype(np.uint8)),
128
+ "Lesion evidence (outlines)",
129
+ )
130
+ )
131
+
132
+ gap = 10
133
+ img_w = (page_w - 2 * margin - gap * (len(panels) - 1)) / len(panels)
134
+ img_h = img_w # preprocessed images are square (circle_crop -> resize)
135
+
136
+ for i, (panel_img, label) in enumerate(panels):
137
+ x = margin + i * (img_w + gap)
138
+ c.drawImage(_pil_reader(panel_img), x, y - img_h, width=img_w, height=img_h)
139
+ c.setFont("Helvetica", 8)
140
+ c.setFillColor(colors.grey)
141
+ c.drawCentredString(x + img_w / 2, y - img_h - 12, label)
142
+ c.setFillColor(colors.black)
143
+ y = y - img_h - 26
144
+
145
+ if result.lesion_overlay is not None:
146
+ y = _draw_lesion_legend(c, margin, y)
147
+ y -= 6
148
+
149
+ c.setFont("Helvetica-Bold", 14)
150
+ referable_tag = "REFERABLE" if result.referable else "not referable"
151
+ c.drawString(
152
+ margin, y, f"ICDR grade {result.grade}: {result.grade_name} ({referable_tag})"
153
+ )
154
+ y -= 20
155
+
156
+ if result.icdr_rationale:
157
+ c.setFont("Helvetica-Bold", 10)
158
+ c.drawString(margin, y, "Evidence:")
159
+ y -= 14
160
+ c.setFont("Helvetica", 9)
161
+ y = _draw_wrapped(c, result.icdr_rationale, margin + 8, y, page_w - 2 * margin - 8)
162
+ y -= 6
163
+
164
+ calib_label = "temperature-scaled" if result.calibrated else "uncalibrated"
165
+ c.setFont("Helvetica", 10)
166
+ if result.confidence is not None:
167
+ c.drawString(margin, y, f"Model confidence ({calib_label}): {result.confidence:.2%}")
168
+ y -= 16
169
+
170
+ if result.class_probs:
171
+ c.setFont("Helvetica-Bold", 10)
172
+ c.drawString(margin, y, f"Per-grade probability ({calib_label}):")
173
+ y -= 14
174
+ c.setFont("Helvetica", 9)
175
+ from drdetect.grading.module import CLASS_NAMES
176
+
177
+ for name, p in zip(CLASS_NAMES, result.class_probs, strict=True):
178
+ c.drawString(margin + 8, y, f"{name:<10} {p:.2%}")
179
+ y -= 12
180
+ y -= 6
181
+
182
+ c.setFont("Helvetica-Bold", 10)
183
+ c.drawString(margin, y, "Image quality (passed):")
184
+ y -= 14
185
+
186
+ c.setFont("Helvetica", 9)
187
+ c.drawString(
188
+ margin + 8,
189
+ y,
190
+ f"sharpness {q.sharpness:.0f} | mean brightness {q.mean_brightness:.0f} | "
191
+ f"field-of-view {q.fov_fraction:.0%} of frame",
192
+ )
193
+ y -= 24
194
+
195
+ c.setStrokeColor(colors.grey)
196
+ c.line(margin, y, page_w - margin, y)
197
+ y -= 16
198
+ c.setFillColor(colors.grey)
199
+ y = _draw_wrapped(c, _DISCLAIMER, margin, y, page_w - 2 * margin, font_size=8)
200
+
201
+ c.showPage()
202
+ c.save()
203
+ return out_path
204
+
205
+
206
+ def _pil_reader(image: Image.Image):
207
+ from reportlab.lib.utils import ImageReader
208
+
209
+ return ImageReader(image)