Spaces:
Sleeping
Sleeping
Fix a cp -R staging bug that left the Grad-CAM fix undeployed; remove stray nested src/src and egg-info
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- src/drdetect.egg-info/PKG-INFO +0 -643
- src/drdetect.egg-info/SOURCES.txt +0 -20
- src/drdetect.egg-info/dependency_links.txt +0 -1
- src/drdetect.egg-info/requires.txt +0 -40
- src/drdetect.egg-info/top_level.txt +0 -1
- src/drdetect/serve/pipeline.py +11 -1
- src/src/drdetect.egg-info/PKG-INFO +0 -643
- src/src/drdetect.egg-info/SOURCES.txt +0 -20
- src/src/drdetect.egg-info/dependency_links.txt +0 -1
- src/src/drdetect.egg-info/requires.txt +0 -40
- src/src/drdetect.egg-info/top_level.txt +0 -1
- src/src/drdetect/__init__.py +0 -6
- src/src/drdetect/calibration/__init__.py +0 -0
- src/src/drdetect/calibration/mc_dropout.py +0 -33
- src/src/drdetect/calibration/temperature.py +0 -101
- src/src/drdetect/data/__init__.py +0 -0
- src/src/drdetect/data/dataset.py +0 -134
- src/src/drdetect/data/manifest.py +0 -107
- src/src/drdetect/data/splits.py +0 -349
- src/src/drdetect/enhance/__init__.py +0 -0
- src/src/drdetect/enhance/preprocessing.py +0 -131
- src/src/drdetect/eval/__init__.py +0 -0
- src/src/drdetect/eval/metrics.py +0 -340
- src/src/drdetect/explain/__init__.py +0 -0
- src/src/drdetect/explain/cam_variants.py +0 -69
- src/src/drdetect/explain/evidence.py +0 -175
- src/src/drdetect/explain/gradcam.py +0 -97
- src/src/drdetect/explain/localization_metrics.py +0 -55
- src/src/drdetect/explain/sanity_checks.py +0 -157
- src/src/drdetect/fusion/__init__.py +0 -1
- src/src/drdetect/fusion/embedding.py +0 -75
- src/src/drdetect/fusion/features.py +0 -260
- src/src/drdetect/fusion/model.py +0 -35
- src/src/drdetect/grading/__init__.py +0 -0
- src/src/drdetect/grading/losses.py +0 -330
- src/src/drdetect/grading/model.py +0 -92
- src/src/drdetect/grading/module.py +0 -256
- src/src/drdetect/quality/__init__.py +0 -0
- src/src/drdetect/quality/assessment.py +0 -127
- src/src/drdetect/segmentation/__init__.py +0 -0
- src/src/drdetect/segmentation/dataset.py +0 -174
- src/src/drdetect/segmentation/localization.py +0 -289
- src/src/drdetect/segmentation/metrics.py +0 -131
- src/src/drdetect/segmentation/microaneurysms.py +0 -495
- src/src/drdetect/segmentation/model.py +0 -47
- src/src/drdetect/segmentation/module.py +0 -120
- src/src/drdetect/segmentation/quadrants.py +0 -69
- src/src/drdetect/segmentation/vessels.py +0 -230
- src/src/drdetect/serve/__init__.py +0 -0
- src/src/drdetect/serve/api.py +0 -123
src/drdetect.egg-info/PKG-INFO
DELETED
|
@@ -1,643 +0,0 @@
|
|
| 1 |
-
Metadata-Version: 2.4
|
| 2 |
-
Name: drdetect
|
| 3 |
-
Version: 0.1.0
|
| 4 |
-
Summary: Explainable, quality-aware AI for diabetic retinopathy screening
|
| 5 |
-
Author: Adarsh Dwivedi
|
| 6 |
-
License: MIT License
|
| 7 |
-
|
| 8 |
-
Copyright (c) 2026 Adarsh Dwivedi
|
| 9 |
-
|
| 10 |
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 11 |
-
of this software and associated documentation files (the "Software"), to deal
|
| 12 |
-
in the Software without restriction, including without limitation the rights
|
| 13 |
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 14 |
-
copies of the Software, and to permit persons to whom the Software is
|
| 15 |
-
furnished to do so, subject to the following conditions:
|
| 16 |
-
|
| 17 |
-
The above copyright notice and this permission notice shall be included in all
|
| 18 |
-
copies or substantial portions of the Software.
|
| 19 |
-
|
| 20 |
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 21 |
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 22 |
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 23 |
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 24 |
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 25 |
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 26 |
-
SOFTWARE.
|
| 27 |
-
|
| 28 |
-
---
|
| 29 |
-
|
| 30 |
-
NOTE ON SCOPE: This licence covers the SOURCE CODE in this repository only.
|
| 31 |
-
|
| 32 |
-
Datasets referenced by this project remain under their own licences and are NOT
|
| 33 |
-
redistributed here (IDRiD: CC BY 4.0; APTOS / EyePACS: Kaggle competition rules;
|
| 34 |
-
Messidor-2: ADCIS terms; DRIVE: research use with registration).
|
| 35 |
-
|
| 36 |
-
Trained model weights derived from those datasets are released for RESEARCH USE
|
| 37 |
-
ONLY.
|
| 38 |
-
|
| 39 |
-
This software is a research prototype. It is NOT a medical device and must not
|
| 40 |
-
be used for clinical diagnosis.
|
| 41 |
-
|
| 42 |
-
Project-URL: Homepage, https://github.com/adarshcod30/Diabetic-Retinopathy-Detection
|
| 43 |
-
Project-URL: Issues, https://github.com/adarshcod30/Diabetic-Retinopathy-Detection/issues
|
| 44 |
-
Keywords: diabetic-retinopathy,medical-imaging,explainable-ai,deep-learning,fundus,computer-vision,healthcare-ai
|
| 45 |
-
Classifier: Development Status :: 2 - Pre-Alpha
|
| 46 |
-
Classifier: Intended Audience :: Science/Research
|
| 47 |
-
Classifier: License :: OSI Approved :: MIT License
|
| 48 |
-
Classifier: Programming Language :: Python :: 3.11
|
| 49 |
-
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
| 50 |
-
Requires-Python: <3.13,>=3.10
|
| 51 |
-
Description-Content-Type: text/markdown
|
| 52 |
-
License-File: LICENSE
|
| 53 |
-
Requires-Dist: torch>=2.2
|
| 54 |
-
Requires-Dist: torchvision>=0.17
|
| 55 |
-
Requires-Dist: lightning>=2.2
|
| 56 |
-
Requires-Dist: timm>=1.0
|
| 57 |
-
Requires-Dist: monai>=1.3
|
| 58 |
-
Requires-Dist: segmentation-models-pytorch>=0.3
|
| 59 |
-
Requires-Dist: opencv-python-headless>=4.9
|
| 60 |
-
Requires-Dist: scikit-image>=0.22
|
| 61 |
-
Requires-Dist: albumentations>=1.4
|
| 62 |
-
Requires-Dist: Pillow>=10.2
|
| 63 |
-
Requires-Dist: numpy>=1.26
|
| 64 |
-
Requires-Dist: pandas>=2.2
|
| 65 |
-
Requires-Dist: scipy>=1.12
|
| 66 |
-
Requires-Dist: scikit-learn>=1.4
|
| 67 |
-
Requires-Dist: statsmodels>=0.14
|
| 68 |
-
Requires-Dist: torchmetrics>=1.3
|
| 69 |
-
Requires-Dist: hydra-core>=1.3
|
| 70 |
-
Requires-Dist: omegaconf>=2.3
|
| 71 |
-
Requires-Dist: wandb>=0.16
|
| 72 |
-
Requires-Dist: grad-cam>=1.5
|
| 73 |
-
Requires-Dist: simpy>=4.1
|
| 74 |
-
Requires-Dist: reportlab>=4.1
|
| 75 |
-
Requires-Dist: fastapi>=0.110
|
| 76 |
-
Requires-Dist: uvicorn>=0.29
|
| 77 |
-
Requires-Dist: onnx>=1.16
|
| 78 |
-
Requires-Dist: onnxruntime>=1.17
|
| 79 |
-
Requires-Dist: gradio>=4.25
|
| 80 |
-
Requires-Dist: matplotlib>=3.8
|
| 81 |
-
Requires-Dist: seaborn>=0.13
|
| 82 |
-
Requires-Dist: tqdm>=4.66
|
| 83 |
-
Requires-Dist: kaggle>=1.6
|
| 84 |
-
Requires-Dist: python-dotenv>=1.0
|
| 85 |
-
Provides-Extra: dev
|
| 86 |
-
Requires-Dist: pytest>=8.0; extra == "dev"
|
| 87 |
-
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
| 88 |
-
Requires-Dist: ruff>=0.4; extra == "dev"
|
| 89 |
-
Requires-Dist: pre-commit>=3.7; extra == "dev"
|
| 90 |
-
Requires-Dist: ipykernel>=6.29; extra == "dev"
|
| 91 |
-
Requires-Dist: jupyterlab>=4.1; extra == "dev"
|
| 92 |
-
Dynamic: license-file
|
| 93 |
-
|
| 94 |
-
<div align="center">
|
| 95 |
-
|
| 96 |
-
# Diabetic Retinopathy Detection
|
| 97 |
-
|
| 98 |
-
### Explainable, quality-aware AI for diabetic retinopathy screening in rural India — built to be audited, not just admired
|
| 99 |
-
|
| 100 |
-
[](docs/04_ROADMAP.md)
|
| 101 |
-
[-blue)](LICENSE)
|
| 102 |
-
[](#getting-started)
|
| 103 |
-
[](#tech-stack)
|
| 104 |
-
[](#ethical-boundary--intended-use)
|
| 105 |
-
|
| 106 |
-
[**Analysis**](docs/01_PROJECT_ANALYSIS.md) · [**Literature**](docs/02_LITERATURE_REVIEW.md) · [**Tech Stack**](docs/03_TECH_STACK.md) · [**Roadmap**](docs/04_ROADMAP.md) · [**Prototype Scope**](docs/05_PROTOTYPE_SCOPE.md) · [**Report Bug**](https://github.com/adarshcod30/Diabetic-Retinopathy-Detection/issues)
|
| 107 |
-
|
| 108 |
-
</div>
|
| 109 |
-
|
| 110 |
-
> **⚠️ Development status.** This repository is in **Phase 0** of a 20-week plan. The architecture,
|
| 111 |
-
> evidence base, and validation protocol are complete and documented; models are not yet trained.
|
| 112 |
-
> Every performance figure below is labelled **target** until it is measured on a locked test set.
|
| 113 |
-
> No unmeasured number is presented as a result.
|
| 114 |
-
|
| 115 |
-
---
|
| 116 |
-
|
| 117 |
-
## Table of Contents
|
| 118 |
-
|
| 119 |
-
- [Overview](#overview)
|
| 120 |
-
- [Key Features](#key-features)
|
| 121 |
-
- [Tech Stack](#tech-stack)
|
| 122 |
-
- [System Architecture](#system-architecture)
|
| 123 |
-
- [Application Flow](#application-flow)
|
| 124 |
-
- [Data & ML Pipeline](#data--ml-pipeline)
|
| 125 |
-
- [Results & Model Performance](#results--model-performance)
|
| 126 |
-
- [Deployment & Infrastructure](#deployment--infrastructure)
|
| 127 |
-
- [Prototype Scope](#prototype-scope-tier-p)
|
| 128 |
-
- [Project Structure](#project-structure)
|
| 129 |
-
- [Getting Started](#getting-started)
|
| 130 |
-
- [Usage](#usage)
|
| 131 |
-
- [Testing](#testing)
|
| 132 |
-
- [Roadmap](#roadmap)
|
| 133 |
-
- [Ethical Boundary & Intended Use](#ethical-boundary--intended-use)
|
| 134 |
-
- [Contributing](#contributing)
|
| 135 |
-
- [License](#license)
|
| 136 |
-
- [Contact](#contact)
|
| 137 |
-
|
| 138 |
-
---
|
| 139 |
-
|
| 140 |
-
## Overview
|
| 141 |
-
|
| 142 |
-
**Problem.** India has roughly **101 million adults with diabetes** (ICMR-INDIAB, *Lancet Diabetes &
|
| 143 |
-
Endocrinology* 2023 — 11.4 % weighted prevalence across 113,043 participants). Diabetic retinopathy
|
| 144 |
-
affects **14–17 %** of them, and there is approximately **one retina specialist per 1.26 million
|
| 145 |
-
people**. Manual screening at that scale is arithmetically impossible.
|
| 146 |
-
|
| 147 |
-
**Solution.** A seven-stage screening pipeline that assesses image quality before it grades, grades
|
| 148 |
-
using the International Clinical DR Severity Scale, and explains itself with lesion-level evidence a
|
| 149 |
-
clinician can verify in seconds — plus a discrete-event simulation of the district screening programme
|
| 150 |
-
the model would run inside.
|
| 151 |
-
|
| 152 |
-
**Why it is different.** Automated DR grading is a solved-enough problem: [Gulshan 2016](https://jamanetwork.com/journals/jama/fullarticle/2588763)
|
| 153 |
-
reached 97.5 % sensitivity, and [IDx-DR](https://www.nature.com/articles/s41746-018-0040-6) is
|
| 154 |
-
FDA-authorised. The unsolved problems are the ones this project targets:
|
| 155 |
-
|
| 156 |
-
1. **Explainability that is measured, not asserted.** Nearly every DR project ships a Grad-CAM and
|
| 157 |
-
stops. This one runs [Adebayo's sanity checks](https://arxiv.org/abs/1810.03292) on the saliency
|
| 158 |
-
method itself, then *quantifies* whether the heatmap lands on real lesions using IDRiD's
|
| 159 |
-
pixel-level masks (pointing game, CAM–lesion IoU).
|
| 160 |
-
2. **Graceful failure on field images.** [Beede et al. (CHI 2020)](https://dl.acm.org/doi/10.1145/3313831.3376718)
|
| 161 |
-
found that in 11 Thai clinics the dominant failure was ungradable images being silently rejected,
|
| 162 |
-
wasting patient trips. Here, rejection returns an actionable reason: defocus, illumination, or
|
| 163 |
-
field of view.
|
| 164 |
-
3. **Clinically grounded evidence.** Lesion segmentation feeds *both* the grade and the explanation,
|
| 165 |
-
mapped onto the ICDR criteria — so the rationale is the same evidence the model actually used.
|
| 166 |
-
|
| 167 |
-
**Keywords:** `diabetic-retinopathy` `medical-imaging` `explainable-ai` `grad-cam` `deep-learning`
|
| 168 |
-
`pytorch` `image-segmentation` `fundus-photography` `computer-vision` `healthcare-ai` `ordinal-regression`
|
| 169 |
-
`model-calibration` `simpy` `rural-health` `screening`
|
| 170 |
-
|
| 171 |
-
---
|
| 172 |
-
|
| 173 |
-
## Key Features
|
| 174 |
-
|
| 175 |
-
| Feature | Description |
|
| 176 |
-
|---|---|
|
| 177 |
-
| **Quality gate with recapture guidance** | 3-class gradability model (EyeQ) fused with focus, illumination and FOV features; a rejected image returns *why*, so the operator can retake it while the patient is still seated |
|
| 178 |
-
| **Adaptive enhancement** | Ben Graham local-colour normalisation, CLAHE, illumination correction — every step ablated, none assumed |
|
| 179 |
-
| **Retinal structure segmentation** | Vessels (DRIVE), optic disc & fovea landmarks, and four lesion classes (MA, haemorrhage, hard/soft exudate) at pixel level |
|
| 180 |
-
| **Sub-pixel microaneurysm detection** | Candidate generation via morphological top-hat + matched filtering, then CNN classification — because MAs vanish under naïve downscaling |
|
| 181 |
-
| **Ordinal DR grading** | ICDR 0–4 with rank-consistent ordinal heads, not plain cross-entropy: grading 0 as 4 is not the same mistake as 0 as 1 |
|
| 182 |
-
| **Lesion-aware fusion** | CNN embedding concatenated with clinically meaningful lesion features (counts, areas, quadrant distribution) |
|
| 183 |
-
| **Calibrated confidence** | Temperature scaling with reliability diagrams and ECE; low-confidence cases escalate to a human grader |
|
| 184 |
-
| **Audited explainability** | Grad-CAM variants scored against ground-truth lesion masks, with sanity checks reported pass/fail |
|
| 185 |
-
| **Auto-generated clinical report** | Annotated PDF with lesion overlays, ICDR evidence table, and calibrated confidence — designed for a 30-second review |
|
| 186 |
-
| **Screening-programme simulation** | SimPy discrete-event model of a 100,000-patient/year district programme: bandwidth, throughput, grader capacity, ophthalmologist-hours freed |
|
| 187 |
-
|
| 188 |
-
---
|
| 189 |
-
|
| 190 |
-
## Tech Stack
|
| 191 |
-
|
| 192 |
-
| Layer | Technology |
|
| 193 |
-
|---|---|
|
| 194 |
-
| DL framework | PyTorch 2.x · PyTorch Lightning · timm |
|
| 195 |
-
| Medical imaging | MONAI · segmentation-models-pytorch |
|
| 196 |
-
| Classical CV | OpenCV · scikit-image · Albumentations |
|
| 197 |
-
| Explainability | pytorch-grad-cam (Grad-CAM / ++ / Score-CAM / Eigen-CAM) |
|
| 198 |
-
| Config & tracking | Hydra + OmegaConf · Weights & Biases |
|
| 199 |
-
| Metrics & statistics | torchmetrics · scikit-learn · scipy · statsmodels (bootstrap CI, DeLong, McNemar) |
|
| 200 |
-
| Simulation | SimPy *(optional Simulink mirror)* |
|
| 201 |
-
| Serving | FastAPI · ONNX Runtime · Docker |
|
| 202 |
-
| Demo | Gradio → HuggingFace Spaces |
|
| 203 |
-
| Reporting | ReportLab |
|
| 204 |
-
| Quality | ruff · pytest · pre-commit · GitHub Actions |
|
| 205 |
-
|
| 206 |
-
Rationale for every choice, including the MATLAB→open-source mapping: [`docs/03_TECH_STACK.md`](docs/03_TECH_STACK.md).
|
| 207 |
-
|
| 208 |
-
---
|
| 209 |
-
|
| 210 |
-
## System Architecture
|
| 211 |
-
|
| 212 |
-
```mermaid
|
| 213 |
-
flowchart TD
|
| 214 |
-
A[Fundus image<br/>portable camera] --> B[Stage 1<br/>Quality Assessment]
|
| 215 |
-
B -->|Reject| B1[Recapture guidance:<br/>defocus · illumination · FOV]
|
| 216 |
-
B1 -.retake.-> A
|
| 217 |
-
B -->|Good / Usable| C[Stage 2<br/>Adaptive Enhancement]
|
| 218 |
-
C --> D[Stage 3<br/>Segmentation]
|
| 219 |
-
D --> D1[Vessels]
|
| 220 |
-
D --> D2[Optic disc / fovea<br/>→ quadrant map]
|
| 221 |
-
D --> D3[Lesions: MA · HE · SE · EX]
|
| 222 |
-
D3 --> E[Lesion features<br/>counts · areas · quadrants]
|
| 223 |
-
C --> F[Stage 4<br/>Grading backbone]
|
| 224 |
-
E --> G[Fusion head]
|
| 225 |
-
F --> G
|
| 226 |
-
G --> H[Stage 5<br/>Ordinal head + calibration]
|
| 227 |
-
H --> I{Confident?}
|
| 228 |
-
I -->|Yes| J[Stage 6<br/>Explainability]
|
| 229 |
-
I -->|No| K[Escalate to human grader]
|
| 230 |
-
D3 --> J
|
| 231 |
-
J --> L[Annotated PDF report]
|
| 232 |
-
L --> M[Ophthalmologist review]
|
| 233 |
-
K --> M
|
| 234 |
-
M --> N[(Audit log)]
|
| 235 |
-
N -.feedback.-> F
|
| 236 |
-
```
|
| 237 |
-
|
| 238 |
-
**In plain language.** An image arrives from a portable camera in a primary health centre. Before
|
| 239 |
-
anything else, the system decides whether it is *gradable* — and if not, tells the operator exactly
|
| 240 |
-
what to fix. A gradable image is normalised for the wide variation in camera and lighting, then passed
|
| 241 |
-
down two parallel paths: one segments the retina's structures and lesions, the other classifies overall
|
| 242 |
-
severity. The two paths meet at a fusion head, because the lesions a clinician would look for are
|
| 243 |
-
exactly the features that should drive the grade. The result is calibrated into an honest probability;
|
| 244 |
-
uncertain cases go straight to a human. Confident cases produce a report whose explanation is grounded
|
| 245 |
-
in the same lesion evidence the model used — not a decorative heatmap. Every decision is logged, and
|
| 246 |
-
graders' corrections feed the next training round.
|
| 247 |
-
|
| 248 |
-
---
|
| 249 |
-
|
| 250 |
-
## Application Flow
|
| 251 |
-
|
| 252 |
-
```mermaid
|
| 253 |
-
sequenceDiagram
|
| 254 |
-
participant P as Patient
|
| 255 |
-
participant H as Health worker
|
| 256 |
-
participant E as Edge app
|
| 257 |
-
participant S as District server
|
| 258 |
-
participant O as Ophthalmologist
|
| 259 |
-
|
| 260 |
-
P->>H: Arrives at screening camp
|
| 261 |
-
H->>E: Capture fundus image
|
| 262 |
-
E->>E: On-device quality check
|
| 263 |
-
alt Ungradable
|
| 264 |
-
E-->>H: "Image defocused — retake"
|
| 265 |
-
H->>E: Recapture
|
| 266 |
-
end
|
| 267 |
-
E->>S: Upload (store-and-forward if offline)
|
| 268 |
-
S->>S: Enhance → segment → grade → calibrate
|
| 269 |
-
alt High confidence, no DR
|
| 270 |
-
S-->>H: Auto-cleared · routine recall
|
| 271 |
-
else Referable or uncertain
|
| 272 |
-
S->>O: Queue annotated report
|
| 273 |
-
O->>O: Review (~30 s target)
|
| 274 |
-
O-->>H: Confirmed grade + referral
|
| 275 |
-
end
|
| 276 |
-
H-->>P: Result and next step
|
| 277 |
-
O->>S: Correction logged for retraining
|
| 278 |
-
```
|
| 279 |
-
|
| 280 |
-
The **on-device quality check is deliberate**: a round trip to the cloud to learn the image was blurry
|
| 281 |
-
is precisely the failure mode documented in Thailand. The check must complete while the patient is
|
| 282 |
-
still in the chair.
|
| 283 |
-
|
| 284 |
-
---
|
| 285 |
-
|
| 286 |
-
## Data & ML Pipeline
|
| 287 |
-
|
| 288 |
-
### Data sources
|
| 289 |
-
|
| 290 |
-
| Dataset | Size | Labels | Population | Role | Licence |
|
| 291 |
-
|---|---|---|---|---|---|
|
| 292 |
-
| [APTOS 2019](https://www.kaggle.com/c/aptos2019-blindness-detection) | 3,662 | ICDR 0–4 | **India** (Aravind, Madurai) | Primary training | Competition rules |
|
| 293 |
-
| [EyePACS 2015](https://www.kaggle.com/c/diabetic-retinopathy-detection) | 88,702 | ICDR 0–4 (noisy) | US | Pretraining *(cloud-side only)* | Competition rules |
|
| 294 |
-
| [IDRiD](https://ieee-dataport.org/open-access/indian-diabetic-retinopathy-image-dataset-idrid) | 516 | Grades + **pixel masks** + OD/fovea | **India** (Nanded) | Lesion segmentation, XAI ground truth | CC BY 4.0 |
|
| 295 |
-
| [Messidor-2](https://www.adcis.net/en/third-party/messidor2/) | 1,748 | Adjudicated grades | France | **Locked external test** | ADCIS terms |
|
| 296 |
-
| [DRIVE](https://drive.grand-challenge.org/) | 40 | Vessel masks | Netherlands | Vessel segmentation | Research use |
|
| 297 |
-
| EyeQ | 28,792 | Good / Usable / Reject | derived from EyePACS | Quality model | Research use |
|
| 298 |
-
|
| 299 |
-
> Raw images are **never committed**. `scripts/download_data.sh` fetches them; `data/manifests/` commits
|
| 300 |
-
> sha256 hashes so anyone can verify byte-identical inputs.
|
| 301 |
-
|
| 302 |
-
### Pipeline
|
| 303 |
-
|
| 304 |
-
```mermaid
|
| 305 |
-
flowchart LR
|
| 306 |
-
R[Raw fundus] --> Q{Gradable?}
|
| 307 |
-
Q -->|No| RC[Recapture]
|
| 308 |
-
Q -->|Yes| P1[crop_from_gray]
|
| 309 |
-
P1 --> P2[circle_crop]
|
| 310 |
-
P2 --> P3[Ben Graham<br/>local colour norm]
|
| 311 |
-
P3 --> P4[CLAHE + illumination norm]
|
| 312 |
-
P4 --> P5[Resize 512–768 px<br/>cache to disk]
|
| 313 |
-
P5 --> AUG[Augment:<br/>flip · rotate · colour jitter]
|
| 314 |
-
AUG --> TR[Train]
|
| 315 |
-
TR --> CAL[Calibrate on val]
|
| 316 |
-
CAL --> TEST[Locked external test<br/>evaluated ONCE]
|
| 317 |
-
```
|
| 318 |
-
|
| 319 |
-
**Cleaning & preparation.** Circle-crop removes the black surround and standardises the field of view;
|
| 320 |
-
`crop_from_gray` handles off-centre captures. **Ben Graham preprocessing** — subtracting a heavily
|
| 321 |
-
blurred copy to remove local average colour — is the single highest-value step, as established by the
|
| 322 |
-
Kaggle DR 2015 winner and used by most APTOS top solutions. Preprocessed images are cached once at
|
| 323 |
-
512 px, which shrinks APTOS from ~10 GB to ~200 MB and makes every subsequent epoch cheaper.
|
| 324 |
-
|
| 325 |
-
**Feature engineering.** Beyond learned features, the lesion branch yields *clinically named* features:
|
| 326 |
-
microaneurysm count, haemorrhage and exudate area, per-quadrant distribution (using the optic
|
| 327 |
-
disc–fovea axis), and distance-to-fovea. These are the ICDR criteria expressed numerically — they
|
| 328 |
-
improve the grade and simultaneously become the explanation.
|
| 329 |
-
|
| 330 |
-
**Training approach.**
|
| 331 |
-
- Two-stage: pretrain on EyePACS (or initialise from [RETFound](https://www.nature.com/articles/s41586-023-06555-x),
|
| 332 |
-
a masked-autoencoder foundation model trained on 1.6 M retinal images), then fine-tune on APTOS.
|
| 333 |
-
- **Ordinal loss**, not cross-entropy — DR grades are ordered, and recent SOTA
|
| 334 |
-
([Dual-SwinOrd](https://www.mdpi.com/2306-5354/13/4/374), AOR-DR) confirms this matters.
|
| 335 |
-
- **Resolution is the dominant hyperparameter.** A microaneurysm is ~10 px on a 4288 px image; at
|
| 336 |
-
224 px it is sub-pixel and physically destroyed. Sweep 384/512/768 early.
|
| 337 |
-
- **Patient-level splits** — both eyes of one patient must never straddle train and test.
|
| 338 |
-
|
| 339 |
-
**Evaluation metrics.** Quadratic Weighted Kappa (grading); sensitivity and specificity at the
|
| 340 |
-
referable-DR operating point (grade ≥ 2); AUROC; **AUPRC for lesion segmentation** (lesion pixels are
|
| 341 |
-
<0.1 % of an image, which makes AUROC flattering and near-meaningless); Expected Calibration Error;
|
| 342 |
-
all with bootstrap 95 % CIs, DeLong for AUC comparisons, and McNemar for paired sensitivity/specificity.
|
| 343 |
-
|
| 344 |
-
---
|
| 345 |
-
|
| 346 |
-
## Results & Model Performance
|
| 347 |
-
|
| 348 |
-
> **No results yet — models are not trained.** The table below states **targets** and the **published
|
| 349 |
-
> work they are benchmarked against**. It will be replaced with measured values, confidence intervals,
|
| 350 |
-
> and the full ablation once Phase 8 completes.
|
| 351 |
-
|
| 352 |
-
### Targets
|
| 353 |
-
|
| 354 |
-
| Metric | Target | Benchmark it is measured against |
|
| 355 |
-
|---|---|---|
|
| 356 |
-
| Referable DR sensitivity | **≥ 90 %** | Ting 2017: 90.5 % · IDx-DR: 87.2 % · Google/Aravind: 88.9 % |
|
| 357 |
-
| Referable DR specificity | **≥ 85 %** | Ting 2017: 91.6 % · IDx-DR: 90.7 % · Google/Aravind: 92.2 % |
|
| 358 |
-
| QWK (APTOS, 5-class) | **≥ 0.90** | Dual-SwinOrd SOTA: 0.9370 |
|
| 359 |
-
| Quality classification (EyeQ) | **≈ 0.90 acc** | VISTA: 0.9066 acc, 0.8868 F1 |
|
| 360 |
-
| Lesion AUPRC (IDRiD) | per-class, cross-validated | IDRiD ISBI-2018 challenge leaderboard |
|
| 361 |
-
| Calibration (ECE) | **< 0.05** after temperature scaling | — |
|
| 362 |
-
| Grad-CAM localisation | pointing-game accuracy vs IDRiD masks | *rarely reported — this is the contribution* |
|
| 363 |
-
|
| 364 |
-
### Published comparators
|
| 365 |
-
|
| 366 |
-
| Study | Task | Sensitivity | Specificity | AUC |
|
| 367 |
-
|---|---|---|---|---|
|
| 368 |
-
| [Gulshan 2016 (JAMA)](https://jamanetwork.com/journals/jama/fullarticle/2588763) | Referable DR, EyePACS-1 | 97.5 % | 93.4 % | 0.991 |
|
| 369 |
-
| [Ting 2017 (JAMA)](https://pubmed.ncbi.nlm.nih.gov/29234807/) | Referable DR, multiethnic | 90.5 % | 91.6 % | 0.936 |
|
| 370 |
-
| [Abràmoff 2018 (npj Digit Med)](https://www.nature.com/articles/s41746-018-0040-6) | mtmDR, **prospective primary care** | 87.2 % | 90.7 % | — |
|
| 371 |
-
| [Gulshan 2019 (JAMA Ophthalmol)](https://research.google/pubs/performance-of-a-deep-learning-algorithm-vs-manual-grading-for-detecting-diabetic-retinopathy-in-india/) | Referable DR, **Aravind, India** | 88.9 % | 92.2 % | 0.963 |
|
| 372 |
-
| same | Referable DR, **Sankara Nethralaya** | 92.1 % | 95.2 % | 0.980 |
|
| 373 |
-
|
| 374 |
-
> **A note on honesty:** a faithful reproduction of Gulshan 2016 scored AUC 0.951 on EyePACS but only
|
| 375 |
-
> **0.853 on Messidor-2**. External validation drops are the norm, not a failure. This project plans
|
| 376 |
-
> for that drop, reports it, and analyses the domain shift rather than re-tuning until it disappears.
|
| 377 |
-
|
| 378 |
-
The planned **ablation** — eleven configurations, one factor added per row, evaluated on the locked
|
| 379 |
-
test set with significance tests — is specified in
|
| 380 |
-
[§7 of the analysis](docs/01_PROJECT_ANALYSIS.md#7-the-ablation-that-answers-integrated--single-technique).
|
| 381 |
-
|
| 382 |
-
---
|
| 383 |
-
|
| 384 |
-
## Deployment & Infrastructure
|
| 385 |
-
|
| 386 |
-
| Concern | Approach |
|
| 387 |
-
|---|---|
|
| 388 |
-
| **Training** | Kaggle Notebooks (T4/P100, 30 GPU-h/week, EyePACS pre-mounted) — chosen because the dev machine has 28 GB free disk and EyePACS is ~90 GB |
|
| 389 |
-
| **Local development** | Apple M4 via PyTorch MPS — preprocessing, IDRiD segmentation, inference, explainability, demo |
|
| 390 |
-
| **Model export** | PyTorch → ONNX → ONNX Runtime (server), CoreML (Apple), TFLite (Android capture app) |
|
| 391 |
-
| **Serving** | FastAPI + ONNX Runtime in Docker (`linux/amd64` + `linux/arm64`, so Jetson/Raspberry Pi remain viable for a PHC) |
|
| 392 |
-
| **Edge** | On-device quality check so blur is caught before upload, not after |
|
| 393 |
-
| **Connectivity** | Store-and-forward queue; the simulation explicitly models 1–10 Mbps rural links and outages |
|
| 394 |
-
| **Public demo** | Gradio on HuggingFace Spaces (free CPU tier) |
|
| 395 |
-
| **CI/CD** | GitHub Actions — ruff, pytest, and an end-to-end single-image smoke test on CPU |
|
| 396 |
-
| **Monitoring** | Audit log of every prediction + grader override; drift review before any retraining |
|
| 397 |
-
| **Reproducibility** | Hydra configs, fixed seeds, sha256 data manifests; `make setup && make evaluate` reproduces the headline table |
|
| 398 |
-
|
| 399 |
-
---
|
| 400 |
-
|
| 401 |
-
## Project Structure
|
| 402 |
-
|
| 403 |
-
```
|
| 404 |
-
Diabetic-Retinopathy-Detection/
|
| 405 |
-
├── configs/ # Hydra YAMLs — one per experiment; this IS the ablation grid
|
| 406 |
-
├── data/ # gitignored (manifests are committed)
|
| 407 |
-
│ ├── raw/ interim/ processed/ external/
|
| 408 |
-
│ └── manifests/ # sha256 + labels + patient_id
|
| 409 |
-
├── docs/
|
| 410 |
-
│ ├── 01_PROJECT_ANALYSIS.md # what this is, why it is hard, what "done" means
|
| 411 |
-
│ ├── 02_LITERATURE_REVIEW.md # annotated evidence base
|
| 412 |
-
│ ├── 03_TECH_STACK.md # tooling decisions and rationale
|
| 413 |
-
│ ├── 04_ROADMAP.md # 20-week phased plan
|
| 414 |
-
│ └── 05_PROTOTYPE_SCOPE.md # Tier-P: scaling down without breaking the science
|
| 415 |
-
├── notebooks/ # exploration only — logic lives in src/
|
| 416 |
-
├── src/drdetect/
|
| 417 |
-
│ ├── data/ # datasets, patient-level splits, manifests
|
| 418 |
-
│ ├── quality/ # Stage 1 — gradability + recapture guidance
|
| 419 |
-
│ ├── enhance/ # Stage 2 — Ben Graham, CLAHE, illumination
|
| 420 |
-
│ ├── segmentation/ # Stage 3 — vessels, OD/fovea, lesions
|
| 421 |
-
│ ├── grading/ # Stage 4 — backbones, ordinal heads, fusion
|
| 422 |
-
│ ├── calibration/ # Stage 5 — temperature scaling, thresholds, uncertainty
|
| 423 |
-
│ ├── explain/ # Stage 6 — CAMs, sanity checks, localisation metrics, reports
|
| 424 |
-
│ ├── eval/ # metrics, bootstrap CI, DeLong, McNemar
|
| 425 |
-
│ ├── serve/ # FastAPI + ONNX
|
| 426 |
-
│ └── utils/ # seeding, logging, io
|
| 427 |
-
├── simulation/
|
| 428 |
-
│ ├── simpy/ # Stage 7 — district screening programme model
|
| 429 |
-
│ └── simulink/ # optional .slx mirror
|
| 430 |
-
├── scripts/ # benchmark_device.py · download_data.sh · preprocess.py · train.py · evaluate.py
|
| 431 |
-
├── tests/
|
| 432 |
-
├── models/ # gitignored; released via GitHub Releases / HF Hub
|
| 433 |
-
└── .github/workflows/
|
| 434 |
-
```
|
| 435 |
-
|
| 436 |
-
---
|
| 437 |
-
|
| 438 |
-
## Getting Started
|
| 439 |
-
|
| 440 |
-
### Prerequisites
|
| 441 |
-
|
| 442 |
-
- Python **3.11** (not 3.13 — several CV/DL wheels still lag)
|
| 443 |
-
- ~20 GB free disk for local datasets
|
| 444 |
-
- A Kaggle account (for datasets and free GPU)
|
| 445 |
-
- Optional: CUDA GPU. Apple Silicon works via MPS for everything except large-scale training.
|
| 446 |
-
|
| 447 |
-
### Installation
|
| 448 |
-
|
| 449 |
-
```bash
|
| 450 |
-
git clone https://github.com/adarshcod30/Diabetic-Retinopathy-Detection.git
|
| 451 |
-
cd Diabetic-Retinopathy-Detection
|
| 452 |
-
```
|
| 453 |
-
|
| 454 |
-
```bash
|
| 455 |
-
conda create -n dr python=3.11 -y && conda activate dr
|
| 456 |
-
```
|
| 457 |
-
|
| 458 |
-
```bash
|
| 459 |
-
pip install -e ".[dev]"
|
| 460 |
-
```
|
| 461 |
-
|
| 462 |
-
```bash
|
| 463 |
-
python -c "import torch; print('MPS:', torch.backends.mps.is_available(), '| CUDA:', torch.cuda.is_available())"
|
| 464 |
-
```
|
| 465 |
-
|
| 466 |
-
### Get the data
|
| 467 |
-
|
| 468 |
-
Place your Kaggle token at `~/.config/kaggle/kaggle.json`, then:
|
| 469 |
-
|
| 470 |
-
```bash
|
| 471 |
-
bash scripts/download_data.sh --datasets aptos,idrid,drive
|
| 472 |
-
```
|
| 473 |
-
|
| 474 |
-
> Messidor-2 requires accepting [ADCIS terms](https://www.adcis.net/en/third-party/messidor2/) and is
|
| 475 |
-
> downloaded manually into `data/external/messidor2/`. **Do not** download EyePACS locally — it is
|
| 476 |
-
> ~90 GB; train against it on Kaggle instead.
|
| 477 |
-
|
| 478 |
-
### Preprocess
|
| 479 |
-
|
| 480 |
-
```bash
|
| 481 |
-
python scripts/preprocess.py --dataset aptos --size 512 --pipeline bengraham
|
| 482 |
-
```
|
| 483 |
-
|
| 484 |
-
---
|
| 485 |
-
|
| 486 |
-
## Usage
|
| 487 |
-
|
| 488 |
-
Grade a single image and produce an annotated PDF report:
|
| 489 |
-
|
| 490 |
-
```bash
|
| 491 |
-
python scripts/predict.py --image path/to/fundus.jpg --report out/report.pdf
|
| 492 |
-
```
|
| 493 |
-
|
| 494 |
-
Launch the interactive demo:
|
| 495 |
-
|
| 496 |
-
```bash
|
| 497 |
-
python -m drdetect.serve.demo
|
| 498 |
-
```
|
| 499 |
-
|
| 500 |
-
Run a training experiment (Hydra — every ablation row is a config override):
|
| 501 |
-
|
| 502 |
-
```bash
|
| 503 |
-
python scripts/train.py experiment=grading_effnetv2 data.image_size=512 loss=ordinal
|
| 504 |
-
```
|
| 505 |
-
|
| 506 |
-
Evaluate on the locked external test set:
|
| 507 |
-
|
| 508 |
-
```bash
|
| 509 |
-
python scripts/evaluate.py --checkpoint models/checkpoints/best.ckpt --split external_test --bootstrap 2000
|
| 510 |
-
```
|
| 511 |
-
|
| 512 |
-
Run the screening-programme simulation:
|
| 513 |
-
|
| 514 |
-
```bash
|
| 515 |
-
python -m simulation.simpy.district --patients-per-year 100000 --graders 4 --bandwidth-mbps 5
|
| 516 |
-
```
|
| 517 |
-
|
| 518 |
-
> Commands reflect the target interface; scripts land progressively through Phases 1–7. See
|
| 519 |
-
> [`docs/04_ROADMAP.md`](docs/04_ROADMAP.md) for what exists today.
|
| 520 |
-
|
| 521 |
-
---
|
| 522 |
-
|
| 523 |
-
## Testing
|
| 524 |
-
|
| 525 |
-
```bash
|
| 526 |
-
pytest -q
|
| 527 |
-
```
|
| 528 |
-
|
| 529 |
-
```bash
|
| 530 |
-
ruff check . && ruff format --check .
|
| 531 |
-
```
|
| 532 |
-
|
| 533 |
-
Test strategy:
|
| 534 |
-
- **Unit** — preprocessing determinism, circle-crop geometry, metric correctness against hand-computed
|
| 535 |
-
cases, patient-level split integrity (*asserts no patient appears in two splits* — the leakage bug
|
| 536 |
-
that silently inflates every published number)
|
| 537 |
-
- **Integration** — one image end-to-end on CPU, asserting a valid grade and a well-formed PDF
|
| 538 |
-
- **CI** — ruff + pytest + the smoke test on every push
|
| 539 |
-
|
| 540 |
-
---
|
| 541 |
-
|
| 542 |
-
## Roadmap
|
| 543 |
-
|
| 544 |
-
| Phase | Weeks | Milestone |
|
| 545 |
-
|---|---|---|
|
| 546 |
-
| 0 · Foundation | 1 | Repo, environment, data audit, manifests |
|
| 547 |
-
| 1 · Baseline | 2–3 | Baseline QWK on APTOS — a number to beat |
|
| 548 |
-
| **2 · Vertical slice** ⭐ | 4–5 | **JPEG in → annotated PDF out** |
|
| 549 |
-
| 3 · Grading depth | 6–8 | Resolution sweep, ordinal loss, pretraining → QWK ≥ 0.90 |
|
| 550 |
-
| 4 · Segmentation | 9–11 | Vessels, OD/fovea, four lesion classes, MA detector |
|
| 551 |
-
| 5 · Integration | 12–13 | Lesion fusion + calibration + uncertainty escalation |
|
| 552 |
-
| **6 · Rigorous XAI** ⭐ | 14–15 | **Sanity checks + measured CAM localisation** |
|
| 553 |
-
| 7 · Simulation | 16–17 | District programme model, ophthalmologist-hours freed |
|
| 554 |
-
| 8 · Validation | 18–19 | Full ablation on locked test set with significance tests |
|
| 555 |
-
| 9 · Release | 20 | ONNX, Docker, HF Spaces demo, model card |
|
| 556 |
-
|
| 557 |
-
Detail, exit criteria, and an explicit scope-cut order: [`docs/04_ROADMAP.md`](docs/04_ROADMAP.md).
|
| 558 |
-
|
| 559 |
-
**Known scope limit:** neovascularisation segmentation is *out of scope* — no public dataset provides
|
| 560 |
-
NV pixel masks. PDR is detected at image level and the gap is documented rather than papered over.
|
| 561 |
-
|
| 562 |
-
---
|
| 563 |
-
|
| 564 |
-
## Prototype Scope (Tier-P)
|
| 565 |
-
|
| 566 |
-
Development runs on a 16 GB Apple M4 with ~28 GB free disk. That constrains **compute**, not
|
| 567 |
-
data — after caching at 512 px, APTOS, IDRiD, Messidor-2 and DRIVE together occupy **0.4 GB**.
|
| 568 |
-
|
| 569 |
-
So the scale-down cuts compute and keeps every image:
|
| 570 |
-
|
| 571 |
-
| | Full (Tier-F) | **Prototype (Tier-P)** |
|
| 572 |
-
|---|---|---|
|
| 573 |
-
| APTOS / IDRiD / DRIVE / Messidor-2 | full | **full — unchanged** |
|
| 574 |
-
| EyePACS pretraining | 88,702 | 12–15k stratified, cached on Kaggle |
|
| 575 |
-
| Backbone | EfficientNetV2-S | EfficientNet-B0 |
|
| 576 |
-
| Resolution | 768 px | 512 px *(floor — below this microaneurysms vanish)* |
|
| 577 |
-
| Cross-validation | 5-fold + TTA | single split + hflip TTA |
|
| 578 |
-
|
| 579 |
-
**≈34× cheaper, zero task data discarded.** Every cut is a config override, reversible on
|
| 580 |
-
Kaggle without re-splitting or re-collecting anything.
|
| 581 |
-
|
| 582 |
-
Two things are never scaled down: the **test set** (a 50-case test set gives a ±8.5 pp
|
| 583 |
-
confidence interval, which makes a ">90 % sensitivity" claim unmakeable) and the **validation
|
| 584 |
-
protocol**. Grade 3 has only 193 images in all of APTOS — subsampling collapses it to 15–38
|
| 585 |
-
training examples long before the dataset merely looks small.
|
| 586 |
-
|
| 587 |
-
Full arithmetic and the claims that do and don't survive: [`docs/05_PROTOTYPE_SCOPE.md`](docs/05_PROTOTYPE_SCOPE.md).
|
| 588 |
-
|
| 589 |
-
## Ethical Boundary & Intended Use
|
| 590 |
-
|
| 591 |
-
**This is a research prototype. It is not a medical device and must not be used for clinical
|
| 592 |
-
diagnosis.**
|
| 593 |
-
|
| 594 |
-
- Software intended for diagnosis falls under **CDSCO** medical-device rules in India; the comparable
|
| 595 |
-
US system (IDx-DR) required an FDA De Novo authorisation. This project has neither and seeks neither.
|
| 596 |
-
- Outputs are **decision support with a human in the loop**, never a diagnosis. Every generated report
|
| 597 |
-
carries the disclaimer.
|
| 598 |
-
- **Training populations are documented and limited:** APTOS and IDRiD are Indian, EyePACS is US,
|
| 599 |
-
Messidor-2 is French. Performance on any other population is **unverified**.
|
| 600 |
-
- No patient-identifying data is stored in this repository; demo images are EXIF-stripped.
|
| 601 |
-
- Released weights are **research use only**, consistent with the licences of the data they derive from.
|
| 602 |
-
|
| 603 |
-
---
|
| 604 |
-
|
| 605 |
-
## Contributing
|
| 606 |
-
|
| 607 |
-
Contributions are welcome — particularly clinical review of the explanation format, additional external
|
| 608 |
-
validation datasets, and reproductions of the ablation.
|
| 609 |
-
|
| 610 |
-
1. Fork and branch (`git checkout -b feature/your-feature`)
|
| 611 |
-
2. `pre-commit install`
|
| 612 |
-
3. Add tests for anything you change
|
| 613 |
-
4. Ensure `pytest` and `ruff check .` pass
|
| 614 |
-
5. Open a pull request describing *what you measured*, not only what you changed
|
| 615 |
-
|
| 616 |
-
Findings that contradict results here are especially welcome. Please open an issue with the
|
| 617 |
-
configuration and seed.
|
| 618 |
-
|
| 619 |
-
---
|
| 620 |
-
|
| 621 |
-
## License
|
| 622 |
-
|
| 623 |
-
Code is released under the **MIT License** — see [LICENSE](LICENSE).
|
| 624 |
-
|
| 625 |
-
**Data and weights are not.** Datasets remain under their original licences (IDRiD CC BY 4.0;
|
| 626 |
-
APTOS/EyePACS competition rules; Messidor-2 ADCIS terms; DRIVE research use). Raw images are never
|
| 627 |
-
redistributed here. Trained weights derived from research-use data are released for **research use
|
| 628 |
-
only**.
|
| 629 |
-
|
| 630 |
-
---
|
| 631 |
-
|
| 632 |
-
## Contact
|
| 633 |
-
|
| 634 |
-
**Adarsh Dwivedi** · [@adarshcod30](https://github.com/adarshcod30)
|
| 635 |
-
Project: <https://github.com/adarshcod30/Diabetic-Retinopathy-Detection>
|
| 636 |
-
|
| 637 |
-
## Acknowledgments
|
| 638 |
-
|
| 639 |
-
The datasets that make this possible: [APTOS](https://www.kaggle.com/c/aptos2019-blindness-detection)
|
| 640 |
-
(Aravind Eye Hospital), [IDRiD](https://idrid.grand-challenge.org/) (Porwal et al.),
|
| 641 |
-
[Messidor-2](https://www.adcis.net/en/third-party/messidor2/) (ADCIS), and
|
| 642 |
-
[DRIVE](https://drive.grand-challenge.org/). Full evidence base:
|
| 643 |
-
[`docs/02_LITERATURE_REVIEW.md`](docs/02_LITERATURE_REVIEW.md).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/drdetect.egg-info/SOURCES.txt
DELETED
|
@@ -1,20 +0,0 @@
|
|
| 1 |
-
LICENSE
|
| 2 |
-
README.md
|
| 3 |
-
pyproject.toml
|
| 4 |
-
src/drdetect/__init__.py
|
| 5 |
-
src/drdetect.egg-info/PKG-INFO
|
| 6 |
-
src/drdetect.egg-info/SOURCES.txt
|
| 7 |
-
src/drdetect.egg-info/dependency_links.txt
|
| 8 |
-
src/drdetect.egg-info/requires.txt
|
| 9 |
-
src/drdetect.egg-info/top_level.txt
|
| 10 |
-
src/drdetect/calibration/__init__.py
|
| 11 |
-
src/drdetect/data/__init__.py
|
| 12 |
-
src/drdetect/enhance/__init__.py
|
| 13 |
-
src/drdetect/enhance/preprocessing.py
|
| 14 |
-
src/drdetect/eval/__init__.py
|
| 15 |
-
src/drdetect/explain/__init__.py
|
| 16 |
-
src/drdetect/grading/__init__.py
|
| 17 |
-
src/drdetect/quality/__init__.py
|
| 18 |
-
src/drdetect/segmentation/__init__.py
|
| 19 |
-
src/drdetect/serve/__init__.py
|
| 20 |
-
src/drdetect/utils/__init__.py
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/drdetect.egg-info/dependency_links.txt
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
|
|
|
|
|
|
src/drdetect.egg-info/requires.txt
DELETED
|
@@ -1,40 +0,0 @@
|
|
| 1 |
-
torch>=2.2
|
| 2 |
-
torchvision>=0.17
|
| 3 |
-
lightning>=2.2
|
| 4 |
-
timm>=1.0
|
| 5 |
-
monai>=1.3
|
| 6 |
-
segmentation-models-pytorch>=0.3
|
| 7 |
-
opencv-python-headless>=4.9
|
| 8 |
-
scikit-image>=0.22
|
| 9 |
-
albumentations>=1.4
|
| 10 |
-
Pillow>=10.2
|
| 11 |
-
numpy>=1.26
|
| 12 |
-
pandas>=2.2
|
| 13 |
-
scipy>=1.12
|
| 14 |
-
scikit-learn>=1.4
|
| 15 |
-
statsmodels>=0.14
|
| 16 |
-
torchmetrics>=1.3
|
| 17 |
-
hydra-core>=1.3
|
| 18 |
-
omegaconf>=2.3
|
| 19 |
-
wandb>=0.16
|
| 20 |
-
grad-cam>=1.5
|
| 21 |
-
simpy>=4.1
|
| 22 |
-
reportlab>=4.1
|
| 23 |
-
fastapi>=0.110
|
| 24 |
-
uvicorn>=0.29
|
| 25 |
-
onnx>=1.16
|
| 26 |
-
onnxruntime>=1.17
|
| 27 |
-
gradio>=4.25
|
| 28 |
-
matplotlib>=3.8
|
| 29 |
-
seaborn>=0.13
|
| 30 |
-
tqdm>=4.66
|
| 31 |
-
kaggle>=1.6
|
| 32 |
-
python-dotenv>=1.0
|
| 33 |
-
|
| 34 |
-
[dev]
|
| 35 |
-
pytest>=8.0
|
| 36 |
-
pytest-cov>=5.0
|
| 37 |
-
ruff>=0.4
|
| 38 |
-
pre-commit>=3.7
|
| 39 |
-
ipykernel>=6.29
|
| 40 |
-
jupyterlab>=4.1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/drdetect.egg-info/top_level.txt
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
drdetect
|
|
|
|
|
|
src/drdetect/serve/pipeline.py
CHANGED
|
@@ -128,7 +128,17 @@ def run_pipeline(
|
|
| 128 |
class_probs = probs
|
| 129 |
confidence = float(probs[grade])
|
| 130 |
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
overlay = overlay_cam(pre, cam)
|
| 133 |
|
| 134 |
return PredictionResult(
|
|
|
|
| 128 |
class_probs = probs
|
| 129 |
confidence = float(probs[grade])
|
| 130 |
|
| 131 |
+
# Grad-CAM indexes directly into the model's raw output (pytorch_grad_cam's
|
| 132 |
+
# ClassifierOutputTarget does `output[target_class]`), which only equals
|
| 133 |
+
# `grade` for the 5-way heads (ce/distance_ce). A regression head has a
|
| 134 |
+
# single output (outputs_for_loss("regression") == 1) -- passing grade=2
|
| 135 |
+
# there indexes position 2 of a 1-element tensor and crashes
|
| 136 |
+
# (IndexError inside pytorch_grad_cam, surfacing as an UnboundLocalError
|
| 137 |
+
# in generate_cam). Clamping to the head's actual output count fixes every
|
| 138 |
+
# loss: a no-op for ce/distance_ce, and the only valid (and semantically
|
| 139 |
+
# correct -- there is only one score to explain) choice for regression.
|
| 140 |
+
cam_target = min(grade, outputs_for_loss(loss_name) - 1)
|
| 141 |
+
cam = generate_cam(model, tensor, target_class=cam_target)
|
| 142 |
overlay = overlay_cam(pre, cam)
|
| 143 |
|
| 144 |
return PredictionResult(
|
src/src/drdetect.egg-info/PKG-INFO
DELETED
|
@@ -1,643 +0,0 @@
|
|
| 1 |
-
Metadata-Version: 2.4
|
| 2 |
-
Name: drdetect
|
| 3 |
-
Version: 0.1.0
|
| 4 |
-
Summary: Explainable, quality-aware AI for diabetic retinopathy screening
|
| 5 |
-
Author: Adarsh Dwivedi
|
| 6 |
-
License: MIT License
|
| 7 |
-
|
| 8 |
-
Copyright (c) 2026 Adarsh Dwivedi
|
| 9 |
-
|
| 10 |
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 11 |
-
of this software and associated documentation files (the "Software"), to deal
|
| 12 |
-
in the Software without restriction, including without limitation the rights
|
| 13 |
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 14 |
-
copies of the Software, and to permit persons to whom the Software is
|
| 15 |
-
furnished to do so, subject to the following conditions:
|
| 16 |
-
|
| 17 |
-
The above copyright notice and this permission notice shall be included in all
|
| 18 |
-
copies or substantial portions of the Software.
|
| 19 |
-
|
| 20 |
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 21 |
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 22 |
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 23 |
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 24 |
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 25 |
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 26 |
-
SOFTWARE.
|
| 27 |
-
|
| 28 |
-
---
|
| 29 |
-
|
| 30 |
-
NOTE ON SCOPE: This licence covers the SOURCE CODE in this repository only.
|
| 31 |
-
|
| 32 |
-
Datasets referenced by this project remain under their own licences and are NOT
|
| 33 |
-
redistributed here (IDRiD: CC BY 4.0; APTOS / EyePACS: Kaggle competition rules;
|
| 34 |
-
Messidor-2: ADCIS terms; DRIVE: research use with registration).
|
| 35 |
-
|
| 36 |
-
Trained model weights derived from those datasets are released for RESEARCH USE
|
| 37 |
-
ONLY.
|
| 38 |
-
|
| 39 |
-
This software is a research prototype. It is NOT a medical device and must not
|
| 40 |
-
be used for clinical diagnosis.
|
| 41 |
-
|
| 42 |
-
Project-URL: Homepage, https://github.com/adarshcod30/Diabetic-Retinopathy-Detection
|
| 43 |
-
Project-URL: Issues, https://github.com/adarshcod30/Diabetic-Retinopathy-Detection/issues
|
| 44 |
-
Keywords: diabetic-retinopathy,medical-imaging,explainable-ai,deep-learning,fundus,computer-vision,healthcare-ai
|
| 45 |
-
Classifier: Development Status :: 2 - Pre-Alpha
|
| 46 |
-
Classifier: Intended Audience :: Science/Research
|
| 47 |
-
Classifier: License :: OSI Approved :: MIT License
|
| 48 |
-
Classifier: Programming Language :: Python :: 3.11
|
| 49 |
-
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
| 50 |
-
Requires-Python: <3.13,>=3.10
|
| 51 |
-
Description-Content-Type: text/markdown
|
| 52 |
-
License-File: LICENSE
|
| 53 |
-
Requires-Dist: torch>=2.2
|
| 54 |
-
Requires-Dist: torchvision>=0.17
|
| 55 |
-
Requires-Dist: lightning>=2.2
|
| 56 |
-
Requires-Dist: timm>=1.0
|
| 57 |
-
Requires-Dist: monai>=1.3
|
| 58 |
-
Requires-Dist: segmentation-models-pytorch>=0.3
|
| 59 |
-
Requires-Dist: opencv-python-headless>=4.9
|
| 60 |
-
Requires-Dist: scikit-image>=0.22
|
| 61 |
-
Requires-Dist: albumentations>=1.4
|
| 62 |
-
Requires-Dist: Pillow>=10.2
|
| 63 |
-
Requires-Dist: numpy>=1.26
|
| 64 |
-
Requires-Dist: pandas>=2.2
|
| 65 |
-
Requires-Dist: scipy>=1.12
|
| 66 |
-
Requires-Dist: scikit-learn>=1.4
|
| 67 |
-
Requires-Dist: statsmodels>=0.14
|
| 68 |
-
Requires-Dist: torchmetrics>=1.3
|
| 69 |
-
Requires-Dist: hydra-core>=1.3
|
| 70 |
-
Requires-Dist: omegaconf>=2.3
|
| 71 |
-
Requires-Dist: wandb>=0.16
|
| 72 |
-
Requires-Dist: grad-cam>=1.5
|
| 73 |
-
Requires-Dist: simpy>=4.1
|
| 74 |
-
Requires-Dist: reportlab>=4.1
|
| 75 |
-
Requires-Dist: fastapi>=0.110
|
| 76 |
-
Requires-Dist: uvicorn>=0.29
|
| 77 |
-
Requires-Dist: onnx>=1.16
|
| 78 |
-
Requires-Dist: onnxruntime>=1.17
|
| 79 |
-
Requires-Dist: gradio>=4.25
|
| 80 |
-
Requires-Dist: matplotlib>=3.8
|
| 81 |
-
Requires-Dist: seaborn>=0.13
|
| 82 |
-
Requires-Dist: tqdm>=4.66
|
| 83 |
-
Requires-Dist: kaggle>=1.6
|
| 84 |
-
Requires-Dist: python-dotenv>=1.0
|
| 85 |
-
Provides-Extra: dev
|
| 86 |
-
Requires-Dist: pytest>=8.0; extra == "dev"
|
| 87 |
-
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
| 88 |
-
Requires-Dist: ruff>=0.4; extra == "dev"
|
| 89 |
-
Requires-Dist: pre-commit>=3.7; extra == "dev"
|
| 90 |
-
Requires-Dist: ipykernel>=6.29; extra == "dev"
|
| 91 |
-
Requires-Dist: jupyterlab>=4.1; extra == "dev"
|
| 92 |
-
Dynamic: license-file
|
| 93 |
-
|
| 94 |
-
<div align="center">
|
| 95 |
-
|
| 96 |
-
# Diabetic Retinopathy Detection
|
| 97 |
-
|
| 98 |
-
### Explainable, quality-aware AI for diabetic retinopathy screening in rural India — built to be audited, not just admired
|
| 99 |
-
|
| 100 |
-
[](docs/04_ROADMAP.md)
|
| 101 |
-
[-blue)](LICENSE)
|
| 102 |
-
[](#getting-started)
|
| 103 |
-
[](#tech-stack)
|
| 104 |
-
[](#ethical-boundary--intended-use)
|
| 105 |
-
|
| 106 |
-
[**Analysis**](docs/01_PROJECT_ANALYSIS.md) · [**Literature**](docs/02_LITERATURE_REVIEW.md) · [**Tech Stack**](docs/03_TECH_STACK.md) · [**Roadmap**](docs/04_ROADMAP.md) · [**Prototype Scope**](docs/05_PROTOTYPE_SCOPE.md) · [**Report Bug**](https://github.com/adarshcod30/Diabetic-Retinopathy-Detection/issues)
|
| 107 |
-
|
| 108 |
-
</div>
|
| 109 |
-
|
| 110 |
-
> **⚠️ Development status.** This repository is in **Phase 0** of a 20-week plan. The architecture,
|
| 111 |
-
> evidence base, and validation protocol are complete and documented; models are not yet trained.
|
| 112 |
-
> Every performance figure below is labelled **target** until it is measured on a locked test set.
|
| 113 |
-
> No unmeasured number is presented as a result.
|
| 114 |
-
|
| 115 |
-
---
|
| 116 |
-
|
| 117 |
-
## Table of Contents
|
| 118 |
-
|
| 119 |
-
- [Overview](#overview)
|
| 120 |
-
- [Key Features](#key-features)
|
| 121 |
-
- [Tech Stack](#tech-stack)
|
| 122 |
-
- [System Architecture](#system-architecture)
|
| 123 |
-
- [Application Flow](#application-flow)
|
| 124 |
-
- [Data & ML Pipeline](#data--ml-pipeline)
|
| 125 |
-
- [Results & Model Performance](#results--model-performance)
|
| 126 |
-
- [Deployment & Infrastructure](#deployment--infrastructure)
|
| 127 |
-
- [Prototype Scope](#prototype-scope-tier-p)
|
| 128 |
-
- [Project Structure](#project-structure)
|
| 129 |
-
- [Getting Started](#getting-started)
|
| 130 |
-
- [Usage](#usage)
|
| 131 |
-
- [Testing](#testing)
|
| 132 |
-
- [Roadmap](#roadmap)
|
| 133 |
-
- [Ethical Boundary & Intended Use](#ethical-boundary--intended-use)
|
| 134 |
-
- [Contributing](#contributing)
|
| 135 |
-
- [License](#license)
|
| 136 |
-
- [Contact](#contact)
|
| 137 |
-
|
| 138 |
-
---
|
| 139 |
-
|
| 140 |
-
## Overview
|
| 141 |
-
|
| 142 |
-
**Problem.** India has roughly **101 million adults with diabetes** (ICMR-INDIAB, *Lancet Diabetes &
|
| 143 |
-
Endocrinology* 2023 — 11.4 % weighted prevalence across 113,043 participants). Diabetic retinopathy
|
| 144 |
-
affects **14–17 %** of them, and there is approximately **one retina specialist per 1.26 million
|
| 145 |
-
people**. Manual screening at that scale is arithmetically impossible.
|
| 146 |
-
|
| 147 |
-
**Solution.** A seven-stage screening pipeline that assesses image quality before it grades, grades
|
| 148 |
-
using the International Clinical DR Severity Scale, and explains itself with lesion-level evidence a
|
| 149 |
-
clinician can verify in seconds — plus a discrete-event simulation of the district screening programme
|
| 150 |
-
the model would run inside.
|
| 151 |
-
|
| 152 |
-
**Why it is different.** Automated DR grading is a solved-enough problem: [Gulshan 2016](https://jamanetwork.com/journals/jama/fullarticle/2588763)
|
| 153 |
-
reached 97.5 % sensitivity, and [IDx-DR](https://www.nature.com/articles/s41746-018-0040-6) is
|
| 154 |
-
FDA-authorised. The unsolved problems are the ones this project targets:
|
| 155 |
-
|
| 156 |
-
1. **Explainability that is measured, not asserted.** Nearly every DR project ships a Grad-CAM and
|
| 157 |
-
stops. This one runs [Adebayo's sanity checks](https://arxiv.org/abs/1810.03292) on the saliency
|
| 158 |
-
method itself, then *quantifies* whether the heatmap lands on real lesions using IDRiD's
|
| 159 |
-
pixel-level masks (pointing game, CAM–lesion IoU).
|
| 160 |
-
2. **Graceful failure on field images.** [Beede et al. (CHI 2020)](https://dl.acm.org/doi/10.1145/3313831.3376718)
|
| 161 |
-
found that in 11 Thai clinics the dominant failure was ungradable images being silently rejected,
|
| 162 |
-
wasting patient trips. Here, rejection returns an actionable reason: defocus, illumination, or
|
| 163 |
-
field of view.
|
| 164 |
-
3. **Clinically grounded evidence.** Lesion segmentation feeds *both* the grade and the explanation,
|
| 165 |
-
mapped onto the ICDR criteria — so the rationale is the same evidence the model actually used.
|
| 166 |
-
|
| 167 |
-
**Keywords:** `diabetic-retinopathy` `medical-imaging` `explainable-ai` `grad-cam` `deep-learning`
|
| 168 |
-
`pytorch` `image-segmentation` `fundus-photography` `computer-vision` `healthcare-ai` `ordinal-regression`
|
| 169 |
-
`model-calibration` `simpy` `rural-health` `screening`
|
| 170 |
-
|
| 171 |
-
---
|
| 172 |
-
|
| 173 |
-
## Key Features
|
| 174 |
-
|
| 175 |
-
| Feature | Description |
|
| 176 |
-
|---|---|
|
| 177 |
-
| **Quality gate with recapture guidance** | 3-class gradability model (EyeQ) fused with focus, illumination and FOV features; a rejected image returns *why*, so the operator can retake it while the patient is still seated |
|
| 178 |
-
| **Adaptive enhancement** | Ben Graham local-colour normalisation, CLAHE, illumination correction — every step ablated, none assumed |
|
| 179 |
-
| **Retinal structure segmentation** | Vessels (DRIVE), optic disc & fovea landmarks, and four lesion classes (MA, haemorrhage, hard/soft exudate) at pixel level |
|
| 180 |
-
| **Sub-pixel microaneurysm detection** | Candidate generation via morphological top-hat + matched filtering, then CNN classification — because MAs vanish under naïve downscaling |
|
| 181 |
-
| **Ordinal DR grading** | ICDR 0–4 with rank-consistent ordinal heads, not plain cross-entropy: grading 0 as 4 is not the same mistake as 0 as 1 |
|
| 182 |
-
| **Lesion-aware fusion** | CNN embedding concatenated with clinically meaningful lesion features (counts, areas, quadrant distribution) |
|
| 183 |
-
| **Calibrated confidence** | Temperature scaling with reliability diagrams and ECE; low-confidence cases escalate to a human grader |
|
| 184 |
-
| **Audited explainability** | Grad-CAM variants scored against ground-truth lesion masks, with sanity checks reported pass/fail |
|
| 185 |
-
| **Auto-generated clinical report** | Annotated PDF with lesion overlays, ICDR evidence table, and calibrated confidence — designed for a 30-second review |
|
| 186 |
-
| **Screening-programme simulation** | SimPy discrete-event model of a 100,000-patient/year district programme: bandwidth, throughput, grader capacity, ophthalmologist-hours freed |
|
| 187 |
-
|
| 188 |
-
---
|
| 189 |
-
|
| 190 |
-
## Tech Stack
|
| 191 |
-
|
| 192 |
-
| Layer | Technology |
|
| 193 |
-
|---|---|
|
| 194 |
-
| DL framework | PyTorch 2.x · PyTorch Lightning · timm |
|
| 195 |
-
| Medical imaging | MONAI · segmentation-models-pytorch |
|
| 196 |
-
| Classical CV | OpenCV · scikit-image · Albumentations |
|
| 197 |
-
| Explainability | pytorch-grad-cam (Grad-CAM / ++ / Score-CAM / Eigen-CAM) |
|
| 198 |
-
| Config & tracking | Hydra + OmegaConf · Weights & Biases |
|
| 199 |
-
| Metrics & statistics | torchmetrics · scikit-learn · scipy · statsmodels (bootstrap CI, DeLong, McNemar) |
|
| 200 |
-
| Simulation | SimPy *(optional Simulink mirror)* |
|
| 201 |
-
| Serving | FastAPI · ONNX Runtime · Docker |
|
| 202 |
-
| Demo | Gradio → HuggingFace Spaces |
|
| 203 |
-
| Reporting | ReportLab |
|
| 204 |
-
| Quality | ruff · pytest · pre-commit · GitHub Actions |
|
| 205 |
-
|
| 206 |
-
Rationale for every choice, including the MATLAB→open-source mapping: [`docs/03_TECH_STACK.md`](docs/03_TECH_STACK.md).
|
| 207 |
-
|
| 208 |
-
---
|
| 209 |
-
|
| 210 |
-
## System Architecture
|
| 211 |
-
|
| 212 |
-
```mermaid
|
| 213 |
-
flowchart TD
|
| 214 |
-
A[Fundus image<br/>portable camera] --> B[Stage 1<br/>Quality Assessment]
|
| 215 |
-
B -->|Reject| B1[Recapture guidance:<br/>defocus · illumination · FOV]
|
| 216 |
-
B1 -.retake.-> A
|
| 217 |
-
B -->|Good / Usable| C[Stage 2<br/>Adaptive Enhancement]
|
| 218 |
-
C --> D[Stage 3<br/>Segmentation]
|
| 219 |
-
D --> D1[Vessels]
|
| 220 |
-
D --> D2[Optic disc / fovea<br/>→ quadrant map]
|
| 221 |
-
D --> D3[Lesions: MA · HE · SE · EX]
|
| 222 |
-
D3 --> E[Lesion features<br/>counts · areas · quadrants]
|
| 223 |
-
C --> F[Stage 4<br/>Grading backbone]
|
| 224 |
-
E --> G[Fusion head]
|
| 225 |
-
F --> G
|
| 226 |
-
G --> H[Stage 5<br/>Ordinal head + calibration]
|
| 227 |
-
H --> I{Confident?}
|
| 228 |
-
I -->|Yes| J[Stage 6<br/>Explainability]
|
| 229 |
-
I -->|No| K[Escalate to human grader]
|
| 230 |
-
D3 --> J
|
| 231 |
-
J --> L[Annotated PDF report]
|
| 232 |
-
L --> M[Ophthalmologist review]
|
| 233 |
-
K --> M
|
| 234 |
-
M --> N[(Audit log)]
|
| 235 |
-
N -.feedback.-> F
|
| 236 |
-
```
|
| 237 |
-
|
| 238 |
-
**In plain language.** An image arrives from a portable camera in a primary health centre. Before
|
| 239 |
-
anything else, the system decides whether it is *gradable* — and if not, tells the operator exactly
|
| 240 |
-
what to fix. A gradable image is normalised for the wide variation in camera and lighting, then passed
|
| 241 |
-
down two parallel paths: one segments the retina's structures and lesions, the other classifies overall
|
| 242 |
-
severity. The two paths meet at a fusion head, because the lesions a clinician would look for are
|
| 243 |
-
exactly the features that should drive the grade. The result is calibrated into an honest probability;
|
| 244 |
-
uncertain cases go straight to a human. Confident cases produce a report whose explanation is grounded
|
| 245 |
-
in the same lesion evidence the model used — not a decorative heatmap. Every decision is logged, and
|
| 246 |
-
graders' corrections feed the next training round.
|
| 247 |
-
|
| 248 |
-
---
|
| 249 |
-
|
| 250 |
-
## Application Flow
|
| 251 |
-
|
| 252 |
-
```mermaid
|
| 253 |
-
sequenceDiagram
|
| 254 |
-
participant P as Patient
|
| 255 |
-
participant H as Health worker
|
| 256 |
-
participant E as Edge app
|
| 257 |
-
participant S as District server
|
| 258 |
-
participant O as Ophthalmologist
|
| 259 |
-
|
| 260 |
-
P->>H: Arrives at screening camp
|
| 261 |
-
H->>E: Capture fundus image
|
| 262 |
-
E->>E: On-device quality check
|
| 263 |
-
alt Ungradable
|
| 264 |
-
E-->>H: "Image defocused — retake"
|
| 265 |
-
H->>E: Recapture
|
| 266 |
-
end
|
| 267 |
-
E->>S: Upload (store-and-forward if offline)
|
| 268 |
-
S->>S: Enhance → segment → grade → calibrate
|
| 269 |
-
alt High confidence, no DR
|
| 270 |
-
S-->>H: Auto-cleared · routine recall
|
| 271 |
-
else Referable or uncertain
|
| 272 |
-
S->>O: Queue annotated report
|
| 273 |
-
O->>O: Review (~30 s target)
|
| 274 |
-
O-->>H: Confirmed grade + referral
|
| 275 |
-
end
|
| 276 |
-
H-->>P: Result and next step
|
| 277 |
-
O->>S: Correction logged for retraining
|
| 278 |
-
```
|
| 279 |
-
|
| 280 |
-
The **on-device quality check is deliberate**: a round trip to the cloud to learn the image was blurry
|
| 281 |
-
is precisely the failure mode documented in Thailand. The check must complete while the patient is
|
| 282 |
-
still in the chair.
|
| 283 |
-
|
| 284 |
-
---
|
| 285 |
-
|
| 286 |
-
## Data & ML Pipeline
|
| 287 |
-
|
| 288 |
-
### Data sources
|
| 289 |
-
|
| 290 |
-
| Dataset | Size | Labels | Population | Role | Licence |
|
| 291 |
-
|---|---|---|---|---|---|
|
| 292 |
-
| [APTOS 2019](https://www.kaggle.com/c/aptos2019-blindness-detection) | 3,662 | ICDR 0–4 | **India** (Aravind, Madurai) | Primary training | Competition rules |
|
| 293 |
-
| [EyePACS 2015](https://www.kaggle.com/c/diabetic-retinopathy-detection) | 88,702 | ICDR 0–4 (noisy) | US | Pretraining *(cloud-side only)* | Competition rules |
|
| 294 |
-
| [IDRiD](https://ieee-dataport.org/open-access/indian-diabetic-retinopathy-image-dataset-idrid) | 516 | Grades + **pixel masks** + OD/fovea | **India** (Nanded) | Lesion segmentation, XAI ground truth | CC BY 4.0 |
|
| 295 |
-
| [Messidor-2](https://www.adcis.net/en/third-party/messidor2/) | 1,748 | Adjudicated grades | France | **Locked external test** | ADCIS terms |
|
| 296 |
-
| [DRIVE](https://drive.grand-challenge.org/) | 40 | Vessel masks | Netherlands | Vessel segmentation | Research use |
|
| 297 |
-
| EyeQ | 28,792 | Good / Usable / Reject | derived from EyePACS | Quality model | Research use |
|
| 298 |
-
|
| 299 |
-
> Raw images are **never committed**. `scripts/download_data.sh` fetches them; `data/manifests/` commits
|
| 300 |
-
> sha256 hashes so anyone can verify byte-identical inputs.
|
| 301 |
-
|
| 302 |
-
### Pipeline
|
| 303 |
-
|
| 304 |
-
```mermaid
|
| 305 |
-
flowchart LR
|
| 306 |
-
R[Raw fundus] --> Q{Gradable?}
|
| 307 |
-
Q -->|No| RC[Recapture]
|
| 308 |
-
Q -->|Yes| P1[crop_from_gray]
|
| 309 |
-
P1 --> P2[circle_crop]
|
| 310 |
-
P2 --> P3[Ben Graham<br/>local colour norm]
|
| 311 |
-
P3 --> P4[CLAHE + illumination norm]
|
| 312 |
-
P4 --> P5[Resize 512–768 px<br/>cache to disk]
|
| 313 |
-
P5 --> AUG[Augment:<br/>flip · rotate · colour jitter]
|
| 314 |
-
AUG --> TR[Train]
|
| 315 |
-
TR --> CAL[Calibrate on val]
|
| 316 |
-
CAL --> TEST[Locked external test<br/>evaluated ONCE]
|
| 317 |
-
```
|
| 318 |
-
|
| 319 |
-
**Cleaning & preparation.** Circle-crop removes the black surround and standardises the field of view;
|
| 320 |
-
`crop_from_gray` handles off-centre captures. **Ben Graham preprocessing** — subtracting a heavily
|
| 321 |
-
blurred copy to remove local average colour — is the single highest-value step, as established by the
|
| 322 |
-
Kaggle DR 2015 winner and used by most APTOS top solutions. Preprocessed images are cached once at
|
| 323 |
-
512 px, which shrinks APTOS from ~10 GB to ~200 MB and makes every subsequent epoch cheaper.
|
| 324 |
-
|
| 325 |
-
**Feature engineering.** Beyond learned features, the lesion branch yields *clinically named* features:
|
| 326 |
-
microaneurysm count, haemorrhage and exudate area, per-quadrant distribution (using the optic
|
| 327 |
-
disc–fovea axis), and distance-to-fovea. These are the ICDR criteria expressed numerically — they
|
| 328 |
-
improve the grade and simultaneously become the explanation.
|
| 329 |
-
|
| 330 |
-
**Training approach.**
|
| 331 |
-
- Two-stage: pretrain on EyePACS (or initialise from [RETFound](https://www.nature.com/articles/s41586-023-06555-x),
|
| 332 |
-
a masked-autoencoder foundation model trained on 1.6 M retinal images), then fine-tune on APTOS.
|
| 333 |
-
- **Ordinal loss**, not cross-entropy — DR grades are ordered, and recent SOTA
|
| 334 |
-
([Dual-SwinOrd](https://www.mdpi.com/2306-5354/13/4/374), AOR-DR) confirms this matters.
|
| 335 |
-
- **Resolution is the dominant hyperparameter.** A microaneurysm is ~10 px on a 4288 px image; at
|
| 336 |
-
224 px it is sub-pixel and physically destroyed. Sweep 384/512/768 early.
|
| 337 |
-
- **Patient-level splits** — both eyes of one patient must never straddle train and test.
|
| 338 |
-
|
| 339 |
-
**Evaluation metrics.** Quadratic Weighted Kappa (grading); sensitivity and specificity at the
|
| 340 |
-
referable-DR operating point (grade ≥ 2); AUROC; **AUPRC for lesion segmentation** (lesion pixels are
|
| 341 |
-
<0.1 % of an image, which makes AUROC flattering and near-meaningless); Expected Calibration Error;
|
| 342 |
-
all with bootstrap 95 % CIs, DeLong for AUC comparisons, and McNemar for paired sensitivity/specificity.
|
| 343 |
-
|
| 344 |
-
---
|
| 345 |
-
|
| 346 |
-
## Results & Model Performance
|
| 347 |
-
|
| 348 |
-
> **No results yet — models are not trained.** The table below states **targets** and the **published
|
| 349 |
-
> work they are benchmarked against**. It will be replaced with measured values, confidence intervals,
|
| 350 |
-
> and the full ablation once Phase 8 completes.
|
| 351 |
-
|
| 352 |
-
### Targets
|
| 353 |
-
|
| 354 |
-
| Metric | Target | Benchmark it is measured against |
|
| 355 |
-
|---|---|---|
|
| 356 |
-
| Referable DR sensitivity | **≥ 90 %** | Ting 2017: 90.5 % · IDx-DR: 87.2 % · Google/Aravind: 88.9 % |
|
| 357 |
-
| Referable DR specificity | **≥ 85 %** | Ting 2017: 91.6 % · IDx-DR: 90.7 % · Google/Aravind: 92.2 % |
|
| 358 |
-
| QWK (APTOS, 5-class) | **≥ 0.90** | Dual-SwinOrd SOTA: 0.9370 |
|
| 359 |
-
| Quality classification (EyeQ) | **≈ 0.90 acc** | VISTA: 0.9066 acc, 0.8868 F1 |
|
| 360 |
-
| Lesion AUPRC (IDRiD) | per-class, cross-validated | IDRiD ISBI-2018 challenge leaderboard |
|
| 361 |
-
| Calibration (ECE) | **< 0.05** after temperature scaling | — |
|
| 362 |
-
| Grad-CAM localisation | pointing-game accuracy vs IDRiD masks | *rarely reported — this is the contribution* |
|
| 363 |
-
|
| 364 |
-
### Published comparators
|
| 365 |
-
|
| 366 |
-
| Study | Task | Sensitivity | Specificity | AUC |
|
| 367 |
-
|---|---|---|---|---|
|
| 368 |
-
| [Gulshan 2016 (JAMA)](https://jamanetwork.com/journals/jama/fullarticle/2588763) | Referable DR, EyePACS-1 | 97.5 % | 93.4 % | 0.991 |
|
| 369 |
-
| [Ting 2017 (JAMA)](https://pubmed.ncbi.nlm.nih.gov/29234807/) | Referable DR, multiethnic | 90.5 % | 91.6 % | 0.936 |
|
| 370 |
-
| [Abràmoff 2018 (npj Digit Med)](https://www.nature.com/articles/s41746-018-0040-6) | mtmDR, **prospective primary care** | 87.2 % | 90.7 % | — |
|
| 371 |
-
| [Gulshan 2019 (JAMA Ophthalmol)](https://research.google/pubs/performance-of-a-deep-learning-algorithm-vs-manual-grading-for-detecting-diabetic-retinopathy-in-india/) | Referable DR, **Aravind, India** | 88.9 % | 92.2 % | 0.963 |
|
| 372 |
-
| same | Referable DR, **Sankara Nethralaya** | 92.1 % | 95.2 % | 0.980 |
|
| 373 |
-
|
| 374 |
-
> **A note on honesty:** a faithful reproduction of Gulshan 2016 scored AUC 0.951 on EyePACS but only
|
| 375 |
-
> **0.853 on Messidor-2**. External validation drops are the norm, not a failure. This project plans
|
| 376 |
-
> for that drop, reports it, and analyses the domain shift rather than re-tuning until it disappears.
|
| 377 |
-
|
| 378 |
-
The planned **ablation** — eleven configurations, one factor added per row, evaluated on the locked
|
| 379 |
-
test set with significance tests — is specified in
|
| 380 |
-
[§7 of the analysis](docs/01_PROJECT_ANALYSIS.md#7-the-ablation-that-answers-integrated--single-technique).
|
| 381 |
-
|
| 382 |
-
---
|
| 383 |
-
|
| 384 |
-
## Deployment & Infrastructure
|
| 385 |
-
|
| 386 |
-
| Concern | Approach |
|
| 387 |
-
|---|---|
|
| 388 |
-
| **Training** | Kaggle Notebooks (T4/P100, 30 GPU-h/week, EyePACS pre-mounted) — chosen because the dev machine has 28 GB free disk and EyePACS is ~90 GB |
|
| 389 |
-
| **Local development** | Apple M4 via PyTorch MPS — preprocessing, IDRiD segmentation, inference, explainability, demo |
|
| 390 |
-
| **Model export** | PyTorch → ONNX → ONNX Runtime (server), CoreML (Apple), TFLite (Android capture app) |
|
| 391 |
-
| **Serving** | FastAPI + ONNX Runtime in Docker (`linux/amd64` + `linux/arm64`, so Jetson/Raspberry Pi remain viable for a PHC) |
|
| 392 |
-
| **Edge** | On-device quality check so blur is caught before upload, not after |
|
| 393 |
-
| **Connectivity** | Store-and-forward queue; the simulation explicitly models 1–10 Mbps rural links and outages |
|
| 394 |
-
| **Public demo** | Gradio on HuggingFace Spaces (free CPU tier) |
|
| 395 |
-
| **CI/CD** | GitHub Actions — ruff, pytest, and an end-to-end single-image smoke test on CPU |
|
| 396 |
-
| **Monitoring** | Audit log of every prediction + grader override; drift review before any retraining |
|
| 397 |
-
| **Reproducibility** | Hydra configs, fixed seeds, sha256 data manifests; `make setup && make evaluate` reproduces the headline table |
|
| 398 |
-
|
| 399 |
-
---
|
| 400 |
-
|
| 401 |
-
## Project Structure
|
| 402 |
-
|
| 403 |
-
```
|
| 404 |
-
Diabetic-Retinopathy-Detection/
|
| 405 |
-
├── configs/ # Hydra YAMLs — one per experiment; this IS the ablation grid
|
| 406 |
-
├── data/ # gitignored (manifests are committed)
|
| 407 |
-
│ ├── raw/ interim/ processed/ external/
|
| 408 |
-
│ └── manifests/ # sha256 + labels + patient_id
|
| 409 |
-
├── docs/
|
| 410 |
-
│ ├── 01_PROJECT_ANALYSIS.md # what this is, why it is hard, what "done" means
|
| 411 |
-
│ ├── 02_LITERATURE_REVIEW.md # annotated evidence base
|
| 412 |
-
│ ├── 03_TECH_STACK.md # tooling decisions and rationale
|
| 413 |
-
│ ├── 04_ROADMAP.md # 20-week phased plan
|
| 414 |
-
│ └── 05_PROTOTYPE_SCOPE.md # Tier-P: scaling down without breaking the science
|
| 415 |
-
├── notebooks/ # exploration only — logic lives in src/
|
| 416 |
-
├── src/drdetect/
|
| 417 |
-
│ ├── data/ # datasets, patient-level splits, manifests
|
| 418 |
-
│ ├── quality/ # Stage 1 — gradability + recapture guidance
|
| 419 |
-
│ ├── enhance/ # Stage 2 — Ben Graham, CLAHE, illumination
|
| 420 |
-
│ ├── segmentation/ # Stage 3 — vessels, OD/fovea, lesions
|
| 421 |
-
│ ├── grading/ # Stage 4 — backbones, ordinal heads, fusion
|
| 422 |
-
│ ├── calibration/ # Stage 5 — temperature scaling, thresholds, uncertainty
|
| 423 |
-
│ ├── explain/ # Stage 6 — CAMs, sanity checks, localisation metrics, reports
|
| 424 |
-
│ ├── eval/ # metrics, bootstrap CI, DeLong, McNemar
|
| 425 |
-
│ ├── serve/ # FastAPI + ONNX
|
| 426 |
-
│ └── utils/ # seeding, logging, io
|
| 427 |
-
├── simulation/
|
| 428 |
-
│ ├── simpy/ # Stage 7 — district screening programme model
|
| 429 |
-
│ └── simulink/ # optional .slx mirror
|
| 430 |
-
├── scripts/ # benchmark_device.py · download_data.sh · preprocess.py · train.py · evaluate.py
|
| 431 |
-
├── tests/
|
| 432 |
-
├── models/ # gitignored; released via GitHub Releases / HF Hub
|
| 433 |
-
└── .github/workflows/
|
| 434 |
-
```
|
| 435 |
-
|
| 436 |
-
---
|
| 437 |
-
|
| 438 |
-
## Getting Started
|
| 439 |
-
|
| 440 |
-
### Prerequisites
|
| 441 |
-
|
| 442 |
-
- Python **3.11** (not 3.13 — several CV/DL wheels still lag)
|
| 443 |
-
- ~20 GB free disk for local datasets
|
| 444 |
-
- A Kaggle account (for datasets and free GPU)
|
| 445 |
-
- Optional: CUDA GPU. Apple Silicon works via MPS for everything except large-scale training.
|
| 446 |
-
|
| 447 |
-
### Installation
|
| 448 |
-
|
| 449 |
-
```bash
|
| 450 |
-
git clone https://github.com/adarshcod30/Diabetic-Retinopathy-Detection.git
|
| 451 |
-
cd Diabetic-Retinopathy-Detection
|
| 452 |
-
```
|
| 453 |
-
|
| 454 |
-
```bash
|
| 455 |
-
conda create -n dr python=3.11 -y && conda activate dr
|
| 456 |
-
```
|
| 457 |
-
|
| 458 |
-
```bash
|
| 459 |
-
pip install -e ".[dev]"
|
| 460 |
-
```
|
| 461 |
-
|
| 462 |
-
```bash
|
| 463 |
-
python -c "import torch; print('MPS:', torch.backends.mps.is_available(), '| CUDA:', torch.cuda.is_available())"
|
| 464 |
-
```
|
| 465 |
-
|
| 466 |
-
### Get the data
|
| 467 |
-
|
| 468 |
-
Place your Kaggle token at `~/.config/kaggle/kaggle.json`, then:
|
| 469 |
-
|
| 470 |
-
```bash
|
| 471 |
-
bash scripts/download_data.sh --datasets aptos,idrid,drive
|
| 472 |
-
```
|
| 473 |
-
|
| 474 |
-
> Messidor-2 requires accepting [ADCIS terms](https://www.adcis.net/en/third-party/messidor2/) and is
|
| 475 |
-
> downloaded manually into `data/external/messidor2/`. **Do not** download EyePACS locally — it is
|
| 476 |
-
> ~90 GB; train against it on Kaggle instead.
|
| 477 |
-
|
| 478 |
-
### Preprocess
|
| 479 |
-
|
| 480 |
-
```bash
|
| 481 |
-
python scripts/preprocess.py --dataset aptos --size 512 --pipeline bengraham
|
| 482 |
-
```
|
| 483 |
-
|
| 484 |
-
---
|
| 485 |
-
|
| 486 |
-
## Usage
|
| 487 |
-
|
| 488 |
-
Grade a single image and produce an annotated PDF report:
|
| 489 |
-
|
| 490 |
-
```bash
|
| 491 |
-
python scripts/predict.py --image path/to/fundus.jpg --report out/report.pdf
|
| 492 |
-
```
|
| 493 |
-
|
| 494 |
-
Launch the interactive demo:
|
| 495 |
-
|
| 496 |
-
```bash
|
| 497 |
-
python -m drdetect.serve.demo
|
| 498 |
-
```
|
| 499 |
-
|
| 500 |
-
Run a training experiment (Hydra — every ablation row is a config override):
|
| 501 |
-
|
| 502 |
-
```bash
|
| 503 |
-
python scripts/train.py experiment=grading_effnetv2 data.image_size=512 loss=ordinal
|
| 504 |
-
```
|
| 505 |
-
|
| 506 |
-
Evaluate on the locked external test set:
|
| 507 |
-
|
| 508 |
-
```bash
|
| 509 |
-
python scripts/evaluate.py --checkpoint models/checkpoints/best.ckpt --split external_test --bootstrap 2000
|
| 510 |
-
```
|
| 511 |
-
|
| 512 |
-
Run the screening-programme simulation:
|
| 513 |
-
|
| 514 |
-
```bash
|
| 515 |
-
python -m simulation.simpy.district --patients-per-year 100000 --graders 4 --bandwidth-mbps 5
|
| 516 |
-
```
|
| 517 |
-
|
| 518 |
-
> Commands reflect the target interface; scripts land progressively through Phases 1–7. See
|
| 519 |
-
> [`docs/04_ROADMAP.md`](docs/04_ROADMAP.md) for what exists today.
|
| 520 |
-
|
| 521 |
-
---
|
| 522 |
-
|
| 523 |
-
## Testing
|
| 524 |
-
|
| 525 |
-
```bash
|
| 526 |
-
pytest -q
|
| 527 |
-
```
|
| 528 |
-
|
| 529 |
-
```bash
|
| 530 |
-
ruff check . && ruff format --check .
|
| 531 |
-
```
|
| 532 |
-
|
| 533 |
-
Test strategy:
|
| 534 |
-
- **Unit** — preprocessing determinism, circle-crop geometry, metric correctness against hand-computed
|
| 535 |
-
cases, patient-level split integrity (*asserts no patient appears in two splits* — the leakage bug
|
| 536 |
-
that silently inflates every published number)
|
| 537 |
-
- **Integration** — one image end-to-end on CPU, asserting a valid grade and a well-formed PDF
|
| 538 |
-
- **CI** — ruff + pytest + the smoke test on every push
|
| 539 |
-
|
| 540 |
-
---
|
| 541 |
-
|
| 542 |
-
## Roadmap
|
| 543 |
-
|
| 544 |
-
| Phase | Weeks | Milestone |
|
| 545 |
-
|---|---|---|
|
| 546 |
-
| 0 · Foundation | 1 | Repo, environment, data audit, manifests |
|
| 547 |
-
| 1 · Baseline | 2–3 | Baseline QWK on APTOS — a number to beat |
|
| 548 |
-
| **2 · Vertical slice** ⭐ | 4–5 | **JPEG in → annotated PDF out** |
|
| 549 |
-
| 3 · Grading depth | 6–8 | Resolution sweep, ordinal loss, pretraining → QWK ≥ 0.90 |
|
| 550 |
-
| 4 · Segmentation | 9–11 | Vessels, OD/fovea, four lesion classes, MA detector |
|
| 551 |
-
| 5 · Integration | 12–13 | Lesion fusion + calibration + uncertainty escalation |
|
| 552 |
-
| **6 · Rigorous XAI** ⭐ | 14–15 | **Sanity checks + measured CAM localisation** |
|
| 553 |
-
| 7 · Simulation | 16–17 | District programme model, ophthalmologist-hours freed |
|
| 554 |
-
| 8 · Validation | 18–19 | Full ablation on locked test set with significance tests |
|
| 555 |
-
| 9 · Release | 20 | ONNX, Docker, HF Spaces demo, model card |
|
| 556 |
-
|
| 557 |
-
Detail, exit criteria, and an explicit scope-cut order: [`docs/04_ROADMAP.md`](docs/04_ROADMAP.md).
|
| 558 |
-
|
| 559 |
-
**Known scope limit:** neovascularisation segmentation is *out of scope* — no public dataset provides
|
| 560 |
-
NV pixel masks. PDR is detected at image level and the gap is documented rather than papered over.
|
| 561 |
-
|
| 562 |
-
---
|
| 563 |
-
|
| 564 |
-
## Prototype Scope (Tier-P)
|
| 565 |
-
|
| 566 |
-
Development runs on a 16 GB Apple M4 with ~28 GB free disk. That constrains **compute**, not
|
| 567 |
-
data — after caching at 512 px, APTOS, IDRiD, Messidor-2 and DRIVE together occupy **0.4 GB**.
|
| 568 |
-
|
| 569 |
-
So the scale-down cuts compute and keeps every image:
|
| 570 |
-
|
| 571 |
-
| | Full (Tier-F) | **Prototype (Tier-P)** |
|
| 572 |
-
|---|---|---|
|
| 573 |
-
| APTOS / IDRiD / DRIVE / Messidor-2 | full | **full — unchanged** |
|
| 574 |
-
| EyePACS pretraining | 88,702 | 12–15k stratified, cached on Kaggle |
|
| 575 |
-
| Backbone | EfficientNetV2-S | EfficientNet-B0 |
|
| 576 |
-
| Resolution | 768 px | 512 px *(floor — below this microaneurysms vanish)* |
|
| 577 |
-
| Cross-validation | 5-fold + TTA | single split + hflip TTA |
|
| 578 |
-
|
| 579 |
-
**≈34× cheaper, zero task data discarded.** Every cut is a config override, reversible on
|
| 580 |
-
Kaggle without re-splitting or re-collecting anything.
|
| 581 |
-
|
| 582 |
-
Two things are never scaled down: the **test set** (a 50-case test set gives a ±8.5 pp
|
| 583 |
-
confidence interval, which makes a ">90 % sensitivity" claim unmakeable) and the **validation
|
| 584 |
-
protocol**. Grade 3 has only 193 images in all of APTOS — subsampling collapses it to 15–38
|
| 585 |
-
training examples long before the dataset merely looks small.
|
| 586 |
-
|
| 587 |
-
Full arithmetic and the claims that do and don't survive: [`docs/05_PROTOTYPE_SCOPE.md`](docs/05_PROTOTYPE_SCOPE.md).
|
| 588 |
-
|
| 589 |
-
## Ethical Boundary & Intended Use
|
| 590 |
-
|
| 591 |
-
**This is a research prototype. It is not a medical device and must not be used for clinical
|
| 592 |
-
diagnosis.**
|
| 593 |
-
|
| 594 |
-
- Software intended for diagnosis falls under **CDSCO** medical-device rules in India; the comparable
|
| 595 |
-
US system (IDx-DR) required an FDA De Novo authorisation. This project has neither and seeks neither.
|
| 596 |
-
- Outputs are **decision support with a human in the loop**, never a diagnosis. Every generated report
|
| 597 |
-
carries the disclaimer.
|
| 598 |
-
- **Training populations are documented and limited:** APTOS and IDRiD are Indian, EyePACS is US,
|
| 599 |
-
Messidor-2 is French. Performance on any other population is **unverified**.
|
| 600 |
-
- No patient-identifying data is stored in this repository; demo images are EXIF-stripped.
|
| 601 |
-
- Released weights are **research use only**, consistent with the licences of the data they derive from.
|
| 602 |
-
|
| 603 |
-
---
|
| 604 |
-
|
| 605 |
-
## Contributing
|
| 606 |
-
|
| 607 |
-
Contributions are welcome — particularly clinical review of the explanation format, additional external
|
| 608 |
-
validation datasets, and reproductions of the ablation.
|
| 609 |
-
|
| 610 |
-
1. Fork and branch (`git checkout -b feature/your-feature`)
|
| 611 |
-
2. `pre-commit install`
|
| 612 |
-
3. Add tests for anything you change
|
| 613 |
-
4. Ensure `pytest` and `ruff check .` pass
|
| 614 |
-
5. Open a pull request describing *what you measured*, not only what you changed
|
| 615 |
-
|
| 616 |
-
Findings that contradict results here are especially welcome. Please open an issue with the
|
| 617 |
-
configuration and seed.
|
| 618 |
-
|
| 619 |
-
---
|
| 620 |
-
|
| 621 |
-
## License
|
| 622 |
-
|
| 623 |
-
Code is released under the **MIT License** — see [LICENSE](LICENSE).
|
| 624 |
-
|
| 625 |
-
**Data and weights are not.** Datasets remain under their original licences (IDRiD CC BY 4.0;
|
| 626 |
-
APTOS/EyePACS competition rules; Messidor-2 ADCIS terms; DRIVE research use). Raw images are never
|
| 627 |
-
redistributed here. Trained weights derived from research-use data are released for **research use
|
| 628 |
-
only**.
|
| 629 |
-
|
| 630 |
-
---
|
| 631 |
-
|
| 632 |
-
## Contact
|
| 633 |
-
|
| 634 |
-
**Adarsh Dwivedi** · [@adarshcod30](https://github.com/adarshcod30)
|
| 635 |
-
Project: <https://github.com/adarshcod30/Diabetic-Retinopathy-Detection>
|
| 636 |
-
|
| 637 |
-
## Acknowledgments
|
| 638 |
-
|
| 639 |
-
The datasets that make this possible: [APTOS](https://www.kaggle.com/c/aptos2019-blindness-detection)
|
| 640 |
-
(Aravind Eye Hospital), [IDRiD](https://idrid.grand-challenge.org/) (Porwal et al.),
|
| 641 |
-
[Messidor-2](https://www.adcis.net/en/third-party/messidor2/) (ADCIS), and
|
| 642 |
-
[DRIVE](https://drive.grand-challenge.org/). Full evidence base:
|
| 643 |
-
[`docs/02_LITERATURE_REVIEW.md`](docs/02_LITERATURE_REVIEW.md).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect.egg-info/SOURCES.txt
DELETED
|
@@ -1,20 +0,0 @@
|
|
| 1 |
-
LICENSE
|
| 2 |
-
README.md
|
| 3 |
-
pyproject.toml
|
| 4 |
-
src/drdetect/__init__.py
|
| 5 |
-
src/drdetect.egg-info/PKG-INFO
|
| 6 |
-
src/drdetect.egg-info/SOURCES.txt
|
| 7 |
-
src/drdetect.egg-info/dependency_links.txt
|
| 8 |
-
src/drdetect.egg-info/requires.txt
|
| 9 |
-
src/drdetect.egg-info/top_level.txt
|
| 10 |
-
src/drdetect/calibration/__init__.py
|
| 11 |
-
src/drdetect/data/__init__.py
|
| 12 |
-
src/drdetect/enhance/__init__.py
|
| 13 |
-
src/drdetect/enhance/preprocessing.py
|
| 14 |
-
src/drdetect/eval/__init__.py
|
| 15 |
-
src/drdetect/explain/__init__.py
|
| 16 |
-
src/drdetect/grading/__init__.py
|
| 17 |
-
src/drdetect/quality/__init__.py
|
| 18 |
-
src/drdetect/segmentation/__init__.py
|
| 19 |
-
src/drdetect/serve/__init__.py
|
| 20 |
-
src/drdetect/utils/__init__.py
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect.egg-info/dependency_links.txt
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
|
|
|
|
|
|
src/src/drdetect.egg-info/requires.txt
DELETED
|
@@ -1,40 +0,0 @@
|
|
| 1 |
-
torch>=2.2
|
| 2 |
-
torchvision>=0.17
|
| 3 |
-
lightning>=2.2
|
| 4 |
-
timm>=1.0
|
| 5 |
-
monai>=1.3
|
| 6 |
-
segmentation-models-pytorch>=0.3
|
| 7 |
-
opencv-python-headless>=4.9
|
| 8 |
-
scikit-image>=0.22
|
| 9 |
-
albumentations>=1.4
|
| 10 |
-
Pillow>=10.2
|
| 11 |
-
numpy>=1.26
|
| 12 |
-
pandas>=2.2
|
| 13 |
-
scipy>=1.12
|
| 14 |
-
scikit-learn>=1.4
|
| 15 |
-
statsmodels>=0.14
|
| 16 |
-
torchmetrics>=1.3
|
| 17 |
-
hydra-core>=1.3
|
| 18 |
-
omegaconf>=2.3
|
| 19 |
-
wandb>=0.16
|
| 20 |
-
grad-cam>=1.5
|
| 21 |
-
simpy>=4.1
|
| 22 |
-
reportlab>=4.1
|
| 23 |
-
fastapi>=0.110
|
| 24 |
-
uvicorn>=0.29
|
| 25 |
-
onnx>=1.16
|
| 26 |
-
onnxruntime>=1.17
|
| 27 |
-
gradio>=4.25
|
| 28 |
-
matplotlib>=3.8
|
| 29 |
-
seaborn>=0.13
|
| 30 |
-
tqdm>=4.66
|
| 31 |
-
kaggle>=1.6
|
| 32 |
-
python-dotenv>=1.0
|
| 33 |
-
|
| 34 |
-
[dev]
|
| 35 |
-
pytest>=8.0
|
| 36 |
-
pytest-cov>=5.0
|
| 37 |
-
ruff>=0.4
|
| 38 |
-
pre-commit>=3.7
|
| 39 |
-
ipykernel>=6.29
|
| 40 |
-
jupyterlab>=4.1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect.egg-info/top_level.txt
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
drdetect
|
|
|
|
|
|
src/src/drdetect/__init__.py
DELETED
|
@@ -1,6 +0,0 @@
|
|
| 1 |
-
"""drdetect — explainable, quality-aware diabetic retinopathy screening.
|
| 2 |
-
|
| 3 |
-
Research prototype. NOT a medical device; not for clinical diagnosis.
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
__version__ = "0.1.0"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/calibration/__init__.py
DELETED
|
File without changes
|
src/src/drdetect/calibration/mc_dropout.py
DELETED
|
@@ -1,33 +0,0 @@
|
|
| 1 |
-
"""MC-dropout uncertainty estimation for the grading model.
|
| 2 |
-
|
| 3 |
-
EfficientNet's classifier dropout is *functional*, not a persistent
|
| 4 |
-
`nn.Dropout` submodule -- timm's own `EfficientNet.forward_head` calls
|
| 5 |
-
`F.dropout(x, p=self.drop_rate, training=self.training)` directly. There is
|
| 6 |
-
no Dropout module to selectively re-enable via `.apply()`, so reactivating
|
| 7 |
-
it at inference means putting the WHOLE model back into `.train()` mode
|
| 8 |
-
(which sets the `self.training` flag `F.dropout` checks), then immediately
|
| 9 |
-
re-freezing BatchNorm with this project's own `freeze_batchnorm` so its
|
| 10 |
-
running statistics stay fixed -- leaving the dropout call as the only thing
|
| 11 |
-
still affected by the reactivated training flag.
|
| 12 |
-
"""
|
| 13 |
-
|
| 14 |
-
from __future__ import annotations
|
| 15 |
-
|
| 16 |
-
import torch
|
| 17 |
-
import torch.nn as nn
|
| 18 |
-
|
| 19 |
-
from drdetect.grading.model import freeze_batchnorm
|
| 20 |
-
|
| 21 |
-
__all__ = ["mc_dropout_samples"]
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def mc_dropout_samples(model: nn.Module, x: torch.Tensor, n_samples: int = 20) -> torch.Tensor:
|
| 25 |
-
"""Returns logits of shape (n_samples, batch, num_outputs), each from an
|
| 26 |
-
independent stochastic forward pass with dropout active and BatchNorm
|
| 27 |
-
still frozen on its pretrained running statistics."""
|
| 28 |
-
model.train()
|
| 29 |
-
freeze_batchnorm(model)
|
| 30 |
-
with torch.no_grad():
|
| 31 |
-
samples = torch.stack([model(x) for _ in range(n_samples)])
|
| 32 |
-
model.eval()
|
| 33 |
-
return samples
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/calibration/temperature.py
DELETED
|
@@ -1,101 +0,0 @@
|
|
| 1 |
-
"""Temperature scaling (Guo et al. 2017) for post-hoc confidence calibration.
|
| 2 |
-
|
| 3 |
-
Modern networks are systematically overconfident: raw softmax output is not a
|
| 4 |
-
probability, even when the model's argmax is highly accurate. This project's
|
| 5 |
-
own Phase 2 report has said so explicitly on every single output --
|
| 6 |
-
"Model confidence (uncalibrated)" -- since there was nothing calibrated to
|
| 7 |
-
report. This module is what closes that disclaimer, for checkpoints it has
|
| 8 |
-
actually been run against.
|
| 9 |
-
|
| 10 |
-
Temperature scaling fixes overconfidence with one learned scalar T, applied
|
| 11 |
-
AFTER training with every other weight frozen:
|
| 12 |
-
|
| 13 |
-
calibrated_probs = softmax(logits / T)
|
| 14 |
-
|
| 15 |
-
T > 1 softens (reduces) confidence; T < 1 sharpens it. It cannot change which
|
| 16 |
-
class wins (dividing every logit by the same positive number does not
|
| 17 |
-
reorder them), only how confident the model is entitled to sound about it.
|
| 18 |
-
|
| 19 |
-
Only meaningful for a softmax head (`ce` / `distance_ce`). CORN's logits are
|
| 20 |
-
per-task sigmoids, not a single softmax, and regression has no probability
|
| 21 |
-
distribution to calibrate at all -- both are out of scope here, not silently
|
| 22 |
-
mishandled.
|
| 23 |
-
"""
|
| 24 |
-
|
| 25 |
-
from __future__ import annotations
|
| 26 |
-
|
| 27 |
-
import json
|
| 28 |
-
from pathlib import Path
|
| 29 |
-
|
| 30 |
-
import torch
|
| 31 |
-
import torch.nn as nn
|
| 32 |
-
import torch.nn.functional as F
|
| 33 |
-
|
| 34 |
-
__all__ = ["TemperatureScaler", "fit_temperature", "save_temperature", "load_temperature"]
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
class TemperatureScaler(nn.Module):
|
| 38 |
-
"""Wraps a frozen classifier's logits; only `log_temperature` is ever trained."""
|
| 39 |
-
|
| 40 |
-
def __init__(self):
|
| 41 |
-
super().__init__()
|
| 42 |
-
self.log_temperature = nn.Parameter(torch.zeros(1))
|
| 43 |
-
|
| 44 |
-
@property
|
| 45 |
-
def temperature(self) -> torch.Tensor:
|
| 46 |
-
# exp(), not a raw Parameter: keeps T > 0 for every value the
|
| 47 |
-
# optimiser can reach. logits / T is undefined at T=0 and inverted
|
| 48 |
-
# (sharpens instead of softens) for T<0 -- a plain Parameter could
|
| 49 |
-
# wander into either during optimisation; exp() cannot reach them.
|
| 50 |
-
return self.log_temperature.exp()
|
| 51 |
-
|
| 52 |
-
def forward(self, logits: torch.Tensor) -> torch.Tensor:
|
| 53 |
-
return logits / self.temperature
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
def fit_temperature(logits: torch.Tensor, targets: torch.Tensor, *, max_iter: int = 50) -> float:
|
| 57 |
-
"""Fit T on held-out (logits, targets) by minimising NLL. Returns the fitted T.
|
| 58 |
-
|
| 59 |
-
`logits` must come from a split the model was not trained OR early-stopped
|
| 60 |
-
on. This project has only a train/val split per fold (no separate
|
| 61 |
-
calibration split was reserved at Phase 0), so in practice this is fit on
|
| 62 |
-
the same fold-0 validation set every other Phase 1/3 number came from --
|
| 63 |
-
the identical "optimistic, not yet the Phase 8 number" caveat already
|
| 64 |
-
disclosed for the operating threshold applies here too, not a new one.
|
| 65 |
-
|
| 66 |
-
LBFGS, not Adam/SGD: the standard choice for this exact problem in the
|
| 67 |
-
original paper's own reference implementation -- one parameter, a
|
| 68 |
-
well-behaved objective, converges in a handful of iterations.
|
| 69 |
-
"""
|
| 70 |
-
scaler = TemperatureScaler()
|
| 71 |
-
optimizer = torch.optim.LBFGS([scaler.log_temperature], lr=0.01, max_iter=max_iter)
|
| 72 |
-
|
| 73 |
-
def closure():
|
| 74 |
-
optimizer.zero_grad()
|
| 75 |
-
loss = F.cross_entropy(scaler(logits), targets)
|
| 76 |
-
loss.backward()
|
| 77 |
-
return loss
|
| 78 |
-
|
| 79 |
-
optimizer.step(closure)
|
| 80 |
-
return float(scaler.temperature.item())
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
def save_temperature(checkpoint_path: str | Path, temperature: float, *, fitted_on: str) -> Path:
|
| 84 |
-
"""Write a sidecar JSON next to `checkpoint_path`, not inside the .ckpt.
|
| 85 |
-
|
| 86 |
-
Keeping it separate means load_temperature() can no-op cleanly for every
|
| 87 |
-
checkpoint that has never been calibrated, instead of every existing
|
| 88 |
-
checkpoint needing to carry a temperature=1.0 placeholder it was never
|
| 89 |
-
actually fit for.
|
| 90 |
-
"""
|
| 91 |
-
out = Path(checkpoint_path).with_name("temperature.json")
|
| 92 |
-
out.write_text(json.dumps({"temperature": temperature, "fitted_on": fitted_on}, indent=2))
|
| 93 |
-
return out
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
def load_temperature(checkpoint_path: str | Path) -> float:
|
| 97 |
-
"""1.0 (uncalibrated, a no-op divisor) if this checkpoint has no fitted temperature."""
|
| 98 |
-
sidecar = Path(checkpoint_path).with_name("temperature.json")
|
| 99 |
-
if not sidecar.exists():
|
| 100 |
-
return 1.0
|
| 101 |
-
return float(json.loads(sidecar.read_text())["temperature"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/data/__init__.py
DELETED
|
File without changes
|
src/src/drdetect/data/dataset.py
DELETED
|
@@ -1,134 +0,0 @@
|
|
| 1 |
-
"""Torch dataset over the preprocessed cache.
|
| 2 |
-
|
| 3 |
-
Reads the 512 px cache written by scripts/preprocess.py, not the raw images.
|
| 4 |
-
The expensive work (crop, Ben Graham, resize) happened once; the training loop
|
| 5 |
-
only decodes a small JPEG and augments.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
from __future__ import annotations
|
| 9 |
-
|
| 10 |
-
from pathlib import Path
|
| 11 |
-
|
| 12 |
-
import cv2
|
| 13 |
-
import numpy as np
|
| 14 |
-
from torch.utils.data import Dataset
|
| 15 |
-
|
| 16 |
-
from drdetect.data.manifest import ImageRecord, read_manifest
|
| 17 |
-
|
| 18 |
-
__all__ = ["FundusDataset", "build_transforms", "load_split"]
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
class FundusDataset(Dataset):
|
| 22 |
-
"""Fundus images with ICDR grades.
|
| 23 |
-
|
| 24 |
-
Args:
|
| 25 |
-
records: manifest rows for this split.
|
| 26 |
-
root: directory the manifest's `path` fields are relative to.
|
| 27 |
-
transform: Albumentations transform. Must include normalisation and
|
| 28 |
-
ToTensorV2 -- this class does no implicit conversion, so what the
|
| 29 |
-
model receives is always explicit in the config.
|
| 30 |
-
"""
|
| 31 |
-
|
| 32 |
-
def __init__(self, records: list[ImageRecord], root: str | Path, transform=None):
|
| 33 |
-
self.records = records
|
| 34 |
-
self.root = Path(root)
|
| 35 |
-
self.transform = transform
|
| 36 |
-
|
| 37 |
-
def __len__(self) -> int:
|
| 38 |
-
return len(self.records)
|
| 39 |
-
|
| 40 |
-
def __getitem__(self, idx: int):
|
| 41 |
-
rec = self.records[idx]
|
| 42 |
-
path = self.root / rec.path
|
| 43 |
-
image = cv2.imread(str(path), cv2.IMREAD_COLOR)
|
| 44 |
-
if image is None:
|
| 45 |
-
raise FileNotFoundError(
|
| 46 |
-
f"Could not read {path}. Re-run scripts/preprocess.py, or verify the "
|
| 47 |
-
f"cache against the manifest with drdetect.data.manifest.verify_manifest."
|
| 48 |
-
)
|
| 49 |
-
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
| 50 |
-
|
| 51 |
-
if self.transform is not None:
|
| 52 |
-
image = self.transform(image=image)["image"]
|
| 53 |
-
return image, rec.label
|
| 54 |
-
|
| 55 |
-
@property
|
| 56 |
-
def labels(self) -> np.ndarray:
|
| 57 |
-
return np.array([r.label for r in self.records])
|
| 58 |
-
|
| 59 |
-
@property
|
| 60 |
-
def groups(self) -> list[str]:
|
| 61 |
-
return [r.group_id for r in self.records]
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def build_transforms(size: int, train: bool):
|
| 65 |
-
"""Augmentations.
|
| 66 |
-
|
| 67 |
-
Train-time choices and why:
|
| 68 |
-
* flips and rotation -- a fundus has no canonical orientation, and left vs
|
| 69 |
-
right eyes are mirror images, so these are label-preserving;
|
| 70 |
-
* mild brightness/contrast -- models the illumination variation that
|
| 71 |
-
portable cameras genuinely produce in the field;
|
| 72 |
-
* NO heavy blur or noise -- a microaneurysm is 1-3 px at this resolution,
|
| 73 |
-
and those augmentations would erase the very feature that defines
|
| 74 |
-
grade 1.
|
| 75 |
-
"""
|
| 76 |
-
import albumentations as A
|
| 77 |
-
from albumentations.pytorch import ToTensorV2
|
| 78 |
-
|
| 79 |
-
# ImageNet statistics, because the backbone is ImageNet-pretrained.
|
| 80 |
-
normalise = A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225))
|
| 81 |
-
|
| 82 |
-
if not train:
|
| 83 |
-
return A.Compose([A.Resize(size, size), normalise, ToTensorV2()])
|
| 84 |
-
|
| 85 |
-
return A.Compose(
|
| 86 |
-
[
|
| 87 |
-
A.Resize(size, size),
|
| 88 |
-
A.HorizontalFlip(p=0.5),
|
| 89 |
-
A.VerticalFlip(p=0.5),
|
| 90 |
-
A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=180, p=0.7),
|
| 91 |
-
A.RandomBrightnessContrast(brightness_limit=0.15, contrast_limit=0.15, p=0.5),
|
| 92 |
-
normalise,
|
| 93 |
-
ToTensorV2(),
|
| 94 |
-
]
|
| 95 |
-
)
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
def load_split(
|
| 99 |
-
manifest_path: str | Path,
|
| 100 |
-
*,
|
| 101 |
-
fold: int = 0,
|
| 102 |
-
n_splits: int = 5,
|
| 103 |
-
seed: int = 42,
|
| 104 |
-
allow_ungrouped: bool = False,
|
| 105 |
-
) -> tuple[list[ImageRecord], list[ImageRecord], str]:
|
| 106 |
-
"""Split a manifest into (train, val, strategy) for one fold.
|
| 107 |
-
|
| 108 |
-
Grouping comes from the manifest's `group_id`, which scripts/preprocess.py
|
| 109 |
-
populates by perceptual hashing. Leakage is asserted, not assumed.
|
| 110 |
-
"""
|
| 111 |
-
from drdetect.data.splits import (
|
| 112 |
-
assert_no_group_leakage,
|
| 113 |
-
stratified_group_split,
|
| 114 |
-
validate_grouping,
|
| 115 |
-
)
|
| 116 |
-
|
| 117 |
-
records = read_manifest(manifest_path)
|
| 118 |
-
labels = [r.label for r in records]
|
| 119 |
-
groups = [r.group_id for r in records]
|
| 120 |
-
|
| 121 |
-
# Reject a collapsed grouping BEFORE splitting. A single giant group passes
|
| 122 |
-
# the leakage assertion trivially and produces a wildly lopsided split.
|
| 123 |
-
if any(groups):
|
| 124 |
-
validate_grouping(groups)
|
| 125 |
-
|
| 126 |
-
folds, strategy = stratified_group_split(
|
| 127 |
-
labels, groups, n_splits=n_splits, seed=seed, allow_ungrouped=allow_ungrouped
|
| 128 |
-
)
|
| 129 |
-
assert_no_group_leakage(folds, groups)
|
| 130 |
-
|
| 131 |
-
val_idx = set(folds[fold].tolist())
|
| 132 |
-
train_records = [r for i, r in enumerate(records) if i not in val_idx]
|
| 133 |
-
val_records = [r for i, r in enumerate(records) if i in val_idx]
|
| 134 |
-
return train_records, val_records, strategy
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/data/manifest.py
DELETED
|
@@ -1,107 +0,0 @@
|
|
| 1 |
-
"""Dataset manifests: the committed record of exactly which bytes were used.
|
| 2 |
-
|
| 3 |
-
Raw images are never committed (licence + size). A manifest is committed instead:
|
| 4 |
-
one row per image with its sha256, so anyone can verify they hold byte-identical
|
| 5 |
-
inputs before comparing results. This is DVC's job, done with a CSV.
|
| 6 |
-
|
| 7 |
-
Schema
|
| 8 |
-
------
|
| 9 |
-
image_id stable identifier (dataset-unique)
|
| 10 |
-
path path relative to the dataset root
|
| 11 |
-
sha256 content hash of the raw file
|
| 12 |
-
dataset aptos | idrid | messidor2 | drive | eyepacs
|
| 13 |
-
label ICDR grade 0-4, or -1 when unlabelled
|
| 14 |
-
group_id patient/eye grouping key for leak-free splits, or "" if unknown
|
| 15 |
-
phash structural perceptual hash (decimal string), or "" -- cached so
|
| 16 |
-
regrouping does not require re-decoding every source image
|
| 17 |
-
width pixels
|
| 18 |
-
height pixels
|
| 19 |
-
"""
|
| 20 |
-
|
| 21 |
-
from __future__ import annotations
|
| 22 |
-
|
| 23 |
-
import csv
|
| 24 |
-
import hashlib
|
| 25 |
-
from dataclasses import asdict, dataclass, fields
|
| 26 |
-
from pathlib import Path
|
| 27 |
-
|
| 28 |
-
__all__ = ["ImageRecord", "sha256_file", "write_manifest", "read_manifest", "verify_manifest"]
|
| 29 |
-
|
| 30 |
-
CHUNK = 1 << 20 # 1 MiB
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
@dataclass(frozen=True)
|
| 34 |
-
class ImageRecord:
|
| 35 |
-
image_id: str
|
| 36 |
-
path: str
|
| 37 |
-
sha256: str
|
| 38 |
-
dataset: str
|
| 39 |
-
label: int = -1
|
| 40 |
-
group_id: str = ""
|
| 41 |
-
phash: str = ""
|
| 42 |
-
width: int = 0
|
| 43 |
-
height: int = 0
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def sha256_file(path: str | Path) -> str:
|
| 47 |
-
"""Content hash, streamed so a 90 GB dataset does not need 90 GB of RAM."""
|
| 48 |
-
h = hashlib.sha256()
|
| 49 |
-
with open(path, "rb") as fh:
|
| 50 |
-
while chunk := fh.read(CHUNK):
|
| 51 |
-
h.update(chunk)
|
| 52 |
-
return h.hexdigest()
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def write_manifest(records: list[ImageRecord], out_path: str | Path) -> Path:
|
| 56 |
-
out_path = Path(out_path)
|
| 57 |
-
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 58 |
-
cols = [f.name for f in fields(ImageRecord)]
|
| 59 |
-
with open(out_path, "w", newline="") as fh:
|
| 60 |
-
writer = csv.DictWriter(fh, fieldnames=cols)
|
| 61 |
-
writer.writeheader()
|
| 62 |
-
for rec in sorted(records, key=lambda r: r.image_id): # stable ordering
|
| 63 |
-
writer.writerow(asdict(rec))
|
| 64 |
-
return out_path
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
def read_manifest(path: str | Path) -> list[ImageRecord]:
|
| 68 |
-
out: list[ImageRecord] = []
|
| 69 |
-
with open(path, newline="") as fh:
|
| 70 |
-
for row in csv.DictReader(fh):
|
| 71 |
-
out.append(
|
| 72 |
-
ImageRecord(
|
| 73 |
-
image_id=row["image_id"],
|
| 74 |
-
path=row["path"],
|
| 75 |
-
sha256=row["sha256"],
|
| 76 |
-
dataset=row["dataset"],
|
| 77 |
-
label=int(row["label"]),
|
| 78 |
-
group_id=row["group_id"],
|
| 79 |
-
phash=row.get("phash", ""),
|
| 80 |
-
width=int(row["width"]),
|
| 81 |
-
height=int(row["height"]),
|
| 82 |
-
)
|
| 83 |
-
)
|
| 84 |
-
return out
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
def verify_manifest(
|
| 88 |
-
manifest_path: str | Path, root: str | Path, *, sample: int | None = None
|
| 89 |
-
) -> tuple[list[str], list[str]]:
|
| 90 |
-
"""Re-hash files on disk and compare against the manifest.
|
| 91 |
-
|
| 92 |
-
Returns (missing, mismatched) image_ids. `sample` limits the check to the
|
| 93 |
-
first N records -- full verification of a large dataset is I/O bound.
|
| 94 |
-
"""
|
| 95 |
-
root = Path(root)
|
| 96 |
-
records = read_manifest(manifest_path)
|
| 97 |
-
if sample is not None:
|
| 98 |
-
records = records[:sample]
|
| 99 |
-
|
| 100 |
-
missing, mismatched = [], []
|
| 101 |
-
for rec in records:
|
| 102 |
-
fpath = root / rec.path
|
| 103 |
-
if not fpath.exists():
|
| 104 |
-
missing.append(rec.image_id)
|
| 105 |
-
elif sha256_file(fpath) != rec.sha256:
|
| 106 |
-
mismatched.append(rec.image_id)
|
| 107 |
-
return missing, mismatched
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/data/splits.py
DELETED
|
@@ -1,349 +0,0 @@
|
|
| 1 |
-
"""Leak-free cross-validation splits.
|
| 2 |
-
|
| 3 |
-
The failure this module exists to prevent
|
| 4 |
-
-----------------------------------------
|
| 5 |
-
Fundus datasets contain both eyes of the same patient, and the two eyes of one
|
| 6 |
-
diabetic are strongly correlated in DR severity. If one eye lands in train and
|
| 7 |
-
the other in test, the model has effectively seen the test set. Every reported
|
| 8 |
-
metric inflates, the loss curves look perfect, and nothing warns you.
|
| 9 |
-
|
| 10 |
-
The complication specific to APTOS
|
| 11 |
-
----------------------------------
|
| 12 |
-
APTOS-2019's `train.csv` carries only `id_code` and `diagnosis`. There is **no
|
| 13 |
-
patient or eye identifier**, so a true patient-level split is impossible on that
|
| 14 |
-
dataset as distributed. Silently falling back to an image-level split would be
|
| 15 |
-
exactly the failure described above, so this module:
|
| 16 |
-
|
| 17 |
-
1. refuses to guess -- `stratified_group_split` requires an explicit decision;
|
| 18 |
-
2. offers `perceptual_hash_groups` as a documented mitigation: images that are
|
| 19 |
-
near-identical (plausibly the same eye or patient) are grouped so they
|
| 20 |
-
cannot straddle folds;
|
| 21 |
-
3. records which strategy was used, so the caveat travels with the results.
|
| 22 |
-
|
| 23 |
-
Messidor-2 *does* encode exam pairing in its filenames, so real grouping is
|
| 24 |
-
available there and should be used.
|
| 25 |
-
"""
|
| 26 |
-
|
| 27 |
-
from __future__ import annotations
|
| 28 |
-
|
| 29 |
-
import warnings
|
| 30 |
-
from collections import defaultdict
|
| 31 |
-
|
| 32 |
-
import numpy as np
|
| 33 |
-
|
| 34 |
-
__all__ = [
|
| 35 |
-
"SplitStrategy",
|
| 36 |
-
"dhash",
|
| 37 |
-
"structural_hash",
|
| 38 |
-
"perceptual_hash_groups",
|
| 39 |
-
"validate_grouping",
|
| 40 |
-
"stratified_group_split",
|
| 41 |
-
"assert_no_group_leakage",
|
| 42 |
-
]
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
class SplitStrategy:
|
| 46 |
-
"""How grouping was determined. Travels with results as a provenance record."""
|
| 47 |
-
|
| 48 |
-
TRUE_GROUPS = "true_groups" # real patient/eye ids -- trustworthy
|
| 49 |
-
PERCEPTUAL = "perceptual_hash" # near-duplicate grouping -- mitigation only
|
| 50 |
-
IMAGE_LEVEL = "image_level" # no grouping -- leakage possible, must be declared
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
def dhash(image: np.ndarray, hash_size: int = 8) -> int:
|
| 54 |
-
"""Difference hash: a 64-bit perceptual fingerprint.
|
| 55 |
-
|
| 56 |
-
Compares each pixel with its right-hand neighbour on a downscaled grayscale
|
| 57 |
-
image, so it is robust to resolution, JPEG artefacts and mild brightness
|
| 58 |
-
shifts, but sensitive to actual content. Two photographs of the same eye
|
| 59 |
-
typically differ by only a few bits.
|
| 60 |
-
|
| 61 |
-
Implemented here rather than pulled from `imagehash` to avoid a dependency
|
| 62 |
-
for ~10 lines, and so the exact behaviour is pinned by our own tests.
|
| 63 |
-
"""
|
| 64 |
-
import cv2
|
| 65 |
-
|
| 66 |
-
if image.ndim == 3:
|
| 67 |
-
image = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
|
| 68 |
-
resized = cv2.resize(image, (hash_size + 1, hash_size), interpolation=cv2.INTER_AREA)
|
| 69 |
-
diff = resized[:, 1:] > resized[:, :-1]
|
| 70 |
-
bits = 0
|
| 71 |
-
for bit in diff.flatten():
|
| 72 |
-
bits = (bits << 1) | int(bit)
|
| 73 |
-
return bits
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
def structural_hash(image: np.ndarray, size: int = 256, hash_size: int = 16) -> int:
|
| 77 |
-
"""Perceptual hash of an image's *coarse structure*, for grouping.
|
| 78 |
-
|
| 79 |
-
Deliberately computed on a plain circle-crop + resize, NOT on the
|
| 80 |
-
Ben Graham-preprocessed image used for training.
|
| 81 |
-
|
| 82 |
-
Ben Graham preprocessing subtracts the local average colour, which removes
|
| 83 |
-
exactly the low-frequency content dHash depends on. Measured on a fundus and
|
| 84 |
-
a degraded copy of itself: hashing the Ben Graham output put them 8 bits
|
| 85 |
-
apart (above the grouping threshold, so the duplicate was missed), while
|
| 86 |
-
hashing the structural image put them 0 bits apart -- with unrelated images
|
| 87 |
-
still 14 bits away.
|
| 88 |
-
|
| 89 |
-
The identity of an eye lives in its vessel and optic-disc layout. That is
|
| 90 |
-
what this hashes.
|
| 91 |
-
|
| 92 |
-
Resolution matters more than it looks. At 8x8 (64 bits) on 128 px, measured
|
| 93 |
-
across APTOS the median inter-image distance was 18 bits with ~670 pairs
|
| 94 |
-
falling within 5 bits -- most of them false matches, because 64 bits cannot
|
| 95 |
-
separate two fundus photographs that are both "a bright disc on black".
|
| 96 |
-
At 16x16 (256 bits) on 256 px the distribution is cleanly bimodal: 148 pairs
|
| 97 |
-
at 0-4 bits and then NOTHING until 39 bits. Those false matches are what
|
| 98 |
-
percolated the grouping into a single giant component.
|
| 99 |
-
"""
|
| 100 |
-
import cv2
|
| 101 |
-
|
| 102 |
-
from drdetect.enhance.preprocessing import circle_crop
|
| 103 |
-
|
| 104 |
-
cropped = circle_crop(image)
|
| 105 |
-
resized = cv2.resize(cropped, (size, size), interpolation=cv2.INTER_AREA)
|
| 106 |
-
return dhash(resized, hash_size=hash_size)
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
def _hamming(a: int, b: int) -> int:
|
| 110 |
-
return bin(a ^ b).count("1")
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
def perceptual_hash_groups(
|
| 114 |
-
image_ids: list[str],
|
| 115 |
-
hashes: list[int],
|
| 116 |
-
max_distance: int = 6,
|
| 117 |
-
*,
|
| 118 |
-
max_group_size: int | None = None,
|
| 119 |
-
) -> dict[str, str]:
|
| 120 |
-
"""Group near-duplicate images via union-find over Hamming distance.
|
| 121 |
-
|
| 122 |
-
Returns {image_id: group_id}. Images within `max_distance` bits are treated
|
| 123 |
-
as the same group (transitively).
|
| 124 |
-
|
| 125 |
-
Percolation, and why `max_group_size` exists
|
| 126 |
-
--------------------------------------------
|
| 127 |
-
Union-find takes the TRANSITIVE closure: a~b and b~c puts a, b and c in one
|
| 128 |
-
group even if a and c are far apart. Over a large set, a small number of
|
| 129 |
-
spurious edges is enough to chain almost everything together -- a random
|
| 130 |
-
graph needs only ~N/2 edges to form a giant connected component.
|
| 131 |
-
|
| 132 |
-
This is not hypothetical. On APTOS (3,662 images) a 64-bit hash produced
|
| 133 |
-
~670 near-matches, and the transitive closure merged 3,138 images into a
|
| 134 |
-
single "group" which then became an entire validation fold. The split was
|
| 135 |
-
train=524 / val=3138 and silently reported as `true_groups`.
|
| 136 |
-
|
| 137 |
-
Two defences: a hash resolution that does not manufacture false edges (see
|
| 138 |
-
`structural_hash`), and this hard cap on group size. `max_group_size`
|
| 139 |
-
defaults to max(25, 1% of the dataset) -- a "patient" larger than that is a
|
| 140 |
-
hash failure, not a patient, so the merge is refused.
|
| 141 |
-
|
| 142 |
-
Choosing `max_distance`
|
| 143 |
-
-----------------------
|
| 144 |
-
Measured on APTOS with `structural_hash` over all 6.7M pairs, the cumulative
|
| 145 |
-
count is flat across thresholds 2-6 (148 pairs at every value) and stays
|
| 146 |
-
near-flat to 8 (150). It then climbs -- 207 at 10, 391 at 12 -- and chaining
|
| 147 |
-
appears: threshold 10 produced a 31-image group whose members were up to 33
|
| 148 |
-
bits apart. The default of 6 sits mid-plateau, so the result is insensitive
|
| 149 |
-
to the exact value.
|
| 150 |
-
|
| 151 |
-
Note the threshold must be measured for the hash ACTUALLY used. An earlier
|
| 152 |
-
value of 10 was taken from a distribution measured on Ben Graham-preprocessed
|
| 153 |
-
images and then applied to `structural_hash`, which runs on raw
|
| 154 |
-
circle-cropped images. Different transform, different distances.
|
| 155 |
-
|
| 156 |
-
This remains a *mitigation*, not a substitute for patient IDs: it catches
|
| 157 |
-
repeated or near-identical captures, but two genuinely different photographs
|
| 158 |
-
of one patient's two eyes will not be grouped. Declare the limitation when
|
| 159 |
-
reporting.
|
| 160 |
-
"""
|
| 161 |
-
if len(image_ids) != len(hashes):
|
| 162 |
-
raise ValueError("image_ids and hashes must be the same length")
|
| 163 |
-
|
| 164 |
-
n = len(image_ids)
|
| 165 |
-
if max_group_size is None:
|
| 166 |
-
max_group_size = max(25, n // 100)
|
| 167 |
-
|
| 168 |
-
parent = list(range(n))
|
| 169 |
-
size = [1] * n
|
| 170 |
-
|
| 171 |
-
def find(i: int) -> int:
|
| 172 |
-
while parent[i] != i:
|
| 173 |
-
parent[i] = parent[parent[i]]
|
| 174 |
-
i = parent[i]
|
| 175 |
-
return i
|
| 176 |
-
|
| 177 |
-
refused = 0
|
| 178 |
-
|
| 179 |
-
def union(i: int, j: int) -> None:
|
| 180 |
-
nonlocal refused
|
| 181 |
-
ri, rj = find(i), find(j)
|
| 182 |
-
if ri == rj:
|
| 183 |
-
return
|
| 184 |
-
if size[ri] + size[rj] > max_group_size:
|
| 185 |
-
# Refusing the merge is the safe failure: two smaller groups may
|
| 186 |
-
# leak, but one giant group destroys the split entirely.
|
| 187 |
-
refused += 1
|
| 188 |
-
return
|
| 189 |
-
lo, hi = (ri, rj) if ri < rj else (rj, ri)
|
| 190 |
-
parent[hi] = lo
|
| 191 |
-
size[lo] += size[hi]
|
| 192 |
-
|
| 193 |
-
# O(n^2). Fine for APTOS (3,662 -> ~6.7M comparisons, seconds). For EyePACS
|
| 194 |
-
# scale this would need LSH bucketing on hash prefixes.
|
| 195 |
-
for i in range(n):
|
| 196 |
-
hi_hash = hashes[i]
|
| 197 |
-
for j in range(i + 1, n):
|
| 198 |
-
if (hi_hash ^ hashes[j]).bit_count() <= max_distance:
|
| 199 |
-
union(i, j)
|
| 200 |
-
|
| 201 |
-
if refused:
|
| 202 |
-
warnings.warn(
|
| 203 |
-
f"{refused} merge(s) refused because they would exceed max_group_size="
|
| 204 |
-
f"{max_group_size}. This usually means the hash is producing false "
|
| 205 |
-
f"matches; check structural_hash resolution and max_distance.",
|
| 206 |
-
UserWarning,
|
| 207 |
-
stacklevel=2,
|
| 208 |
-
)
|
| 209 |
-
|
| 210 |
-
roots = defaultdict(list)
|
| 211 |
-
for idx, image_id in enumerate(image_ids):
|
| 212 |
-
roots[find(idx)].append(image_id)
|
| 213 |
-
|
| 214 |
-
# Name each group after its lexicographically smallest member, NOT the
|
| 215 |
-
# union-find root index. The root index depends on the order the inputs
|
| 216 |
-
# arrived in, so identical data grouped twice produced identical partitions
|
| 217 |
-
# under different names -- and StratifiedGroupKFold assigns folds by group
|
| 218 |
-
# VALUE, so the two runs got different splits. Measured: the 512 px and
|
| 219 |
-
# 768 px manifests had the same 3,523 groups but shared only 139 of 733
|
| 220 |
-
# validation images, making the two runs non-comparable.
|
| 221 |
-
#
|
| 222 |
-
# Naming by member makes the mapping a pure function of the partition.
|
| 223 |
-
mapping: dict[str, str] = {}
|
| 224 |
-
for members in roots.values():
|
| 225 |
-
gid = f"g_{min(members)}"
|
| 226 |
-
for m in members:
|
| 227 |
-
mapping[m] = gid
|
| 228 |
-
return mapping
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
def validate_grouping(
|
| 232 |
-
groups: list[str],
|
| 233 |
-
*,
|
| 234 |
-
max_group_fraction: float = 0.05,
|
| 235 |
-
min_group_fraction: float = 0.5,
|
| 236 |
-
) -> dict:
|
| 237 |
-
"""Reject degenerate groupings before they silently destroy a split.
|
| 238 |
-
|
| 239 |
-
Raises if any single group holds more than `max_group_fraction` of the data,
|
| 240 |
-
or if the number of distinct groups falls below `min_group_fraction` of the
|
| 241 |
-
number of images.
|
| 242 |
-
|
| 243 |
-
This exists because the failure it catches is invisible downstream: a
|
| 244 |
-
grouping that collapsed 86% of APTOS into one group still reported strategy
|
| 245 |
-
`true_groups`, still passed the no-leakage assertion (trivially -- one group
|
| 246 |
-
cannot straddle folds), and produced a train=524 / val=3138 split that would
|
| 247 |
-
have trained for hours and produced a meaningless number.
|
| 248 |
-
"""
|
| 249 |
-
from collections import Counter
|
| 250 |
-
|
| 251 |
-
n = len(groups)
|
| 252 |
-
counts = Counter(groups)
|
| 253 |
-
largest_id, largest = counts.most_common(1)[0]
|
| 254 |
-
stats = {
|
| 255 |
-
"n_images": n,
|
| 256 |
-
"n_groups": len(counts),
|
| 257 |
-
"largest_group": largest,
|
| 258 |
-
"largest_group_id": largest_id,
|
| 259 |
-
"merged": n - len(counts),
|
| 260 |
-
}
|
| 261 |
-
|
| 262 |
-
if largest > max_group_fraction * n:
|
| 263 |
-
raise ValueError(
|
| 264 |
-
f"Degenerate grouping: group {largest_id!r} holds {largest}/{n} images "
|
| 265 |
-
f"({100 * largest / n:.1f}%), above the {100 * max_group_fraction:.0f}% limit.\n"
|
| 266 |
-
f"A 'patient' that large is a hash collapse, not a patient. Re-run "
|
| 267 |
-
f"grouping with a higher-resolution hash or a tighter max_distance."
|
| 268 |
-
)
|
| 269 |
-
if len(counts) < min_group_fraction * n:
|
| 270 |
-
raise ValueError(
|
| 271 |
-
f"Degenerate grouping: {len(counts)} groups for {n} images "
|
| 272 |
-
f"(< {100 * min_group_fraction:.0f}%). The hash is over-merging."
|
| 273 |
-
)
|
| 274 |
-
return stats
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
def stratified_group_split(
|
| 278 |
-
labels: list[int],
|
| 279 |
-
groups: list[str] | None,
|
| 280 |
-
*,
|
| 281 |
-
n_splits: int = 5,
|
| 282 |
-
seed: int = 42,
|
| 283 |
-
allow_ungrouped: bool = False,
|
| 284 |
-
) -> tuple[list[np.ndarray], str]:
|
| 285 |
-
"""Stratified k-fold that keeps each group whole.
|
| 286 |
-
|
| 287 |
-
Args:
|
| 288 |
-
labels: class label per sample, used for stratification.
|
| 289 |
-
groups: grouping key per sample. `None` or all-empty means no grouping
|
| 290 |
-
information exists.
|
| 291 |
-
allow_ungrouped: must be set explicitly to proceed without groups. This
|
| 292 |
-
is a guard rail: falling back silently is the bug this module exists
|
| 293 |
-
to prevent.
|
| 294 |
-
|
| 295 |
-
Returns:
|
| 296 |
-
(fold_indices, strategy) where fold_indices[k] holds the *test* indices
|
| 297 |
-
of fold k, and strategy is a `SplitStrategy` constant to record.
|
| 298 |
-
|
| 299 |
-
Raises:
|
| 300 |
-
ValueError: if grouping is absent and `allow_ungrouped` is False.
|
| 301 |
-
"""
|
| 302 |
-
from sklearn.model_selection import StratifiedGroupKFold, StratifiedKFold
|
| 303 |
-
|
| 304 |
-
labels_arr = np.asarray(labels)
|
| 305 |
-
has_groups = groups is not None and any(g for g in groups)
|
| 306 |
-
|
| 307 |
-
if not has_groups:
|
| 308 |
-
if not allow_ungrouped:
|
| 309 |
-
raise ValueError(
|
| 310 |
-
"No grouping information supplied. A plain image-level split can leak "
|
| 311 |
-
"the same patient across folds and silently inflate every metric.\n"
|
| 312 |
-
"Either provide `groups` (from patient ids, or from "
|
| 313 |
-
"perceptual_hash_groups()), or pass allow_ungrouped=True to accept "
|
| 314 |
-
"the risk explicitly and declare it when reporting results."
|
| 315 |
-
)
|
| 316 |
-
warnings.warn(
|
| 317 |
-
"Splitting at IMAGE level with no grouping. If this dataset contains "
|
| 318 |
-
"multiple images per patient, results will be optimistically biased. "
|
| 319 |
-
"Record SplitStrategy.IMAGE_LEVEL alongside any metric derived from it.",
|
| 320 |
-
UserWarning,
|
| 321 |
-
stacklevel=2,
|
| 322 |
-
)
|
| 323 |
-
splitter = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=seed)
|
| 324 |
-
folds = [test for _, test in splitter.split(np.zeros(len(labels_arr)), labels_arr)]
|
| 325 |
-
return folds, SplitStrategy.IMAGE_LEVEL
|
| 326 |
-
|
| 327 |
-
groups_arr = np.asarray(groups)
|
| 328 |
-
splitter = StratifiedGroupKFold(n_splits=n_splits, shuffle=True, random_state=seed)
|
| 329 |
-
folds = [test for _, test in splitter.split(np.zeros(len(labels_arr)), labels_arr, groups_arr)]
|
| 330 |
-
return folds, SplitStrategy.TRUE_GROUPS
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
def assert_no_group_leakage(folds: list[np.ndarray], groups: list[str]) -> None:
|
| 334 |
-
"""Raise if any group appears in more than one fold.
|
| 335 |
-
|
| 336 |
-
Call this after splitting, every time. It is cheap, and it converts the most
|
| 337 |
-
expensive silent bug in this literature into a loud, immediate failure.
|
| 338 |
-
"""
|
| 339 |
-
seen: dict[str, int] = {}
|
| 340 |
-
for fold_idx, indices in enumerate(folds):
|
| 341 |
-
for i in indices:
|
| 342 |
-
gid = groups[i]
|
| 343 |
-
if not gid:
|
| 344 |
-
continue
|
| 345 |
-
prev = seen.setdefault(gid, fold_idx)
|
| 346 |
-
if prev != fold_idx:
|
| 347 |
-
raise AssertionError(
|
| 348 |
-
f"Group leakage: group {gid!r} appears in folds {prev} and {fold_idx}."
|
| 349 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/enhance/__init__.py
DELETED
|
File without changes
|
src/src/drdetect/enhance/preprocessing.py
DELETED
|
@@ -1,131 +0,0 @@
|
|
| 1 |
-
"""Fundus preprocessing primitives.
|
| 2 |
-
|
| 3 |
-
The pipeline order matters and is not arbitrary:
|
| 4 |
-
|
| 5 |
-
crop_from_gray -> circle_crop -> ben_graham -> (CLAHE) -> resize
|
| 6 |
-
|
| 7 |
-
`crop_from_gray` first, because the black surround otherwise drags down every
|
| 8 |
-
subsequent statistic (the blur in `ben_graham`, the CLAHE histogram, the
|
| 9 |
-
illumination estimate). `ben_graham` before `resize`, because it is defined
|
| 10 |
-
relative to image radius and is cheapest to reason about at native scale.
|
| 11 |
-
|
| 12 |
-
Reference: Ben Graham's winning solution, Kaggle Diabetic Retinopathy 2015.
|
| 13 |
-
"""
|
| 14 |
-
|
| 15 |
-
from __future__ import annotations
|
| 16 |
-
|
| 17 |
-
import cv2
|
| 18 |
-
import numpy as np
|
| 19 |
-
|
| 20 |
-
__all__ = [
|
| 21 |
-
"crop_from_gray",
|
| 22 |
-
"circle_crop",
|
| 23 |
-
"ben_graham",
|
| 24 |
-
"apply_clahe",
|
| 25 |
-
"preprocess",
|
| 26 |
-
]
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def crop_from_gray(image: np.ndarray, tol: int = 7) -> np.ndarray:
|
| 30 |
-
"""Trim the uninformative dark border around a fundus photograph.
|
| 31 |
-
|
| 32 |
-
Fundus cameras produce a bright circular retina on a black rectangle. The
|
| 33 |
-
black region carries no signal but skews global statistics, so it goes first.
|
| 34 |
-
|
| 35 |
-
Args:
|
| 36 |
-
image: HxWx3 uint8 RGB, or HxW uint8 grayscale.
|
| 37 |
-
tol: Intensity at or below which a pixel counts as background.
|
| 38 |
-
|
| 39 |
-
Returns:
|
| 40 |
-
The tightest crop containing above-threshold content. Returns the input
|
| 41 |
-
unchanged if the mask is empty (a fully dark frame), rather than raising
|
| 42 |
-
-- an all-dark image is a *quality* failure and is Stage 1's business,
|
| 43 |
-
not this function's.
|
| 44 |
-
"""
|
| 45 |
-
if image.ndim == 2:
|
| 46 |
-
mask = image > tol
|
| 47 |
-
if not mask.any():
|
| 48 |
-
return image
|
| 49 |
-
return image[np.ix_(mask.any(1), mask.any(0))]
|
| 50 |
-
|
| 51 |
-
gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
|
| 52 |
-
mask = gray > tol
|
| 53 |
-
if not mask.any():
|
| 54 |
-
return image
|
| 55 |
-
|
| 56 |
-
rows, cols = mask.any(1), mask.any(0)
|
| 57 |
-
return image[np.ix_(rows, cols)]
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def circle_crop(image: np.ndarray) -> np.ndarray:
|
| 61 |
-
"""Mask everything outside the inscribed circular field of view.
|
| 62 |
-
|
| 63 |
-
Standardises the FOV across cameras so the model cannot learn to exploit
|
| 64 |
-
corner artefacts that happen to correlate with a particular device.
|
| 65 |
-
"""
|
| 66 |
-
image = crop_from_gray(image)
|
| 67 |
-
h, w = image.shape[:2]
|
| 68 |
-
cx, cy = w // 2, h // 2
|
| 69 |
-
r = min(cx, cy)
|
| 70 |
-
|
| 71 |
-
mask = np.zeros((h, w), dtype=np.uint8)
|
| 72 |
-
cv2.circle(mask, (cx, cy), r, color=1, thickness=-1)
|
| 73 |
-
|
| 74 |
-
out = image * mask[..., None] if image.ndim == 3 else image * mask
|
| 75 |
-
return crop_from_gray(out)
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
def ben_graham(image: np.ndarray, sigma_scale: float = 30.0, weight: float = 4.0) -> np.ndarray:
|
| 79 |
-
"""Subtract local average colour (Ben Graham, Kaggle DR 2015).
|
| 80 |
-
|
| 81 |
-
out = weight*img - weight*GaussianBlur(img, sigma) + 128
|
| 82 |
-
|
| 83 |
-
The blur sigma is tied to *image radius*, not to a fixed pixel count, which
|
| 84 |
-
makes the normalisation scale-invariant -- important because fundus cameras
|
| 85 |
-
output wildly different resolutions.
|
| 86 |
-
|
| 87 |
-
This removes the between-camera and between-patient variation in overall
|
| 88 |
-
illumination and pigmentation while preserving the high-frequency detail
|
| 89 |
-
that lesions live in.
|
| 90 |
-
"""
|
| 91 |
-
radius = min(image.shape[:2]) / 2.0
|
| 92 |
-
sigma = max(radius / sigma_scale, 1.0)
|
| 93 |
-
blurred = cv2.GaussianBlur(image, (0, 0), sigmaX=sigma)
|
| 94 |
-
out = cv2.addWeighted(image, weight, blurred, -weight, 128)
|
| 95 |
-
return np.clip(out, 0, 255).astype(np.uint8)
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
def apply_clahe(image: np.ndarray, clip_limit: float = 2.0, tile_grid: int = 8) -> np.ndarray:
|
| 99 |
-
"""Contrast-limited adaptive histogram equalisation on the LAB L channel.
|
| 100 |
-
|
| 101 |
-
Operating on L rather than per-RGB-channel avoids the colour casts that
|
| 102 |
-
channel-wise CLAHE introduces.
|
| 103 |
-
|
| 104 |
-
Note: `clip_limit` is a genuine risk knob. Too high and sensor noise is
|
| 105 |
-
amplified into structures that look like microaneurysms. Ablate it; do not
|
| 106 |
-
assume 2.0 is right for your camera mix.
|
| 107 |
-
"""
|
| 108 |
-
lab = cv2.cvtColor(image, cv2.COLOR_RGB2LAB)
|
| 109 |
-
clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(tile_grid, tile_grid))
|
| 110 |
-
lab[..., 0] = clahe.apply(lab[..., 0])
|
| 111 |
-
return cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
def preprocess(
|
| 115 |
-
image: np.ndarray,
|
| 116 |
-
size: int = 512,
|
| 117 |
-
use_ben_graham: bool = True,
|
| 118 |
-
use_clahe: bool = False,
|
| 119 |
-
) -> np.ndarray:
|
| 120 |
-
"""Full preprocessing chain, resized to `size` x `size`.
|
| 121 |
-
|
| 122 |
-
`use_clahe` defaults to False deliberately: it is a hypothesis to be tested
|
| 123 |
-
in the ablation, not a default to be inherited. See docs/01_PROJECT_ANALYSIS.md
|
| 124 |
-
section 6, Stage 2.
|
| 125 |
-
"""
|
| 126 |
-
out = circle_crop(image)
|
| 127 |
-
if use_ben_graham:
|
| 128 |
-
out = ben_graham(out)
|
| 129 |
-
if use_clahe:
|
| 130 |
-
out = apply_clahe(out)
|
| 131 |
-
return cv2.resize(out, (size, size), interpolation=cv2.INTER_AREA)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/eval/__init__.py
DELETED
|
File without changes
|
src/src/drdetect/eval/metrics.py
DELETED
|
@@ -1,340 +0,0 @@
|
|
| 1 |
-
"""Evaluation metrics for DR grading.
|
| 2 |
-
|
| 3 |
-
Two things here are easy to get wrong and are therefore made explicit:
|
| 4 |
-
|
| 5 |
-
1. **The referable-DR threshold must be chosen on validation and then frozen.**
|
| 6 |
-
Picking it on the test set is the most common silent cheat in this
|
| 7 |
-
literature -- it converts "we achieved 90% sensitivity" into "there exists a
|
| 8 |
-
threshold at which we would have". `choose_threshold_for_sensitivity` and
|
| 9 |
-
`evaluate_at_threshold` are separated so the two steps cannot be conflated.
|
| 10 |
-
|
| 11 |
-
2. **A point estimate without an interval is not a result.** On a few hundred
|
| 12 |
-
test images, a reported "91.3% sensitivity" can easily be consistent with
|
| 13 |
-
82%. `bootstrap_ci` exists so no headline number ships bare.
|
| 14 |
-
"""
|
| 15 |
-
|
| 16 |
-
from __future__ import annotations
|
| 17 |
-
|
| 18 |
-
from dataclasses import dataclass
|
| 19 |
-
|
| 20 |
-
import numpy as np
|
| 21 |
-
|
| 22 |
-
__all__ = [
|
| 23 |
-
"REFERABLE_THRESHOLD",
|
| 24 |
-
"BinaryScores",
|
| 25 |
-
"quadratic_weighted_kappa",
|
| 26 |
-
"referable_labels",
|
| 27 |
-
"binary_scores",
|
| 28 |
-
"choose_threshold_for_sensitivity",
|
| 29 |
-
"sensitivity_at_specificity_floor",
|
| 30 |
-
"youden_j",
|
| 31 |
-
"evaluate_at_threshold",
|
| 32 |
-
"bootstrap_ci",
|
| 33 |
-
"expected_calibration_error",
|
| 34 |
-
"DeLongResult",
|
| 35 |
-
"delong_roc_variance",
|
| 36 |
-
"delong_test",
|
| 37 |
-
]
|
| 38 |
-
|
| 39 |
-
# ICDR grade >= 2 (Moderate NPDR) is the clinical referral boundary.
|
| 40 |
-
REFERABLE_THRESHOLD = 2
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
@dataclass(frozen=True)
|
| 44 |
-
class BinaryScores:
|
| 45 |
-
sensitivity: float
|
| 46 |
-
specificity: float
|
| 47 |
-
ppv: float
|
| 48 |
-
npv: float
|
| 49 |
-
tp: int
|
| 50 |
-
fp: int
|
| 51 |
-
tn: int
|
| 52 |
-
fn: int
|
| 53 |
-
|
| 54 |
-
@property
|
| 55 |
-
def n_positive(self) -> int:
|
| 56 |
-
return self.tp + self.fn
|
| 57 |
-
|
| 58 |
-
@property
|
| 59 |
-
def n_negative(self) -> int:
|
| 60 |
-
return self.tn + self.fp
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def quadratic_weighted_kappa(y_true, y_pred, *, n_classes: int = 5) -> float:
|
| 64 |
-
"""Cohen's kappa with quadratic weights -- the standard DR grading metric.
|
| 65 |
-
|
| 66 |
-
Quadratic weighting is what makes this the right metric for an *ordinal*
|
| 67 |
-
task: confusing grade 0 with grade 4 is penalised 16x more than confusing 0
|
| 68 |
-
with 1, matching the clinical cost structure. Plain accuracy treats both as
|
| 69 |
-
a single error.
|
| 70 |
-
"""
|
| 71 |
-
y_true = np.asarray(y_true, dtype=int)
|
| 72 |
-
y_pred = np.asarray(y_pred, dtype=int)
|
| 73 |
-
|
| 74 |
-
obs = np.zeros((n_classes, n_classes), dtype=float)
|
| 75 |
-
for t, p in zip(y_true, y_pred, strict=True):
|
| 76 |
-
obs[t, p] += 1
|
| 77 |
-
|
| 78 |
-
hist_true = obs.sum(axis=1)
|
| 79 |
-
hist_pred = obs.sum(axis=0)
|
| 80 |
-
expected = np.outer(hist_true, hist_pred) / max(obs.sum(), 1)
|
| 81 |
-
|
| 82 |
-
i, j = np.meshgrid(np.arange(n_classes), np.arange(n_classes), indexing="ij")
|
| 83 |
-
weights = ((i - j) ** 2) / ((n_classes - 1) ** 2)
|
| 84 |
-
|
| 85 |
-
denom = (weights * expected).sum()
|
| 86 |
-
if denom == 0:
|
| 87 |
-
return 0.0
|
| 88 |
-
return float(1.0 - (weights * obs).sum() / denom)
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
def referable_labels(grades) -> np.ndarray:
|
| 92 |
-
"""Binarise ICDR grades into referable (>=2) vs not."""
|
| 93 |
-
return (np.asarray(grades) >= REFERABLE_THRESHOLD).astype(int)
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
def binary_scores(y_true_binary, y_pred_binary) -> BinaryScores:
|
| 97 |
-
y_true = np.asarray(y_true_binary, dtype=int)
|
| 98 |
-
y_pred = np.asarray(y_pred_binary, dtype=int)
|
| 99 |
-
|
| 100 |
-
tp = int(((y_true == 1) & (y_pred == 1)).sum())
|
| 101 |
-
fp = int(((y_true == 0) & (y_pred == 1)).sum())
|
| 102 |
-
tn = int(((y_true == 0) & (y_pred == 0)).sum())
|
| 103 |
-
fn = int(((y_true == 1) & (y_pred == 0)).sum())
|
| 104 |
-
|
| 105 |
-
def safe(num: int, den: int) -> float:
|
| 106 |
-
return float(num / den) if den else float("nan")
|
| 107 |
-
|
| 108 |
-
return BinaryScores(
|
| 109 |
-
sensitivity=safe(tp, tp + fn),
|
| 110 |
-
specificity=safe(tn, tn + fp),
|
| 111 |
-
ppv=safe(tp, tp + fp),
|
| 112 |
-
npv=safe(tn, tn + fn),
|
| 113 |
-
tp=tp,
|
| 114 |
-
fp=fp,
|
| 115 |
-
tn=tn,
|
| 116 |
-
fn=fn,
|
| 117 |
-
)
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def choose_threshold_for_sensitivity(
|
| 121 |
-
y_true_binary, y_score, *, target_sensitivity: float = 0.90
|
| 122 |
-
) -> float:
|
| 123 |
-
"""Lowest-specificity-cost threshold meeting a sensitivity floor.
|
| 124 |
-
|
| 125 |
-
**Call this on the VALIDATION set only.** The returned threshold is then
|
| 126 |
-
frozen and passed to `evaluate_at_threshold` on the test set.
|
| 127 |
-
|
| 128 |
-
Screening is deliberately asymmetric: a missed referable case may cost
|
| 129 |
-
sight, a false positive costs one ophthalmologist review. So we fix
|
| 130 |
-
sensitivity and accept whatever specificity follows, rather than optimising
|
| 131 |
-
a symmetric metric like accuracy or Youden's J.
|
| 132 |
-
"""
|
| 133 |
-
y_true = np.asarray(y_true_binary, dtype=int)
|
| 134 |
-
y_score = np.asarray(y_score, dtype=float)
|
| 135 |
-
|
| 136 |
-
best_threshold, best_specificity = 0.0, -1.0
|
| 137 |
-
for threshold in np.unique(np.concatenate([y_score, [0.0, 1.0]])):
|
| 138 |
-
scores = binary_scores(y_true, (y_score >= threshold).astype(int))
|
| 139 |
-
if scores.sensitivity >= target_sensitivity and scores.specificity > best_specificity:
|
| 140 |
-
best_threshold, best_specificity = float(threshold), scores.specificity
|
| 141 |
-
return best_threshold
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
def sensitivity_at_specificity_floor(y_true_binary, y_score, *, spec_floor: float = 0.85) -> float:
|
| 145 |
-
"""Best sensitivity achievable while keeping specificity >= spec_floor.
|
| 146 |
-
|
| 147 |
-
Why this exists
|
| 148 |
-
----------------
|
| 149 |
-
Plain sensitivity, monitored alone with mode="max", is gameable by a
|
| 150 |
-
single degenerate trick: predict referable for everyone. Checked directly
|
| 151 |
-
against this project's own training logs, `val/sensitivity_referable`
|
| 152 |
-
peaks at the exact epoch every other analysis in Phase 3 identified as the
|
| 153 |
-
grade-2 collapse (3 of 4 CE runs checked; the fourth misses by one epoch).
|
| 154 |
-
At that epoch sensitivity hits ~1.00 because the model refers almost every
|
| 155 |
-
image, not because it discriminates well -- specificity there is ~0.80,
|
| 156 |
-
barely above chance for the majority class. See
|
| 157 |
-
docs/07_PHASE3_RESULTS.md for the measurement.
|
| 158 |
-
|
| 159 |
-
Result 4 already showed a *different* naive fix (macro-recall selection)
|
| 160 |
-
fails for the same underlying reason: a single-sided metric cannot
|
| 161 |
-
distinguish genuine improvement from a degenerate shortcut.
|
| 162 |
-
|
| 163 |
-
This function instead operationalises the project's own stated target
|
| 164 |
-
(docs/01_PROJECT_ANALYSIS.md: sensitivity >90%, specificity >85%) directly:
|
| 165 |
-
among all thresholds achieving specificity >= spec_floor on this data,
|
| 166 |
-
return the highest sensitivity. If no threshold clears the floor, return
|
| 167 |
-
`best_specificity - 1.0` -- a value guaranteed to be negative, so any
|
| 168 |
-
epoch that DOES clear the floor always outranks one that does not, and
|
| 169 |
-
epochs that all fail the floor are still ordered by how close they came.
|
| 170 |
-
|
| 171 |
-
**Selection use only.** As with every other `val/*` metric monitored
|
| 172 |
-
during training, this sweeps thresholds on the validation split purely to
|
| 173 |
-
choose which epoch's weights to keep -- the same role val/qwk already
|
| 174 |
-
plays. It is not the reported clinical operating point; that threshold is
|
| 175 |
-
chosen once, on validation, and frozen before scoring the locked test set
|
| 176 |
-
in `scripts/evaluate.py`, per the module-level docstring above.
|
| 177 |
-
"""
|
| 178 |
-
y_true = np.asarray(y_true_binary, dtype=int)
|
| 179 |
-
y_score = np.asarray(y_score, dtype=float)
|
| 180 |
-
|
| 181 |
-
best_sensitivity = -1.0
|
| 182 |
-
best_specificity_overall = -1.0
|
| 183 |
-
for threshold in np.unique(np.concatenate([y_score, [0.0, 1.0]])):
|
| 184 |
-
scores = binary_scores(y_true, (y_score >= threshold).astype(int))
|
| 185 |
-
if scores.specificity > best_specificity_overall:
|
| 186 |
-
best_specificity_overall = scores.specificity
|
| 187 |
-
if scores.specificity >= spec_floor and scores.sensitivity > best_sensitivity:
|
| 188 |
-
best_sensitivity = scores.sensitivity
|
| 189 |
-
|
| 190 |
-
if best_sensitivity >= 0.0:
|
| 191 |
-
return float(best_sensitivity)
|
| 192 |
-
return float(best_specificity_overall - 1.0)
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
def youden_j(y_true_binary, y_score, threshold: float = 0.5) -> float:
|
| 196 |
-
"""Youden's J = sensitivity + specificity - 1, at a fixed threshold.
|
| 197 |
-
|
| 198 |
-
Logged alongside sensitivity_at_specificity_floor purely for comparison in
|
| 199 |
-
the metrics CSV -- it is the standard symmetric alternative, included so a
|
| 200 |
-
later reader can see both without re-deriving them. Not used for selection
|
| 201 |
-
here: unlike the floor-constrained metric, J does not target this
|
| 202 |
-
project's stated specificity requirement and can settle on a point that
|
| 203 |
-
satisfies neither the sensitivity nor the specificity bar.
|
| 204 |
-
"""
|
| 205 |
-
scores = binary_scores(y_true_binary, (np.asarray(y_score) >= threshold).astype(int))
|
| 206 |
-
return float(scores.sensitivity + scores.specificity - 1.0)
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
def evaluate_at_threshold(y_true_binary, y_score, threshold: float) -> BinaryScores:
|
| 210 |
-
"""Apply a threshold frozen from validation. No tuning happens here."""
|
| 211 |
-
return binary_scores(y_true_binary, (np.asarray(y_score) >= threshold).astype(int))
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
def bootstrap_ci(
|
| 215 |
-
metric_fn,
|
| 216 |
-
*arrays,
|
| 217 |
-
n_resamples: int = 2000,
|
| 218 |
-
confidence: float = 0.95,
|
| 219 |
-
seed: int = 42,
|
| 220 |
-
) -> tuple[float, float, float]:
|
| 221 |
-
"""Percentile bootstrap CI for any metric over paired arrays.
|
| 222 |
-
|
| 223 |
-
Returns (point_estimate, lower, upper). Resamples cases (not predictions),
|
| 224 |
-
which is the correct unit -- the uncertainty being quantified is "which
|
| 225 |
-
patients happened to be in this test set".
|
| 226 |
-
"""
|
| 227 |
-
arrays = [np.asarray(a) for a in arrays]
|
| 228 |
-
n = len(arrays[0])
|
| 229 |
-
if any(len(a) != n for a in arrays):
|
| 230 |
-
raise ValueError("all arrays must have the same length")
|
| 231 |
-
|
| 232 |
-
rng = np.random.default_rng(seed)
|
| 233 |
-
point = float(metric_fn(*arrays))
|
| 234 |
-
|
| 235 |
-
stats = np.empty(n_resamples, dtype=float)
|
| 236 |
-
for b in range(n_resamples):
|
| 237 |
-
idx = rng.integers(0, n, size=n)
|
| 238 |
-
stats[b] = metric_fn(*[a[idx] for a in arrays])
|
| 239 |
-
|
| 240 |
-
stats = stats[np.isfinite(stats)]
|
| 241 |
-
if stats.size == 0:
|
| 242 |
-
return point, float("nan"), float("nan")
|
| 243 |
-
|
| 244 |
-
alpha = (1.0 - confidence) / 2.0
|
| 245 |
-
return point, float(np.quantile(stats, alpha)), float(np.quantile(stats, 1 - alpha))
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
@dataclass(frozen=True)
|
| 249 |
-
class DeLongResult:
|
| 250 |
-
auc_a: float
|
| 251 |
-
auc_b: float
|
| 252 |
-
z: float
|
| 253 |
-
p: float
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
def _delong_structural_components(
|
| 257 |
-
pos_scores: np.ndarray, neg_scores: np.ndarray
|
| 258 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 259 |
-
"""Placement values per DeLong et al. (1988) / Sun & Xu (2014): psi(x, y) =
|
| 260 |
-
1 if x>y, 0.5 if x==y, 0 if x<y, averaged over the other group. Their means
|
| 261 |
-
both equal AUC; their sample variances/covariances give AUC's variance
|
| 262 |
-
without ever forming the full O(n_pos * n_neg) U-statistic sum by hand."""
|
| 263 |
-
diff = pos_scores[:, None] - neg_scores[None, :]
|
| 264 |
-
psi = np.where(diff > 0, 1.0, np.where(diff == 0, 0.5, 0.0))
|
| 265 |
-
return psi.mean(axis=1), psi.mean(axis=0)
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
def delong_roc_variance(y_true_binary, y_score) -> tuple[float, float]:
|
| 269 |
-
"""AUC and its DeLong variance for one classifier. Mainly a building block
|
| 270 |
-
for `delong_test`; exposed because reporting AUC's own CI (not just a
|
| 271 |
-
difference) is sometimes wanted on its own."""
|
| 272 |
-
y_true = np.asarray(y_true_binary, dtype=int)
|
| 273 |
-
y_score = np.asarray(y_score, dtype=float)
|
| 274 |
-
v10, v01 = _delong_structural_components(y_score[y_true == 1], y_score[y_true == 0])
|
| 275 |
-
auc = float(v10.mean())
|
| 276 |
-
var = float(v10.var(ddof=1) / len(v10) + v01.var(ddof=1) / len(v01))
|
| 277 |
-
return auc, var
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
def delong_test(y_true_binary, score_a, score_b) -> DeLongResult:
|
| 281 |
-
"""Paired DeLong test: are two classifiers' AUCs different, on the SAME
|
| 282 |
-
sample? (DeLong, DeLong & Clarke-Pearson, 1988; fast form: Sun & Xu, 2014.)
|
| 283 |
-
|
| 284 |
-
Why paired, not two independent bootstrap CIs
|
| 285 |
-
----------------------------------------------
|
| 286 |
-
`score_a` and `score_b` are two models' scores for the *same* patients.
|
| 287 |
-
Comparing two independent-looking AUC confidence intervals for overlap is
|
| 288 |
-
not a significance test and is typically conservative: it ignores that
|
| 289 |
-
both models get the same cases right or wrong for the same reasons (the
|
| 290 |
-
same hard patients), which is exactly the correlation McNemar's test
|
| 291 |
-
exploits for paired sensitivity/specificity elsewhere in this project
|
| 292 |
-
(`scripts/compare.py`). DeLong's test is the AUC-scale analogue: it uses
|
| 293 |
-
the covariance between the two classifiers' per-patient placement values,
|
| 294 |
-
not just their marginal variances.
|
| 295 |
-
"""
|
| 296 |
-
y_true = np.asarray(y_true_binary, dtype=int)
|
| 297 |
-
score_a = np.asarray(score_a, dtype=float)
|
| 298 |
-
score_b = np.asarray(score_b, dtype=float)
|
| 299 |
-
pos, neg = y_true == 1, y_true == 0
|
| 300 |
-
|
| 301 |
-
v10_a, v01_a = _delong_structural_components(score_a[pos], score_a[neg])
|
| 302 |
-
v10_b, v01_b = _delong_structural_components(score_b[pos], score_b[neg])
|
| 303 |
-
auc_a, auc_b = float(v10_a.mean()), float(v10_b.mean())
|
| 304 |
-
|
| 305 |
-
s10 = np.cov(np.vstack([v10_a, v10_b]))
|
| 306 |
-
s01 = np.cov(np.vstack([v01_a, v01_b]))
|
| 307 |
-
s = s10 / len(v10_a) + s01 / len(v01_a)
|
| 308 |
-
|
| 309 |
-
var_d = float(s[0, 0] + s[1, 1] - 2 * s[0, 1])
|
| 310 |
-
if var_d <= 0:
|
| 311 |
-
# Degenerate only when both classifiers agree on every placement
|
| 312 |
-
# (identical scores, or one/zero cases in a class) -- there is no
|
| 313 |
-
# detectable difference to test.
|
| 314 |
-
return DeLongResult(auc_a, auc_b, 0.0, 1.0)
|
| 315 |
-
|
| 316 |
-
from scipy import stats as scipy_stats
|
| 317 |
-
|
| 318 |
-
z = (auc_a - auc_b) / np.sqrt(var_d)
|
| 319 |
-
p = float(2 * (1 - scipy_stats.norm.cdf(abs(z))))
|
| 320 |
-
return DeLongResult(auc_a, auc_b, float(z), p)
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
def expected_calibration_error(y_true_binary, y_prob, *, n_bins: int = 15) -> float:
|
| 324 |
-
"""ECE: mean |confidence - accuracy| over equal-width probability bins.
|
| 325 |
-
|
| 326 |
-
Modern networks are systematically overconfident (Guo et al., 2017), so raw
|
| 327 |
-
softmax output is not a probability. This is the number that must fall after
|
| 328 |
-
temperature scaling in Phase 5.
|
| 329 |
-
"""
|
| 330 |
-
y_true = np.asarray(y_true_binary, dtype=int)
|
| 331 |
-
y_prob = np.asarray(y_prob, dtype=float)
|
| 332 |
-
|
| 333 |
-
edges = np.linspace(0.0, 1.0, n_bins + 1)
|
| 334 |
-
ece = 0.0
|
| 335 |
-
for lo, hi in zip(edges[:-1], edges[1:], strict=True):
|
| 336 |
-
mask = (y_prob > lo) & (y_prob <= hi) if lo > 0 else (y_prob >= lo) & (y_prob <= hi)
|
| 337 |
-
if not mask.any():
|
| 338 |
-
continue
|
| 339 |
-
ece += mask.mean() * abs(y_prob[mask].mean() - y_true[mask].mean())
|
| 340 |
-
return float(ece)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/explain/__init__.py
DELETED
|
File without changes
|
src/src/drdetect/explain/cam_variants.py
DELETED
|
@@ -1,69 +0,0 @@
|
|
| 1 |
-
"""Four CAM methods, one interface, so they can be compared rather than assumed equivalent.
|
| 2 |
-
|
| 3 |
-
docs/04_ROADMAP.md Phase 6 calls for Grad-CAM, Grad-CAM++, Score-CAM and
|
| 4 |
-
Eigen-CAM "side by side" -- comparing them requires calling them the same
|
| 5 |
-
way. Phase 2 shipped only Grad-CAM (drdetect.explain.gradcam); this module
|
| 6 |
-
adds the other three without duplicating the eval-mode/target-layer handling
|
| 7 |
-
already correct there.
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
from __future__ import annotations
|
| 11 |
-
|
| 12 |
-
import torch
|
| 13 |
-
import torch.nn as nn
|
| 14 |
-
|
| 15 |
-
from drdetect.explain.gradcam import default_target_layer
|
| 16 |
-
|
| 17 |
-
__all__ = ["CAM_METHODS", "generate_cam_variant"]
|
| 18 |
-
|
| 19 |
-
CAM_METHODS = ("gradcam", "gradcam++", "scorecam", "eigencam")
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
def _cam_class(name: str):
|
| 23 |
-
from pytorch_grad_cam import EigenCAM, GradCAM, GradCAMPlusPlus, ScoreCAM
|
| 24 |
-
|
| 25 |
-
return {
|
| 26 |
-
"gradcam": GradCAM,
|
| 27 |
-
"gradcam++": GradCAMPlusPlus,
|
| 28 |
-
"scorecam": ScoreCAM,
|
| 29 |
-
"eigencam": EigenCAM,
|
| 30 |
-
}[name]
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def generate_cam_variant(
|
| 34 |
-
method: str,
|
| 35 |
-
model: nn.Module,
|
| 36 |
-
input_tensor: torch.Tensor,
|
| 37 |
-
target_class: int,
|
| 38 |
-
target_layer: nn.Module | None = None,
|
| 39 |
-
):
|
| 40 |
-
"""Grayscale heatmap in [0, 1] from the named method.
|
| 41 |
-
|
| 42 |
-
Args:
|
| 43 |
-
method: one of CAM_METHODS.
|
| 44 |
-
target_class: explain the PREDICTED class -- see drdetect.explain.gradcam.generate_cam.
|
| 45 |
-
|
| 46 |
-
EigenCAM and Score-CAM do not use gradients (Eigen-CAM is a pure
|
| 47 |
-
activation decomposition; Score-CAM perturbs the input and reads off
|
| 48 |
-
softmax scores), so they are computed under `torch.no_grad()`, unlike
|
| 49 |
-
Grad-CAM/Grad-CAM++ which require gradients to reach the target layer.
|
| 50 |
-
"""
|
| 51 |
-
from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget
|
| 52 |
-
|
| 53 |
-
if method not in CAM_METHODS:
|
| 54 |
-
raise ValueError(f"unknown CAM method {method!r}; expected one of {CAM_METHODS}")
|
| 55 |
-
|
| 56 |
-
layer = target_layer or default_target_layer(model)
|
| 57 |
-
cam_cls = _cam_class(method)
|
| 58 |
-
needs_grad = method in ("gradcam", "gradcam++")
|
| 59 |
-
|
| 60 |
-
was_training = model.training
|
| 61 |
-
model.eval()
|
| 62 |
-
try:
|
| 63 |
-
with torch.set_grad_enabled(needs_grad), cam_cls(model=model, target_layers=[layer]) as cam:
|
| 64 |
-
grayscale_cam = cam(
|
| 65 |
-
input_tensor=input_tensor, targets=[ClassifierOutputTarget(target_class)]
|
| 66 |
-
)
|
| 67 |
-
return grayscale_cam[0]
|
| 68 |
-
finally:
|
| 69 |
-
model.train(was_training)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/explain/evidence.py
DELETED
|
@@ -1,175 +0,0 @@
|
|
| 1 |
-
"""ICDR evidence table + templated natural-language rationale, and outline-based
|
| 2 |
-
lesion-overlay rendering for the final report (docs/04_ROADMAP.md, Phase 6).
|
| 3 |
-
|
| 4 |
-
Reuses `drdetect.fusion.features`'s model-running internals (the same tiled
|
| 5 |
-
segmentation inference, OD/fovea localisation, and microaneurysm candidate
|
| 6 |
-
scoring already built and evaluated for Phase 5's fusion head) rather than
|
| 7 |
-
re-implementing lesion detection a second time -- this module only adds the
|
| 8 |
-
per-instance/per-quadrant structure a human-readable report needs, on top of
|
| 9 |
-
the aggregate scalar features Phase 5's fusion head consumes.
|
| 10 |
-
"""
|
| 11 |
-
|
| 12 |
-
from __future__ import annotations
|
| 13 |
-
|
| 14 |
-
from pathlib import Path
|
| 15 |
-
|
| 16 |
-
import cv2
|
| 17 |
-
import numpy as np
|
| 18 |
-
|
| 19 |
-
from drdetect.fusion.features import (
|
| 20 |
-
LOCALIZATION_SIZE,
|
| 21 |
-
LesionModels,
|
| 22 |
-
_predict_od_fovea,
|
| 23 |
-
_tile_predict,
|
| 24 |
-
lesion_centroids,
|
| 25 |
-
)
|
| 26 |
-
from drdetect.segmentation.microaneurysms import score_candidates
|
| 27 |
-
from drdetect.segmentation.quadrants import assign_quadrant
|
| 28 |
-
|
| 29 |
-
__all__ = [
|
| 30 |
-
"extract_lesion_evidence",
|
| 31 |
-
"render_lesion_overlay",
|
| 32 |
-
"build_icdr_rationale",
|
| 33 |
-
"LESION_COLORS_RGB",
|
| 34 |
-
]
|
| 35 |
-
|
| 36 |
-
_SEGMENTED_LESIONS = ["hard_exudates", "soft_exudates", "haemorrhages"]
|
| 37 |
-
# RGB, since render_lesion_overlay draws on an RGB array throughout this module.
|
| 38 |
-
LESION_COLORS_RGB = {
|
| 39 |
-
"hard_exudates": (255, 215, 0), # amber
|
| 40 |
-
"soft_exudates": (0, 191, 255), # light blue
|
| 41 |
-
"haemorrhages": (255, 0, 0), # red
|
| 42 |
-
"microaneurysms": (255, 0, 255), # magenta
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def extract_lesion_evidence(image_path: str | Path, models: LesionModels) -> dict:
|
| 47 |
-
"""Per-lesion-type instance list (quadrant + size) and binary masks for
|
| 48 |
-
rendering, from the same models `drdetect.fusion.features` uses for the
|
| 49 |
-
fusion head's aggregate scalars."""
|
| 50 |
-
image_bgr = cv2.imread(str(image_path), cv2.IMREAD_COLOR)
|
| 51 |
-
image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
|
| 52 |
-
|
| 53 |
-
od_xy, fovea_xy, _diameter = _predict_od_fovea(image_rgb, models)
|
| 54 |
-
scale_x = LOCALIZATION_SIZE[1] / image_rgb.shape[1]
|
| 55 |
-
scale_y = LOCALIZATION_SIZE[0] / image_rgb.shape[0]
|
| 56 |
-
|
| 57 |
-
lesions: dict[str, dict] = {}
|
| 58 |
-
masks: dict[str, np.ndarray] = {}
|
| 59 |
-
for lesion_type in _SEGMENTED_LESIONS:
|
| 60 |
-
module = getattr(models, lesion_type)
|
| 61 |
-
prob_map = _tile_predict(module, image_rgb, models.device)
|
| 62 |
-
binary_mask = prob_map > 0.5
|
| 63 |
-
masks[lesion_type] = binary_mask
|
| 64 |
-
centroids = lesion_centroids(prob_map, threshold=0.5)
|
| 65 |
-
instances = [
|
| 66 |
-
{"quadrant": assign_quadrant((cx * scale_x, cy * scale_y), od_xy, fovea_xy)}
|
| 67 |
-
for cx, cy in centroids
|
| 68 |
-
]
|
| 69 |
-
lesions[lesion_type] = {
|
| 70 |
-
"count": len(instances),
|
| 71 |
-
"area_pct": float(binary_mask.mean() * 100.0),
|
| 72 |
-
"instances": instances,
|
| 73 |
-
}
|
| 74 |
-
|
| 75 |
-
dummy_mask = np.zeros(image_bgr.shape[:2], dtype=np.uint8)
|
| 76 |
-
ma_result = score_candidates(
|
| 77 |
-
image_bgr, dummy_mask, models.microaneurysm_classifier.model, device=str(models.device)
|
| 78 |
-
)
|
| 79 |
-
accepted = ma_result["scores"] > 0.95
|
| 80 |
-
ma_instances = [
|
| 81 |
-
{
|
| 82 |
-
"quadrant": assign_quadrant((c.cx * scale_x, c.cy * scale_y), od_xy, fovea_xy),
|
| 83 |
-
"xy": (float(c.cx), float(c.cy)),
|
| 84 |
-
}
|
| 85 |
-
for c, keep in zip(ma_result["candidates"], accepted, strict=True)
|
| 86 |
-
if keep
|
| 87 |
-
]
|
| 88 |
-
lesions["microaneurysms"] = {"count": len(ma_instances), "instances": ma_instances}
|
| 89 |
-
|
| 90 |
-
return {"od_xy": od_xy, "fovea_xy": fovea_xy, "lesions": lesions, "masks": masks}
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def render_lesion_overlay(image_rgb: np.ndarray, evidence: dict) -> np.ndarray:
|
| 94 |
-
"""Outline contours, not filled blobs -- the roadmap's own stated reason:
|
| 95 |
-
outlines beat blobs for clinical legibility (a filled overlay hides the
|
| 96 |
-
tissue underneath it; an outline points at it without obscuring it).
|
| 97 |
-
|
| 98 |
-
Microaneurysms are marked with small circles rather than outlines --
|
| 99 |
-
they're candidate *points* from the classifier (drdetect.segmentation.
|
| 100 |
-
microaneurysms), not a segmented region, so there's no contour to draw.
|
| 101 |
-
"""
|
| 102 |
-
overlay = image_rgb.copy()
|
| 103 |
-
for lesion_type, mask in evidence["masks"].items():
|
| 104 |
-
color = LESION_COLORS_RGB[lesion_type]
|
| 105 |
-
contours, _ = cv2.findContours(
|
| 106 |
-
mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
| 107 |
-
)
|
| 108 |
-
cv2.drawContours(overlay, contours, -1, color, thickness=3)
|
| 109 |
-
|
| 110 |
-
ma_color = LESION_COLORS_RGB["microaneurysms"]
|
| 111 |
-
for inst in evidence["lesions"].get("microaneurysms", {}).get("instances", []):
|
| 112 |
-
x, y = inst["xy"]
|
| 113 |
-
cv2.circle(overlay, (int(round(x)), int(round(y))), radius=12, color=ma_color, thickness=2)
|
| 114 |
-
|
| 115 |
-
return overlay
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
_ICDR_GRADE_NAMES = {
|
| 119 |
-
0: "No apparent DR",
|
| 120 |
-
1: "Mild NPDR",
|
| 121 |
-
2: "Moderate NPDR",
|
| 122 |
-
3: "Severe NPDR",
|
| 123 |
-
4: "Proliferative DR",
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
def build_icdr_rationale(grade: int, evidence: dict) -> str:
|
| 128 |
-
"""A templated sentence grounding the predicted ICDR grade in the actual
|
| 129 |
-
detected evidence -- not a restatement of the grade, an explanation of
|
| 130 |
-
what supports it (or, honestly, what doesn't fully support it).
|
| 131 |
-
|
| 132 |
-
Neovascularisation (the feature that actually defines grade 4, PDR) has
|
| 133 |
-
no segmentation model in this project (docs/01's own scope-honesty
|
| 134 |
-
note: "neovascularisation has no public pixel masks... document as
|
| 135 |
-
future work, do not fabricate it") -- a grade-4 rationale says so
|
| 136 |
-
explicitly rather than inventing NV evidence this pipeline cannot see.
|
| 137 |
-
"""
|
| 138 |
-
lesions = evidence["lesions"]
|
| 139 |
-
ma_n = lesions.get("microaneurysms", {}).get("count", 0)
|
| 140 |
-
he_n = lesions.get("haemorrhages", {}).get("count", 0)
|
| 141 |
-
ex_pct = lesions.get("hard_exudates", {}).get("area_pct", 0.0)
|
| 142 |
-
se_pct = lesions.get("soft_exudates", {}).get("area_pct", 0.0)
|
| 143 |
-
|
| 144 |
-
quadrants_with_he = {
|
| 145 |
-
inst["quadrant"] for inst in lesions.get("haemorrhages", {}).get("instances", [])
|
| 146 |
-
}
|
| 147 |
-
|
| 148 |
-
evidence_parts = []
|
| 149 |
-
if ma_n:
|
| 150 |
-
evidence_parts.append(f"{ma_n} microaneurysm candidate(s)")
|
| 151 |
-
if he_n:
|
| 152 |
-
evidence_parts.append(
|
| 153 |
-
f"{he_n} haemorrhage region(s) across {len(quadrants_with_he)} quadrant(s)"
|
| 154 |
-
)
|
| 155 |
-
if ex_pct > 0.01:
|
| 156 |
-
evidence_parts.append(f"hard exudates covering {ex_pct:.2f}% of the image")
|
| 157 |
-
if se_pct > 0.01:
|
| 158 |
-
evidence_parts.append(
|
| 159 |
-
f"soft exudates (cotton-wool spots) covering {se_pct:.2f}% of the image"
|
| 160 |
-
)
|
| 161 |
-
|
| 162 |
-
grade_name = _ICDR_GRADE_NAMES.get(grade, f"grade {grade}")
|
| 163 |
-
if not evidence_parts:
|
| 164 |
-
return f"ICDR grade {grade} ({grade_name}): no significant lesion evidence detected."
|
| 165 |
-
|
| 166 |
-
body = "; ".join(evidence_parts)
|
| 167 |
-
note = ""
|
| 168 |
-
if grade == 4:
|
| 169 |
-
note = (
|
| 170 |
-
" Note: proliferative DR (grade 4) is defined by neovascularisation, which this "
|
| 171 |
-
"pipeline does not detect (no public pixel masks exist to train against) -- this "
|
| 172 |
-
"grade is the classifier's own image-level prediction, not evidence-backed for the "
|
| 173 |
-
"specific feature that defines it."
|
| 174 |
-
)
|
| 175 |
-
return f"ICDR grade {grade} ({grade_name}): {body}.{note}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/explain/gradcam.py
DELETED
|
@@ -1,97 +0,0 @@
|
|
| 1 |
-
"""Grad-CAM evidence overlay for the DR grader.
|
| 2 |
-
|
| 3 |
-
Thin wrapper around `pytorch_grad_cam` rather than a hand-rolled hook, so the
|
| 4 |
-
CAM implementation matches a maintained, widely-checked reference rather than
|
| 5 |
-
a bespoke one. Phase 6 (docs/04_ROADMAP.md) is where CAM variants get compared
|
| 6 |
-
side by side and run through Adebayo's sanity checks -- this module is only
|
| 7 |
-
the single Grad-CAM used for Phase 2's end-to-end slice, not that comparison.
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
from __future__ import annotations
|
| 11 |
-
|
| 12 |
-
import cv2
|
| 13 |
-
import numpy as np
|
| 14 |
-
import torch
|
| 15 |
-
import torch.nn as nn
|
| 16 |
-
|
| 17 |
-
__all__ = ["default_target_layer", "generate_cam", "overlay_cam"]
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def default_target_layer(model: nn.Module) -> nn.Module:
|
| 21 |
-
"""The last activation before global pooling, for a timm EfficientNet.
|
| 22 |
-
|
| 23 |
-
`bn2`, not `conv_head`. timm's EfficientNet fuses BatchNorm+SiLU into a
|
| 24 |
-
single `BatchNormAct2d` module at `bn2`, so `conv_head`'s own output is
|
| 25 |
-
the bare pre-activation conv -- unbounded, and roughly half-negative in
|
| 26 |
-
practice. Grad-CAM/Grad-CAM++/Eigen-CAM mostly tolerate that (their
|
| 27 |
-
weights can themselves be negative and partially cancel it out), but
|
| 28 |
-
Score-CAM's weights are a softmax over class scores -- always positive --
|
| 29 |
-
so weighting an already-negative activation map and then ReLU-ing the
|
| 30 |
-
result (`base_cam.py`'s `compute_cam_per_layer`) can zero it out
|
| 31 |
-
entirely. Measured directly: `conv_head` produced an all-zero Score-CAM
|
| 32 |
-
heatmap on a real image; `bn2`, whose output has already passed through
|
| 33 |
-
SiLU, did not. `bn2` is the correct choice for every method, not a
|
| 34 |
-
Score-CAM-specific workaround -- it's simply the last point where the
|
| 35 |
-
activation reflects what the network actually forward-propagates.
|
| 36 |
-
"""
|
| 37 |
-
backbone = getattr(model, "backbone", model)
|
| 38 |
-
if not hasattr(backbone, "bn2"):
|
| 39 |
-
raise AttributeError(
|
| 40 |
-
f"{type(backbone).__name__} has no `bn2`; pass target_layer explicitly."
|
| 41 |
-
)
|
| 42 |
-
return backbone.bn2
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def generate_cam(
|
| 46 |
-
model: nn.Module,
|
| 47 |
-
input_tensor: torch.Tensor,
|
| 48 |
-
target_class: int,
|
| 49 |
-
target_layer: nn.Module | None = None,
|
| 50 |
-
) -> np.ndarray:
|
| 51 |
-
"""Grad-CAM heatmap for one image, as a single HxW array in [0, 1].
|
| 52 |
-
|
| 53 |
-
Args:
|
| 54 |
-
model: the grader, in eval mode. Frozen BatchNorm does not interfere
|
| 55 |
-
-- Grad-CAM only needs gradients w.r.t. activations, never an
|
| 56 |
-
optimiser step.
|
| 57 |
-
input_tensor: 1xCxHxW, already normalised exactly as at inference.
|
| 58 |
-
target_class: which class's score to explain. For a grading report
|
| 59 |
-
this should be the PREDICTED class, not the true label -- the
|
| 60 |
-
report is explaining what the model said, not grading the CAM.
|
| 61 |
-
target_layer: defaults to `default_target_layer(model)`.
|
| 62 |
-
"""
|
| 63 |
-
from pytorch_grad_cam import GradCAM
|
| 64 |
-
from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget
|
| 65 |
-
|
| 66 |
-
layer = target_layer or default_target_layer(model)
|
| 67 |
-
was_training = model.training
|
| 68 |
-
model.eval()
|
| 69 |
-
try:
|
| 70 |
-
with GradCAM(model=model, target_layers=[layer]) as cam:
|
| 71 |
-
grayscale_cam = cam(
|
| 72 |
-
input_tensor=input_tensor, targets=[ClassifierOutputTarget(target_class)]
|
| 73 |
-
)
|
| 74 |
-
return grayscale_cam[0]
|
| 75 |
-
finally:
|
| 76 |
-
model.train(was_training)
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def overlay_cam(
|
| 80 |
-
rgb_image: np.ndarray, grayscale_cam: np.ndarray, alpha: float = 0.45
|
| 81 |
-
) -> np.ndarray:
|
| 82 |
-
"""Blend a Grad-CAM heatmap onto its source image.
|
| 83 |
-
|
| 84 |
-
Args:
|
| 85 |
-
rgb_image: HxWx3 uint8, the exact image the CAM was computed on
|
| 86 |
-
(post-preprocessing) -- overlaying on a differently-cropped raw
|
| 87 |
-
image would misalign the heatmap with the lesions it is marking.
|
| 88 |
-
grayscale_cam: output of `generate_cam`, same H and W as `rgb_image`.
|
| 89 |
-
alpha: heatmap opacity.
|
| 90 |
-
"""
|
| 91 |
-
from pytorch_grad_cam.utils.image import show_cam_on_image
|
| 92 |
-
|
| 93 |
-
if grayscale_cam.shape[:2] != rgb_image.shape[:2]:
|
| 94 |
-
grayscale_cam = cv2.resize(grayscale_cam, (rgb_image.shape[1], rgb_image.shape[0]))
|
| 95 |
-
float_rgb = rgb_image.astype(np.float32) / 255.0
|
| 96 |
-
overlay = show_cam_on_image(float_rgb, grayscale_cam, use_rgb=True, image_weight=1 - alpha)
|
| 97 |
-
return overlay
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/explain/localization_metrics.py
DELETED
|
@@ -1,55 +0,0 @@
|
|
| 1 |
-
"""CAM-vs-ground-truth localisation quality: pointing game + IoU.
|
| 2 |
-
|
| 3 |
-
Phase 6's own exit criterion (docs/04_ROADMAP.md): "a table of saliency
|
| 4 |
-
methods x (sanity-check pass/fail, pointing-game accuracy, IoU)". This
|
| 5 |
-
module computes the pointing-game and IoU halves of that table, against
|
| 6 |
-
IDRiD's real per-lesion-type pixel masks -- the sanity-check half already
|
| 7 |
-
exists in drdetect.explain.sanity_checks (docs/08_PHASE6_RESULTS.md).
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
from __future__ import annotations
|
| 11 |
-
|
| 12 |
-
import cv2
|
| 13 |
-
import numpy as np
|
| 14 |
-
|
| 15 |
-
__all__ = ["upsample_cam", "pointing_game_hit", "cam_mask_iou"]
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def upsample_cam(cam: np.ndarray, target_shape: tuple[int, int]) -> np.ndarray:
|
| 19 |
-
"""Resize a CAM (native resolution: the model's input size, e.g. 512x512)
|
| 20 |
-
up to `target_shape` (height, width) -- typically a lesion mask's own
|
| 21 |
-
full resolution. A CAM is already a smooth, interpolated heatmap, so
|
| 22 |
-
upsampling it preserves the mask's own pixel precision, rather than
|
| 23 |
-
downsampling the mask and losing the smallest lesions -- microaneurysms
|
| 24 |
-
are already known, from this project's own Phase 3 findings, to vanish
|
| 25 |
-
below ~1024px."""
|
| 26 |
-
h, w = target_shape
|
| 27 |
-
return cv2.resize(cam, (w, h), interpolation=cv2.INTER_LINEAR)
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def pointing_game_hit(cam: np.ndarray, mask: np.ndarray) -> bool:
|
| 31 |
-
"""True if the CAM's single point of maximum attention falls on a
|
| 32 |
-
positive mask pixel -- the standard pointing-game definition (Zhang et
|
| 33 |
-
al. 2018), threshold-free by construction."""
|
| 34 |
-
y, x = np.unravel_index(np.argmax(cam), cam.shape)
|
| 35 |
-
return bool(mask[y, x] > 0)
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
def cam_mask_iou(cam: np.ndarray, mask: np.ndarray, threshold: float = 0.5) -> float:
|
| 39 |
-
"""IoU between the CAM thresholded at a fixed value and the binary
|
| 40 |
-
lesion mask.
|
| 41 |
-
|
| 42 |
-
Expect this to read low for genuinely sparse lesion types
|
| 43 |
-
(haemorrhages and microaneurysms are well under 0.1% of image pixels,
|
| 44 |
-
see drdetect.segmentation.metrics) even for a CAM correctly pointing at
|
| 45 |
-
the right general region -- Grad-CAM produces a smooth, diffuse
|
| 46 |
-
attention region by construction, not a pixel-precise segmentation, so
|
| 47 |
-
IoU against a sparse mask is a much harder bar than the pointing game.
|
| 48 |
-
NaN, not 0, when the union is empty (both CAM-positive and mask are
|
| 49 |
-
empty) -- 0 would misrepresent "nothing to compare" as "total miss".
|
| 50 |
-
"""
|
| 51 |
-
binary_cam = cam > threshold
|
| 52 |
-
binary_mask = mask.astype(bool)
|
| 53 |
-
intersection = np.logical_and(binary_cam, binary_mask).sum()
|
| 54 |
-
union = np.logical_or(binary_cam, binary_mask).sum()
|
| 55 |
-
return float(intersection / union) if union > 0 else float("nan")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/explain/sanity_checks.py
DELETED
|
@@ -1,157 +0,0 @@
|
|
| 1 |
-
"""Adebayo-style sanity checks for saliency methods.
|
| 2 |
-
|
| 3 |
-
Adebayo et al. 2018 (NeurIPS), "Sanity Checks for Saliency Maps": several
|
| 4 |
-
widely-used saliency methods are empirically insensitive to the model's
|
| 5 |
-
learned parameters -- they produce nearly the same map whether the model is
|
| 6 |
-
trained or has its weights replaced with noise. A method that fails this is
|
| 7 |
-
not explaining the MODEL; it behaves closer to an edge detector applied to
|
| 8 |
-
the input, dressed up as an explanation. Nobody knew whether that applied
|
| 9 |
-
here until it was checked -- this project's own culture treats an unchecked
|
| 10 |
-
assumption as no different from a wrong one.
|
| 11 |
-
|
| 12 |
-
Two checks, both from the paper:
|
| 13 |
-
|
| 14 |
-
Model parameter randomisation (`model_randomization_test`) -- cascading:
|
| 15 |
-
progressively randomise the model from the OUTPUT layer backward
|
| 16 |
-
toward the input, and measure how much each CAM method's heatmap
|
| 17 |
-
changes at each step, against its own original heatmap. A method that
|
| 18 |
-
PASSES should diverge quickly; a method whose heatmap barely moves as
|
| 19 |
-
the whole model turns to noise is not using what the model learned.
|
| 20 |
-
|
| 21 |
-
Data randomisation (`compare_against_shuffled_labels`) -- compare CAMs
|
| 22 |
-
from the real trained model against CAMs from an otherwise-identical
|
| 23 |
-
model trained on the SAME images with shuffled labels. A method that
|
| 24 |
-
PASSES should look different between the two, since the two models
|
| 25 |
-
learned genuinely different things; a method that looks similar
|
| 26 |
-
regardless of what the underlying model actually learned is not
|
| 27 |
-
sensitive to that.
|
| 28 |
-
|
| 29 |
-
Similarity is reported as both Spearman rank correlation and SSIM, matching
|
| 30 |
-
the two metrics the original paper used -- rank correlation alone can be
|
| 31 |
-
fooled by a saliency map that reorders pixels similarly but at a different
|
| 32 |
-
spatial scale, which SSIM is sensitive to and rank correlation is not.
|
| 33 |
-
"""
|
| 34 |
-
|
| 35 |
-
from __future__ import annotations
|
| 36 |
-
|
| 37 |
-
import copy
|
| 38 |
-
from dataclasses import dataclass
|
| 39 |
-
|
| 40 |
-
import numpy as np
|
| 41 |
-
import torch
|
| 42 |
-
import torch.nn as nn
|
| 43 |
-
|
| 44 |
-
from drdetect.explain.cam_variants import CAM_METHODS, generate_cam_variant
|
| 45 |
-
|
| 46 |
-
__all__ = [
|
| 47 |
-
"CascadeStep",
|
| 48 |
-
"cam_similarity",
|
| 49 |
-
"cascading_randomization_layers",
|
| 50 |
-
"model_randomization_test",
|
| 51 |
-
"compare_against_shuffled_labels",
|
| 52 |
-
]
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
@dataclass(frozen=True)
|
| 56 |
-
class CascadeStep:
|
| 57 |
-
layer_randomized: str
|
| 58 |
-
spearman: dict[str, float]
|
| 59 |
-
ssim: dict[str, float]
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def cam_similarity(cam_a: np.ndarray, cam_b: np.ndarray) -> tuple[float, float]:
|
| 63 |
-
"""(Spearman rank correlation, SSIM) between two same-shape grayscale CAMs."""
|
| 64 |
-
from scipy.stats import spearmanr
|
| 65 |
-
from skimage.metrics import structural_similarity
|
| 66 |
-
|
| 67 |
-
rho = float(spearmanr(cam_a.ravel(), cam_b.ravel()).statistic)
|
| 68 |
-
ssim = float(structural_similarity(cam_a, cam_b, data_range=1.0))
|
| 69 |
-
return rho, ssim
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
def cascading_randomization_layers(backbone: nn.Module) -> list[tuple[str, list[nn.Module]]]:
|
| 73 |
-
"""Named layer groups of a timm EfficientNet backbone, OUTPUT-to-INPUT order.
|
| 74 |
-
|
| 75 |
-
This order is the point: Adebayo's cascading test randomises from the
|
| 76 |
-
output backward, one layer group at a time, so that at step k the model
|
| 77 |
-
is "the real model, except everything from the output back through
|
| 78 |
-
group k is now noise." Reversed (input-to-output) would be a different,
|
| 79 |
-
weaker test -- early layers dominate less of what a CAM at `bn2`
|
| 80 |
-
(drdetect.explain.gradcam.default_target_layer) actually reflects.
|
| 81 |
-
"""
|
| 82 |
-
layers = [
|
| 83 |
-
("classifier", [backbone.classifier]),
|
| 84 |
-
("conv_head+bn2", [backbone.conv_head, backbone.bn2]),
|
| 85 |
-
]
|
| 86 |
-
for i in reversed(range(len(backbone.blocks))):
|
| 87 |
-
layers.append((f"blocks[{i}]", [backbone.blocks[i]]))
|
| 88 |
-
layers.append(("conv_stem+bn1", [backbone.conv_stem, backbone.bn1]))
|
| 89 |
-
return layers
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
def _reset_in_place(modules: list[nn.Module]) -> None:
|
| 93 |
-
for module in modules:
|
| 94 |
-
for child in module.modules():
|
| 95 |
-
if hasattr(child, "reset_parameters"):
|
| 96 |
-
child.reset_parameters()
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
def model_randomization_test(
|
| 100 |
-
model: nn.Module,
|
| 101 |
-
input_tensor: torch.Tensor,
|
| 102 |
-
target_class: int,
|
| 103 |
-
methods: tuple[str, ...] = CAM_METHODS,
|
| 104 |
-
) -> list[CascadeStep]:
|
| 105 |
-
"""Cascading model-randomisation sanity check across every CAM method at once.
|
| 106 |
-
|
| 107 |
-
Works on a deep copy -- the caller's trained model is never mutated.
|
| 108 |
-
Every method's CAM is computed against the SAME progressively-randomised
|
| 109 |
-
model at each step, so the comparison at a given step is apples to apples
|
| 110 |
-
across methods, not just against each method's own baseline.
|
| 111 |
-
"""
|
| 112 |
-
randomized = copy.deepcopy(model)
|
| 113 |
-
backbone = getattr(randomized, "backbone", randomized)
|
| 114 |
-
layer_groups = cascading_randomization_layers(backbone)
|
| 115 |
-
|
| 116 |
-
originals = {
|
| 117 |
-
m: generate_cam_variant(m, randomized, input_tensor, target_class) for m in methods
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
steps = []
|
| 121 |
-
for name, modules in layer_groups:
|
| 122 |
-
_reset_in_place(modules)
|
| 123 |
-
spearman, ssim = {}, {}
|
| 124 |
-
for method in methods:
|
| 125 |
-
cam = generate_cam_variant(method, randomized, input_tensor, target_class)
|
| 126 |
-
rho, s = cam_similarity(originals[method], cam)
|
| 127 |
-
spearman[method] = rho
|
| 128 |
-
ssim[method] = s
|
| 129 |
-
steps.append(CascadeStep(layer_randomized=name, spearman=spearman, ssim=ssim))
|
| 130 |
-
return steps
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
def compare_against_shuffled_labels(
|
| 134 |
-
real_model: nn.Module,
|
| 135 |
-
shuffled_model: nn.Module,
|
| 136 |
-
input_tensor: torch.Tensor,
|
| 137 |
-
target_class_real: int,
|
| 138 |
-
target_class_shuffled: int,
|
| 139 |
-
methods: tuple[str, ...] = CAM_METHODS,
|
| 140 |
-
) -> dict[str, tuple[float, float]]:
|
| 141 |
-
"""Data-randomisation check: CAM(real model) vs CAM(shuffled-label model), same image.
|
| 142 |
-
|
| 143 |
-
Args:
|
| 144 |
-
target_class_real / target_class_shuffled: each model's OWN predicted
|
| 145 |
-
class for this image -- the two models were trained on different
|
| 146 |
-
label associations and will usually disagree, and each CAM must
|
| 147 |
-
explain what its own model actually predicted.
|
| 148 |
-
|
| 149 |
-
Returns: {method: (spearman, ssim)}.
|
| 150 |
-
"""
|
| 151 |
-
return {
|
| 152 |
-
method: cam_similarity(
|
| 153 |
-
generate_cam_variant(method, real_model, input_tensor, target_class_real),
|
| 154 |
-
generate_cam_variant(method, shuffled_model, input_tensor, target_class_shuffled),
|
| 155 |
-
)
|
| 156 |
-
for method in methods
|
| 157 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/fusion/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
"""Phase 5: fusing the grading model's CNN embedding with Phase 4's lesion evidence."""
|
|
|
|
|
|
src/src/drdetect/fusion/embedding.py
DELETED
|
@@ -1,75 +0,0 @@
|
|
| 1 |
-
"""CNN embedding extraction from the canonical grading checkpoint, for
|
| 2 |
-
Phase 5's fusion head.
|
| 3 |
-
|
| 4 |
-
Uses `baseline_effb0_512_fold0` (QWK 0.8930), not the 1024px checkpoint --
|
| 5 |
-
Phase 3's own conclusion was "the baseline stands" (docs/07_PHASE3_RESULTS.md),
|
| 6 |
-
and Result 14 retracted the 1024px checkpoint's causal story (its edge over
|
| 7 |
-
the baseline is now suspected to be a backend artifact, not resolution).
|
| 8 |
-
Building the fusion head on a result the project already walked back would
|
| 9 |
-
undercut that finding.
|
| 10 |
-
|
| 11 |
-
Reuses the checkpoint-loading convention scripts/evaluate.py and
|
| 12 |
-
scripts/calibrate.py already established (strip the Lightning `model.`
|
| 13 |
-
prefix, load into a bare `FundusGrader`) rather than inventing a new one.
|
| 14 |
-
|
| 15 |
-
**Images come from the Ben-Graham-preprocessed cache
|
| 16 |
-
(`data/processed/aptos_512/`), not the raw APTOS files** -- unlike
|
| 17 |
-
`drdetect.fusion.features`, which deliberately uses raw images because the
|
| 18 |
-
Phase 4 lesion models were trained on IDRiD's un-enhanced originals. The
|
| 19 |
-
grading model was trained on the preprocessed cache, so its embedding is
|
| 20 |
-
only meaningful computed on the same distribution it actually learned from.
|
| 21 |
-
Two different "the right image for this model" answers for two different
|
| 22 |
-
models feeding the same fusion vector -- not an inconsistency.
|
| 23 |
-
"""
|
| 24 |
-
|
| 25 |
-
from __future__ import annotations
|
| 26 |
-
|
| 27 |
-
from pathlib import Path
|
| 28 |
-
|
| 29 |
-
import torch
|
| 30 |
-
|
| 31 |
-
from drdetect.data.dataset import build_transforms
|
| 32 |
-
from drdetect.grading.model import FundusGrader, build_model
|
| 33 |
-
|
| 34 |
-
__all__ = ["load_grading_backbone", "extract_cnn_embedding", "EMBEDDING_DIM"]
|
| 35 |
-
|
| 36 |
-
EMBEDDING_DIM = 1280 # efficientnet_b0's pre-classifier feature width
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
def load_grading_backbone(
|
| 40 |
-
checkpoint: str, *, backbone: str = "efficientnet_b0", device: torch.device
|
| 41 |
-
) -> FundusGrader:
|
| 42 |
-
model = build_model(backbone, num_outputs=5, pretrained=False, freeze_bn=True)
|
| 43 |
-
ckpt = torch.load(checkpoint, map_location="cpu", weights_only=False)
|
| 44 |
-
state = ckpt.get("state_dict", ckpt)
|
| 45 |
-
state = {k.removeprefix("model."): v for k, v in state.items() if k.startswith("model.")}
|
| 46 |
-
model.load_state_dict(state)
|
| 47 |
-
return model.to(device).eval()
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def extract_cnn_embedding(
|
| 51 |
-
image_path: str | Path, model: FundusGrader, device: torch.device, *, size: int = 512
|
| 52 |
-
) -> torch.Tensor:
|
| 53 |
-
"""The 1280-d pre-classifier pooled feature (`forward_head(...,
|
| 54 |
-
pre_logits=True)`), not the 5-way logits `model(x)` would give."""
|
| 55 |
-
embedding, _logits = extract_cnn_embedding_and_logits(image_path, model, device, size=size)
|
| 56 |
-
return embedding
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
def extract_cnn_embedding_and_logits(
|
| 60 |
-
image_path: str | Path, model: FundusGrader, device: torch.device, *, size: int = 512
|
| 61 |
-
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 62 |
-
"""Both the fusion head's input (the embedding) and the baseline
|
| 63 |
-
grading-only prediction (the 5-way logits) from one forward pass --
|
| 64 |
-
used when comparing fusion against grading-alone on the same images, so
|
| 65 |
-
the baseline's own prediction doesn't need a second pass over the model."""
|
| 66 |
-
import cv2
|
| 67 |
-
|
| 68 |
-
transform = build_transforms(size, train=False)
|
| 69 |
-
image = cv2.cvtColor(cv2.imread(str(image_path), cv2.IMREAD_COLOR), cv2.COLOR_BGR2RGB)
|
| 70 |
-
tensor = transform(image=image)["image"].unsqueeze(0).to(device)
|
| 71 |
-
with torch.no_grad():
|
| 72 |
-
features = model.backbone.forward_features(tensor)
|
| 73 |
-
embedding = model.backbone.forward_head(features, pre_logits=True)
|
| 74 |
-
logits = model.backbone.forward_head(features)
|
| 75 |
-
return embedding.squeeze(0).cpu(), logits.squeeze(0).cpu()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/fusion/features.py
DELETED
|
@@ -1,260 +0,0 @@
|
|
| 1 |
-
"""Lesion feature extraction: runs Phase 4's models as inference engines over
|
| 2 |
-
images that have no lesion ground truth of their own (APTOS), to build the
|
| 3 |
-
per-image feature vector Phase 5's fusion head concats with the grading
|
| 4 |
-
model's CNN embedding.
|
| 5 |
-
|
| 6 |
-
**Cross-dataset domain shift, faced honestly, not hidden**: every model used
|
| 7 |
-
here was trained and validated exclusively on IDRiD (2848x4288, one capture
|
| 8 |
-
device/protocol). APTOS images range from 1050x1050 to 3216x2136 across
|
| 9 |
-
several different capture devices, with no resolution normalisation applied
|
| 10 |
-
before inference -- a cheap sanity check (the hard-exudate model against 4
|
| 11 |
-
images per DR grade) showed mean predicted probability rising with grade
|
| 12 |
-
even so (grade 0: 0.0000-0.0025 positive-pixel fraction at threshold 0.5;
|
| 13 |
-
grade 3: 0.023-0.114), which is why this was judged worth building rather
|
| 14 |
-
than assumed broken -- but it was never validated against real APTOS lesion
|
| 15 |
-
ground truth (APTOS ships none), so treat these as a genuine, undemonstrated
|
| 16 |
-
hypothesis under the fusion head's own McNemar test, not a calibrated
|
| 17 |
-
measurement the way IDRiD-evaluated numbers elsewhere in this project are.
|
| 18 |
-
|
| 19 |
-
IDRiD's own grading images ("B. Disease Grading") are byte-identical to its
|
| 20 |
-
localisation images ("C. Localization") -- real OD/fovea/lesion ground truth
|
| 21 |
-
would be available there. Deliberately not used: docs/04_ROADMAP.md Phase 8
|
| 22 |
-
reserves IDRiD (with Messidor-2) as the locked, evaluate-once final test
|
| 23 |
-
set, and using it to build or tune the fusion head now would contaminate it.
|
| 24 |
-
"""
|
| 25 |
-
|
| 26 |
-
from __future__ import annotations
|
| 27 |
-
|
| 28 |
-
from dataclasses import dataclass
|
| 29 |
-
from pathlib import Path
|
| 30 |
-
|
| 31 |
-
import cv2
|
| 32 |
-
import numpy as np
|
| 33 |
-
import torch
|
| 34 |
-
|
| 35 |
-
from drdetect.segmentation.localization import (
|
| 36 |
-
LocalizationModule,
|
| 37 |
-
heatmap_argmax,
|
| 38 |
-
working_res_od_diameter,
|
| 39 |
-
)
|
| 40 |
-
from drdetect.segmentation.microaneurysms import ClassifierModule, PatchClassifier, score_candidates
|
| 41 |
-
from drdetect.segmentation.model import build_segmentation_model
|
| 42 |
-
from drdetect.segmentation.module import SegmentationModule
|
| 43 |
-
from drdetect.segmentation.quadrants import assign_quadrant
|
| 44 |
-
|
| 45 |
-
__all__ = [
|
| 46 |
-
"LesionModels",
|
| 47 |
-
"load_lesion_models",
|
| 48 |
-
"extract_lesion_features",
|
| 49 |
-
"lesion_centroids",
|
| 50 |
-
"FEATURE_NAMES",
|
| 51 |
-
"QUADRANT_ORDER",
|
| 52 |
-
]
|
| 53 |
-
|
| 54 |
-
LOCALIZATION_SIZE = (512, 768) # (height, width), matches training
|
| 55 |
-
SEGMENTATION_PATCH = 512
|
| 56 |
-
MA_TUNED_THRESHOLD = 0.95 # docs/15's val-tuned operating point, frozen there
|
| 57 |
-
|
| 58 |
-
_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
| 59 |
-
_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
| 60 |
-
|
| 61 |
-
QUADRANT_ORDER = [
|
| 62 |
-
"superior-foveal-side",
|
| 63 |
-
"inferior-foveal-side",
|
| 64 |
-
"superior-disc-side",
|
| 65 |
-
"inferior-disc-side",
|
| 66 |
-
]
|
| 67 |
-
FEATURE_NAMES = [
|
| 68 |
-
"hard_exudate_mean_prob",
|
| 69 |
-
"soft_exudate_mean_prob",
|
| 70 |
-
"haemorrhage_mean_prob",
|
| 71 |
-
"microaneurysm_count",
|
| 72 |
-
"distance_to_fovea_mean",
|
| 73 |
-
*[f"quadrant_frac_{q}" for q in QUADRANT_ORDER],
|
| 74 |
-
]
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
@dataclass
|
| 78 |
-
class LesionModels:
|
| 79 |
-
localization: LocalizationModule
|
| 80 |
-
hard_exudates: SegmentationModule
|
| 81 |
-
soft_exudates: SegmentationModule
|
| 82 |
-
haemorrhages: SegmentationModule
|
| 83 |
-
microaneurysm_classifier: ClassifierModule
|
| 84 |
-
device: torch.device
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
def _load_segmentation(checkpoint: str, device: torch.device) -> SegmentationModule:
|
| 88 |
-
model = build_segmentation_model("resnet34", pretrained=False, classes=1)
|
| 89 |
-
module = SegmentationModule.load_from_checkpoint(checkpoint, model=model, map_location=device)
|
| 90 |
-
return module.eval().to(device)
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def load_lesion_models(
|
| 94 |
-
*,
|
| 95 |
-
localization_checkpoint: str,
|
| 96 |
-
hard_exudate_checkpoint: str,
|
| 97 |
-
soft_exudate_checkpoint: str,
|
| 98 |
-
haemorrhage_checkpoint: str,
|
| 99 |
-
microaneurysm_checkpoint: str,
|
| 100 |
-
device: torch.device,
|
| 101 |
-
) -> LesionModels:
|
| 102 |
-
loc_model = build_segmentation_model("resnet34", pretrained=False, classes=2)
|
| 103 |
-
localization = (
|
| 104 |
-
LocalizationModule.load_from_checkpoint(
|
| 105 |
-
localization_checkpoint, model=loc_model, map_location=device
|
| 106 |
-
)
|
| 107 |
-
.eval()
|
| 108 |
-
.to(device)
|
| 109 |
-
)
|
| 110 |
-
|
| 111 |
-
ma_model = PatchClassifier()
|
| 112 |
-
microaneurysm_classifier = (
|
| 113 |
-
ClassifierModule.load_from_checkpoint(
|
| 114 |
-
microaneurysm_checkpoint, model=ma_model, map_location=device
|
| 115 |
-
)
|
| 116 |
-
.eval()
|
| 117 |
-
.to(device)
|
| 118 |
-
)
|
| 119 |
-
|
| 120 |
-
return LesionModels(
|
| 121 |
-
localization=localization,
|
| 122 |
-
hard_exudates=_load_segmentation(hard_exudate_checkpoint, device),
|
| 123 |
-
soft_exudates=_load_segmentation(soft_exudate_checkpoint, device),
|
| 124 |
-
haemorrhages=_load_segmentation(haemorrhage_checkpoint, device),
|
| 125 |
-
microaneurysm_classifier=microaneurysm_classifier,
|
| 126 |
-
device=device,
|
| 127 |
-
)
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
def _predict_od_fovea(
|
| 131 |
-
image_rgb: np.ndarray, models: LesionModels
|
| 132 |
-
) -> tuple[tuple[float, float], tuple[float, float], float]:
|
| 133 |
-
"""Returns (od_xy, fovea_xy, od_diameter), all in LOCALIZATION_SIZE's
|
| 134 |
-
resized pixel space (not the input image's own resolution)."""
|
| 135 |
-
resized = cv2.resize(image_rgb, (LOCALIZATION_SIZE[1], LOCALIZATION_SIZE[0]))
|
| 136 |
-
rgb = (resized.astype(np.float32) / 255.0 - _MEAN) / _STD
|
| 137 |
-
tensor = torch.from_numpy(rgb.transpose(2, 0, 1)).float().unsqueeze(0).to(models.device)
|
| 138 |
-
with torch.no_grad():
|
| 139 |
-
logits = models.localization(tensor)
|
| 140 |
-
probs = torch.sigmoid(logits).squeeze(0).cpu().numpy()
|
| 141 |
-
od_xy = heatmap_argmax(probs[0])
|
| 142 |
-
fovea_xy = heatmap_argmax(probs[1])
|
| 143 |
-
diameter = working_res_od_diameter(LOCALIZATION_SIZE)
|
| 144 |
-
return od_xy, fovea_xy, diameter
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
def _tile_predict(
|
| 148 |
-
module: SegmentationModule, image_rgb: np.ndarray, device: torch.device
|
| 149 |
-
) -> np.ndarray:
|
| 150 |
-
"""Same non-overlapping reflect-padded tiling as
|
| 151 |
-
scripts/evaluate_segmentation.py's tile_predict, inlined here rather than
|
| 152 |
-
imported from a script module so library code doesn't depend on scripts/."""
|
| 153 |
-
patch_size = SEGMENTATION_PATCH
|
| 154 |
-
h, w = image_rgb.shape[:2]
|
| 155 |
-
ph = (patch_size - h % patch_size) % patch_size
|
| 156 |
-
pw = (patch_size - w % patch_size) % patch_size
|
| 157 |
-
padded = cv2.copyMakeBorder(image_rgb, 0, ph, 0, pw, cv2.BORDER_REFLECT_101)
|
| 158 |
-
ph_full, pw_full = padded.shape[:2]
|
| 159 |
-
|
| 160 |
-
prob_map = np.zeros((ph_full, pw_full), dtype=np.float32)
|
| 161 |
-
tiles, positions = [], []
|
| 162 |
-
for top in range(0, ph_full, patch_size):
|
| 163 |
-
for left in range(0, pw_full, patch_size):
|
| 164 |
-
tiles.append(padded[top : top + patch_size, left : left + patch_size])
|
| 165 |
-
positions.append((top, left))
|
| 166 |
-
|
| 167 |
-
batch_size = 4
|
| 168 |
-
with torch.no_grad():
|
| 169 |
-
for start in range(0, len(tiles), batch_size):
|
| 170 |
-
chunk = tiles[start : start + batch_size]
|
| 171 |
-
arrs = [
|
| 172 |
-
((t.astype(np.float32) / 255.0 - _MEAN) / _STD).transpose(2, 0, 1) for t in chunk
|
| 173 |
-
]
|
| 174 |
-
batch = torch.from_numpy(np.stack(arrs)).float().to(device)
|
| 175 |
-
logits = module(batch)
|
| 176 |
-
probs = torch.sigmoid(logits).squeeze(1).cpu().numpy()
|
| 177 |
-
for (top, left), prob in zip(positions[start : start + batch_size], probs, strict=True):
|
| 178 |
-
prob_map[top : top + patch_size, left : left + patch_size] = prob
|
| 179 |
-
return prob_map[:h, :w]
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
def lesion_centroids(prob_map: np.ndarray, threshold: float = 0.5) -> list[tuple[float, float]]:
|
| 183 |
-
"""Connected-component centroids of a thresholded probability map -- a
|
| 184 |
-
handful of per-lesion centroids is what quadrant/distance features need,
|
| 185 |
-
not per-pixel detail."""
|
| 186 |
-
binary = (prob_map > threshold).astype(np.uint8)
|
| 187 |
-
if binary.sum() == 0:
|
| 188 |
-
return []
|
| 189 |
-
n, _labels, stats, centroids = cv2.connectedComponentsWithStats(binary, connectivity=8)
|
| 190 |
-
return [tuple(centroids[i]) for i in range(1, n) if stats[i, cv2.CC_STAT_AREA] >= 3]
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
def extract_lesion_features(image_path: str | Path, models: LesionModels) -> dict[str, float]:
|
| 194 |
-
"""One image -> the flat feature dict Phase 5's fusion head concats with
|
| 195 |
-
the grading CNN's embedding. Every model runs at its own native working
|
| 196 |
-
convention (full-resolution tiling for the three DeepLabV3+ segmentation
|
| 197 |
-
models, a fixed 512x768 resize for localisation, full-resolution
|
| 198 |
-
candidate generation for microaneurysms) -- see this module's own
|
| 199 |
-
docstring for the cross-dataset resolution caveat that follows from that.
|
| 200 |
-
"""
|
| 201 |
-
image_bgr = cv2.imread(str(image_path), cv2.IMREAD_COLOR)
|
| 202 |
-
image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
|
| 203 |
-
|
| 204 |
-
od_xy, fovea_xy, od_diameter = _predict_od_fovea(image_rgb, models)
|
| 205 |
-
|
| 206 |
-
he_prob = _tile_predict(models.hard_exudates, image_rgb, models.device)
|
| 207 |
-
se_prob = _tile_predict(models.soft_exudates, image_rgb, models.device)
|
| 208 |
-
ha_prob = _tile_predict(models.haemorrhages, image_rgb, models.device)
|
| 209 |
-
|
| 210 |
-
# Segmentation ran at the image's OWN resolution; localisation predicts in
|
| 211 |
-
# LOCALIZATION_SIZE's resized space. Rescale lesion centroids into that
|
| 212 |
-
# same space before computing distance/quadrant features, since od_xy/
|
| 213 |
-
# fovea_xy live there.
|
| 214 |
-
scale_x = LOCALIZATION_SIZE[1] / image_rgb.shape[1]
|
| 215 |
-
scale_y = LOCALIZATION_SIZE[0] / image_rgb.shape[0]
|
| 216 |
-
|
| 217 |
-
all_centroids: list[tuple[float, float]] = []
|
| 218 |
-
for prob_map in (he_prob, se_prob, ha_prob):
|
| 219 |
-
for cx, cy in lesion_centroids(prob_map):
|
| 220 |
-
all_centroids.append((cx * scale_x, cy * scale_y))
|
| 221 |
-
|
| 222 |
-
# score_candidates expects a ground-truth mask (it's normally used for
|
| 223 |
-
# scored evaluation, see evaluate_microaneurysms.py) purely to compute
|
| 224 |
-
# labels/recall bookkeeping this call never reads -- an all-zero dummy
|
| 225 |
-
# mask makes that bookkeeping cheap and correct-by-construction (0
|
| 226 |
-
# recovered of 0 true instances) without a separate inference-only code
|
| 227 |
-
# path for a function that otherwise does exactly what's needed here.
|
| 228 |
-
dummy_mask = np.zeros(image_bgr.shape[:2], dtype=np.uint8)
|
| 229 |
-
ma_result = score_candidates(
|
| 230 |
-
image_bgr, dummy_mask, models.microaneurysm_classifier.model, device=str(models.device)
|
| 231 |
-
)
|
| 232 |
-
accepted = ma_result["scores"] > MA_TUNED_THRESHOLD
|
| 233 |
-
ma_count = int(accepted.sum())
|
| 234 |
-
for c, keep in zip(ma_result["candidates"], accepted, strict=True):
|
| 235 |
-
if keep:
|
| 236 |
-
all_centroids.append((c.cx * scale_x, c.cy * scale_y))
|
| 237 |
-
|
| 238 |
-
if all_centroids:
|
| 239 |
-
distances = [
|
| 240 |
-
float(np.linalg.norm(np.array(c) - np.array(fovea_xy))) / od_diameter
|
| 241 |
-
for c in all_centroids
|
| 242 |
-
]
|
| 243 |
-
distance_to_fovea_mean = float(np.mean(distances))
|
| 244 |
-
quadrant_counts = dict.fromkeys(QUADRANT_ORDER, 0)
|
| 245 |
-
for c in all_centroids:
|
| 246 |
-
quadrant_counts[assign_quadrant(c, od_xy, fovea_xy)] += 1
|
| 247 |
-
total = len(all_centroids)
|
| 248 |
-
quadrant_fracs = {q: quadrant_counts[q] / total for q in QUADRANT_ORDER}
|
| 249 |
-
else:
|
| 250 |
-
distance_to_fovea_mean = float("nan")
|
| 251 |
-
quadrant_fracs = dict.fromkeys(QUADRANT_ORDER, 0.0)
|
| 252 |
-
|
| 253 |
-
return {
|
| 254 |
-
"hard_exudate_mean_prob": float(he_prob.mean()),
|
| 255 |
-
"soft_exudate_mean_prob": float(se_prob.mean()),
|
| 256 |
-
"haemorrhage_mean_prob": float(ha_prob.mean()),
|
| 257 |
-
"microaneurysm_count": float(ma_count),
|
| 258 |
-
"distance_to_fovea_mean": distance_to_fovea_mean,
|
| 259 |
-
**{f"quadrant_frac_{q}": quadrant_fracs[q] for q in QUADRANT_ORDER},
|
| 260 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/fusion/model.py
DELETED
|
@@ -1,35 +0,0 @@
|
|
| 1 |
-
"""The fusion head itself: concat(CNN embedding, lesion features) -> ordinal head.
|
| 2 |
-
|
| 3 |
-
A small MLP, not another CNN -- the input is already a fixed-length vector
|
| 4 |
-
(the grading backbone's pooled embedding plus a handful of scalar lesion
|
| 5 |
-
features), not an image needing spatial processing.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
from __future__ import annotations
|
| 9 |
-
|
| 10 |
-
import torch
|
| 11 |
-
import torch.nn as nn
|
| 12 |
-
|
| 13 |
-
__all__ = ["FusionHead"]
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
class FusionHead(nn.Module):
|
| 17 |
-
def __init__(
|
| 18 |
-
self,
|
| 19 |
-
*,
|
| 20 |
-
embedding_dim: int,
|
| 21 |
-
lesion_dim: int,
|
| 22 |
-
hidden_dim: int = 128,
|
| 23 |
-
num_outputs: int = 4,
|
| 24 |
-
dropout: float = 0.3,
|
| 25 |
-
):
|
| 26 |
-
super().__init__()
|
| 27 |
-
self.net = nn.Sequential(
|
| 28 |
-
nn.Linear(embedding_dim + lesion_dim, hidden_dim),
|
| 29 |
-
nn.ReLU(inplace=True),
|
| 30 |
-
nn.Dropout(dropout),
|
| 31 |
-
nn.Linear(hidden_dim, num_outputs),
|
| 32 |
-
)
|
| 33 |
-
|
| 34 |
-
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 35 |
-
return self.net(x)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/grading/__init__.py
DELETED
|
File without changes
|
src/src/drdetect/grading/losses.py
DELETED
|
@@ -1,330 +0,0 @@
|
|
| 1 |
-
"""Ordinal losses for DR grading.
|
| 2 |
-
|
| 3 |
-
Why cross-entropy is the wrong loss here
|
| 4 |
-
----------------------------------------
|
| 5 |
-
DR grades are ordered: 0 < 1 < 2 < 3 < 4. Cross-entropy treats them as
|
| 6 |
-
unordered labels, so predicting 4 for a true grade 1 costs exactly as much as
|
| 7 |
-
predicting 2. The loss-minimising strategy under that objective is to hedge
|
| 8 |
-
toward the majority middle class.
|
| 9 |
-
|
| 10 |
-
The Phase 1 baseline did precisely that. From its confusion matrix, errors
|
| 11 |
-
collapsed onto grade 2: 29 of 74 grade-1 cases, 21 of 39 grade-3, and 22 of 59
|
| 12 |
-
grade-4 were all predicted Moderate. See docs/06_PHASE1_RESULTS.md.
|
| 13 |
-
|
| 14 |
-
Three alternatives are provided.
|
| 15 |
-
|
| 16 |
-
`CornLoss` -- rank-consistent ordinal regression (Shi, Cao & Raschka, 2023).
|
| 17 |
-
The model emits K-1 logits; logit j models P(y > j | y > j-1). Because the
|
| 18 |
-
probabilities chain multiplicatively, predictions are rank-monotonic *by
|
| 19 |
-
construction*: the model cannot output "probably worse than grade 2 but not
|
| 20 |
-
worse than grade 1". Each task trains only on the conditional subset, which
|
| 21 |
-
is what distinguishes CORN from the earlier CORAL and removes its shared-bias
|
| 22 |
-
restriction.
|
| 23 |
-
|
| 24 |
-
`OrdinalRegressionLoss` -- single continuous output, thresholds fitted after
|
| 25 |
-
training. This is the approach behind most winning Kaggle DR solutions. It
|
| 26 |
-
optimises QWK almost directly, at the cost of a post-hoc threshold search.
|
| 27 |
-
|
| 28 |
-
`DistanceWeightedCE` -- ordinary cross-entropy with soft targets whose mass
|
| 29 |
-
decays with ordinal distance. The least invasive option: it keeps the K-way
|
| 30 |
-
head and only changes the targets.
|
| 31 |
-
"""
|
| 32 |
-
|
| 33 |
-
from __future__ import annotations
|
| 34 |
-
|
| 35 |
-
import numpy as np
|
| 36 |
-
import torch
|
| 37 |
-
import torch.nn as nn
|
| 38 |
-
import torch.nn.functional as F
|
| 39 |
-
|
| 40 |
-
__all__ = [
|
| 41 |
-
"CornLoss",
|
| 42 |
-
"decode_output",
|
| 43 |
-
"naive_referable_cut",
|
| 44 |
-
"OrdinalRegressionLoss",
|
| 45 |
-
"DistanceWeightedCE",
|
| 46 |
-
"corn_probabilities",
|
| 47 |
-
"corn_predict",
|
| 48 |
-
"corn_task_pos_weights",
|
| 49 |
-
"regression_predict",
|
| 50 |
-
"fit_thresholds",
|
| 51 |
-
"build_loss",
|
| 52 |
-
"outputs_for_loss",
|
| 53 |
-
]
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
class CornLoss(nn.Module):
|
| 57 |
-
"""Conditional ordinal regression, rank-consistent by construction.
|
| 58 |
-
|
| 59 |
-
Expects `num_classes - 1` logits. Task j is trained only on samples with
|
| 60 |
-
y >= j, so each binary problem is genuinely conditional.
|
| 61 |
-
"""
|
| 62 |
-
|
| 63 |
-
def __init__(
|
| 64 |
-
self,
|
| 65 |
-
num_classes: int = 5,
|
| 66 |
-
class_weights: torch.Tensor | None = None,
|
| 67 |
-
task_pos_weights: torch.Tensor | None = None,
|
| 68 |
-
):
|
| 69 |
-
super().__init__()
|
| 70 |
-
self.num_classes = num_classes
|
| 71 |
-
self.register_buffer(
|
| 72 |
-
"class_weights",
|
| 73 |
-
class_weights if class_weights is not None else torch.ones(num_classes),
|
| 74 |
-
)
|
| 75 |
-
self.register_buffer(
|
| 76 |
-
"task_pos_weights",
|
| 77 |
-
task_pos_weights if task_pos_weights is not None else torch.ones(num_classes - 1),
|
| 78 |
-
)
|
| 79 |
-
|
| 80 |
-
def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
|
| 81 |
-
if logits.shape[1] != self.num_classes - 1:
|
| 82 |
-
raise ValueError(
|
| 83 |
-
f"CornLoss expects {self.num_classes - 1} logits, got {logits.shape[1]}. "
|
| 84 |
-
f"Build the model with num_outputs={self.num_classes - 1}."
|
| 85 |
-
)
|
| 86 |
-
|
| 87 |
-
losses, total = [], 0.0
|
| 88 |
-
for j in range(self.num_classes - 1):
|
| 89 |
-
subset = targets >= j # conditional on having exceeded j-1
|
| 90 |
-
if not subset.any():
|
| 91 |
-
continue
|
| 92 |
-
binary = (targets[subset] > j).float()
|
| 93 |
-
w = self.class_weights[targets[subset]]
|
| 94 |
-
bce = F.binary_cross_entropy_with_logits(
|
| 95 |
-
logits[subset, j],
|
| 96 |
-
binary,
|
| 97 |
-
weight=w,
|
| 98 |
-
pos_weight=self.task_pos_weights[j],
|
| 99 |
-
reduction="sum",
|
| 100 |
-
)
|
| 101 |
-
losses.append(bce)
|
| 102 |
-
total += float(w.sum())
|
| 103 |
-
|
| 104 |
-
if not losses:
|
| 105 |
-
return logits.sum() * 0.0 # keeps the graph connected
|
| 106 |
-
return torch.stack(losses).sum() / max(total, 1.0)
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
def corn_task_pos_weights(labels, num_classes: int = 5) -> list[float]:
|
| 110 |
-
"""Per-task positive weights that rebalance CORN's conditional subsets.
|
| 111 |
-
|
| 112 |
-
CORN trains task j only on samples with y >= j, asking whether y > j. Those
|
| 113 |
-
subsets inherit the label distribution and are usually NOT balanced. Measured
|
| 114 |
-
on the APTOS training split:
|
| 115 |
-
|
| 116 |
-
task j=0 2929 samples, 50.7% positive balanced
|
| 117 |
-
task j=1 1485 samples, 80.1% positive badly skewed
|
| 118 |
-
task j=2 1189 samples, 32.8% positive skewed
|
| 119 |
-
task j=3 390 samples, 60.5% positive balanced
|
| 120 |
-
|
| 121 |
-
Task j=1 asks "given grade >= 1, is it worse than 1?" and is 80% positive,
|
| 122 |
-
because grade 1 is only 296 of 1485 samples with grade >= 1. Unweighted, the
|
| 123 |
-
loss-minimising answer is almost always yes, which pushes grade-1 cases into
|
| 124 |
-
grade 2. Measured consequence: unweighted CORN sent 49 of 74 grade-1
|
| 125 |
-
validation cases to grade 2, against 29 for plain cross-entropy, and grade-1
|
| 126 |
-
recall fell from 0.541 to 0.297.
|
| 127 |
-
|
| 128 |
-
Returns n_negative / n_positive per task, the standard BCE `pos_weight`,
|
| 129 |
-
which makes each conditional task cost-balanced.
|
| 130 |
-
"""
|
| 131 |
-
y = np.asarray(labels)
|
| 132 |
-
weights = []
|
| 133 |
-
for j in range(num_classes - 1):
|
| 134 |
-
subset = y[y >= j]
|
| 135 |
-
pos = int((subset > j).sum())
|
| 136 |
-
neg = int(len(subset) - pos)
|
| 137 |
-
weights.append(float(neg / pos) if pos > 0 else 1.0)
|
| 138 |
-
return weights
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
def corn_probabilities(logits: torch.Tensor) -> torch.Tensor:
|
| 142 |
-
"""Cumulative probabilities P(y > j) from CORN logits, via the chain rule.
|
| 143 |
-
|
| 144 |
-
The cumulative product is what guarantees monotonicity: P(y>j) can never
|
| 145 |
-
exceed P(y>j-1), because it is that value multiplied by a sigmoid.
|
| 146 |
-
"""
|
| 147 |
-
return torch.cumprod(torch.sigmoid(logits), dim=1)
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
def corn_predict(logits: torch.Tensor) -> torch.Tensor:
|
| 151 |
-
"""Predicted grade = number of thresholds exceeded."""
|
| 152 |
-
return (corn_probabilities(logits) > 0.5).sum(dim=1)
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
def corn_class_probabilities(logits: torch.Tensor) -> torch.Tensor:
|
| 156 |
-
"""Per-class probabilities from cumulative ones, for referable scoring.
|
| 157 |
-
|
| 158 |
-
P(y=k) = P(y>k-1) - P(y>k), with P(y>-1)=1 and P(y>K-1)=0.
|
| 159 |
-
"""
|
| 160 |
-
cum = corn_probabilities(logits)
|
| 161 |
-
ones = torch.ones_like(cum[:, :1])
|
| 162 |
-
zeros = torch.zeros_like(cum[:, :1])
|
| 163 |
-
upper = torch.cat([ones, cum], dim=1)
|
| 164 |
-
lower = torch.cat([cum, zeros], dim=1)
|
| 165 |
-
return (upper - lower).clamp(min=0.0)
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
class OrdinalRegressionLoss(nn.Module):
|
| 169 |
-
"""Single-output regression on the grade, with SmoothL1.
|
| 170 |
-
|
| 171 |
-
SmoothL1 rather than MSE: MSE lets a handful of grade-0-predicted-as-4
|
| 172 |
-
outliers dominate the gradient, which at batch 4 is destabilising.
|
| 173 |
-
"""
|
| 174 |
-
|
| 175 |
-
def __init__(self, beta: float = 1.0):
|
| 176 |
-
super().__init__()
|
| 177 |
-
self.beta = beta
|
| 178 |
-
|
| 179 |
-
def forward(self, output: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
|
| 180 |
-
return F.smooth_l1_loss(output.squeeze(-1), targets.float(), beta=self.beta)
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
def regression_predict(output: torch.Tensor, thresholds: list[float] | None = None) -> torch.Tensor:
|
| 184 |
-
"""Discretise a continuous prediction using cut-points."""
|
| 185 |
-
if thresholds is None:
|
| 186 |
-
thresholds = [0.5, 1.5, 2.5, 3.5]
|
| 187 |
-
x = output.squeeze(-1)
|
| 188 |
-
pred = torch.zeros_like(x, dtype=torch.long)
|
| 189 |
-
for t in thresholds:
|
| 190 |
-
pred += (x > t).long()
|
| 191 |
-
return pred
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
def fit_thresholds(predictions, targets, *, n_classes: int = 5, n_rounds: int = 8) -> list[float]:
|
| 195 |
-
"""Coordinate-ascent search for cut-points maximising QWK.
|
| 196 |
-
|
| 197 |
-
**Fit on validation, never on test.** The returned thresholds are part of the
|
| 198 |
-
model and must be frozen alongside the weights.
|
| 199 |
-
"""
|
| 200 |
-
from drdetect.eval.metrics import quadratic_weighted_kappa
|
| 201 |
-
|
| 202 |
-
preds = np.asarray(predictions, dtype=float).ravel()
|
| 203 |
-
targs = np.asarray(targets, dtype=int).ravel()
|
| 204 |
-
thresholds = [i + 0.5 for i in range(n_classes - 1)]
|
| 205 |
-
|
| 206 |
-
def score(th: list[float]) -> float:
|
| 207 |
-
digitised = np.zeros_like(preds, dtype=int)
|
| 208 |
-
for t in th:
|
| 209 |
-
digitised += (preds > t).astype(int)
|
| 210 |
-
return quadratic_weighted_kappa(targs, digitised, n_classes=n_classes)
|
| 211 |
-
|
| 212 |
-
best = score(thresholds)
|
| 213 |
-
for _ in range(n_rounds):
|
| 214 |
-
improved = False
|
| 215 |
-
for i in range(len(thresholds)):
|
| 216 |
-
lo = thresholds[i - 1] + 0.05 if i > 0 else preds.min() - 0.5
|
| 217 |
-
hi = thresholds[i + 1] - 0.05 if i < len(thresholds) - 1 else preds.max() + 0.5
|
| 218 |
-
if hi <= lo:
|
| 219 |
-
continue
|
| 220 |
-
for candidate in np.linspace(lo, hi, 40):
|
| 221 |
-
trial = list(thresholds)
|
| 222 |
-
trial[i] = float(candidate)
|
| 223 |
-
s = score(trial)
|
| 224 |
-
if s > best:
|
| 225 |
-
best, thresholds, improved = s, trial, True
|
| 226 |
-
if not improved:
|
| 227 |
-
break
|
| 228 |
-
return thresholds
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
class DistanceWeightedCE(nn.Module):
|
| 232 |
-
"""Cross-entropy against soft targets that decay with ordinal distance.
|
| 233 |
-
|
| 234 |
-
Target mass for class k given true grade y is proportional to
|
| 235 |
-
exp(-|k - y| / temperature), so predicting an adjacent grade is penalised
|
| 236 |
-
far less than predicting a distant one -- the property plain CE lacks.
|
| 237 |
-
"""
|
| 238 |
-
|
| 239 |
-
def __init__(
|
| 240 |
-
self,
|
| 241 |
-
num_classes: int = 5,
|
| 242 |
-
temperature: float = 0.7,
|
| 243 |
-
class_weights: torch.Tensor | None = None,
|
| 244 |
-
):
|
| 245 |
-
super().__init__()
|
| 246 |
-
self.num_classes = num_classes
|
| 247 |
-
self.temperature = temperature
|
| 248 |
-
grid = torch.arange(num_classes).float()
|
| 249 |
-
soft = torch.exp(-(grid[None, :] - grid[:, None]).abs() / temperature)
|
| 250 |
-
self.register_buffer("soft_targets", soft / soft.sum(dim=1, keepdim=True))
|
| 251 |
-
self.register_buffer(
|
| 252 |
-
"class_weights",
|
| 253 |
-
class_weights if class_weights is not None else torch.ones(num_classes),
|
| 254 |
-
)
|
| 255 |
-
|
| 256 |
-
def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
|
| 257 |
-
soft = self.soft_targets[targets]
|
| 258 |
-
per_sample = -(soft * F.log_softmax(logits, dim=1)).sum(dim=1)
|
| 259 |
-
w = self.class_weights[targets]
|
| 260 |
-
return (per_sample * w).sum() / w.sum().clamp(min=1e-8)
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
def outputs_for_loss(loss_name: str, num_classes: int = 5) -> int:
|
| 264 |
-
"""How many head outputs a given loss requires."""
|
| 265 |
-
return {
|
| 266 |
-
"ce": num_classes,
|
| 267 |
-
"distance_ce": num_classes,
|
| 268 |
-
"corn": num_classes - 1,
|
| 269 |
-
"regression": 1,
|
| 270 |
-
}[loss_name]
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
def build_loss(
|
| 274 |
-
name: str,
|
| 275 |
-
*,
|
| 276 |
-
num_classes: int = 5,
|
| 277 |
-
class_weights: list[float] | None = None,
|
| 278 |
-
task_pos_weights: list[float] | None = None,
|
| 279 |
-
):
|
| 280 |
-
w = torch.tensor(class_weights, dtype=torch.float) if class_weights else None
|
| 281 |
-
if name == "ce":
|
| 282 |
-
return nn.CrossEntropyLoss(weight=w)
|
| 283 |
-
if name == "corn":
|
| 284 |
-
tw = (
|
| 285 |
-
torch.tensor(task_pos_weights, dtype=torch.float)
|
| 286 |
-
if task_pos_weights is not None
|
| 287 |
-
else None
|
| 288 |
-
)
|
| 289 |
-
return CornLoss(num_classes, class_weights=w, task_pos_weights=tw)
|
| 290 |
-
if name == "regression":
|
| 291 |
-
return OrdinalRegressionLoss()
|
| 292 |
-
if name == "distance_ce":
|
| 293 |
-
return DistanceWeightedCE(num_classes, class_weights=w)
|
| 294 |
-
raise ValueError(f"unknown loss {name!r}; expected ce, corn, regression or distance_ce")
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
def decode_output(output: torch.Tensor, loss_name: str) -> tuple[np.ndarray, np.ndarray]:
|
| 298 |
-
"""Map a raw head output to (predicted grade, referable score).
|
| 299 |
-
|
| 300 |
-
Single source of truth for decoding, shared by the training module and the
|
| 301 |
-
evaluation script. Duplicating this logic once caused an evaluation to load a
|
| 302 |
-
5-output head against a 4-output CORN checkpoint; keeping one implementation
|
| 303 |
-
makes that class of mismatch impossible.
|
| 304 |
-
|
| 305 |
-
Each loss parameterises the head differently:
|
| 306 |
-
ce / distance_ce -- K-way softmax; referable = sum of P(class >= 2)
|
| 307 |
-
corn -- K-1 chained sigmoids; referable = P(y > 1) directly
|
| 308 |
-
regression -- one continuous value, which itself ranks severity
|
| 309 |
-
|
| 310 |
-
The referable score need only be monotone in severity; threshold selection
|
| 311 |
-
handles the scale.
|
| 312 |
-
"""
|
| 313 |
-
if loss_name == "corn":
|
| 314 |
-
cum = corn_probabilities(output) # P(y > j), monotone by construction
|
| 315 |
-
return (cum > 0.5).sum(dim=1).numpy(), cum[:, 1].numpy()
|
| 316 |
-
|
| 317 |
-
if loss_name == "regression":
|
| 318 |
-
return regression_predict(output).numpy(), output.squeeze(-1).numpy()
|
| 319 |
-
|
| 320 |
-
probs = torch.softmax(output, dim=1).numpy()
|
| 321 |
-
return probs.argmax(axis=1), probs[:, 2:].sum(axis=1)
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
def naive_referable_cut(loss_name: str) -> float:
|
| 325 |
-
"""The 'no calibration' cut point, whose scale depends on the head.
|
| 326 |
-
|
| 327 |
-
Only a progress signal during training -- the reported operating point is
|
| 328 |
-
chosen for target sensitivity in scripts/evaluate.py.
|
| 329 |
-
"""
|
| 330 |
-
return 1.5 if loss_name == "regression" else 0.5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/grading/model.py
DELETED
|
@@ -1,92 +0,0 @@
|
|
| 1 |
-
"""DR grading backbones.
|
| 2 |
-
|
| 3 |
-
Two decisions here come from measurements rather than convention.
|
| 4 |
-
|
| 5 |
-
Frozen BatchNorm
|
| 6 |
-
----------------
|
| 7 |
-
Benchmarking on this project's target machine showed batch size is capped at 4
|
| 8 |
-
by available RAM (docs/05_PROTOTYPE_SCOPE.md section 6). EfficientNet uses
|
| 9 |
-
BatchNorm, whose running statistics are estimated per micro-batch -- at batch 4
|
| 10 |
-
they are noisy, and gradient accumulation does NOT help, because accumulation
|
| 11 |
-
batches the optimiser step, not the normalisation. Freezing BN to use the
|
| 12 |
-
pretrained ImageNet statistics is the standard fix for small-batch fine-tuning
|
| 13 |
-
and is the default here.
|
| 14 |
-
|
| 15 |
-
Ordinal-ready head
|
| 16 |
-
------------------
|
| 17 |
-
Phase 1 is a plain cross-entropy baseline, deliberately. But DR grades are
|
| 18 |
-
ordinal, and Phase 3 replaces the loss. `num_outputs` is therefore separated
|
| 19 |
-
from `num_classes` so the same backbone serves a 5-way softmax, a 1-output
|
| 20 |
-
regression head, or a 4-output CORAL head without rewriting the model.
|
| 21 |
-
"""
|
| 22 |
-
|
| 23 |
-
from __future__ import annotations
|
| 24 |
-
|
| 25 |
-
import torch
|
| 26 |
-
import torch.nn as nn
|
| 27 |
-
|
| 28 |
-
__all__ = ["build_model", "freeze_batchnorm", "count_parameters"]
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def freeze_batchnorm(model: nn.Module) -> int:
|
| 32 |
-
"""Put every BatchNorm into eval mode and stop its affine parameters training.
|
| 33 |
-
|
| 34 |
-
Returns the number of layers frozen, for logging -- a silent no-op here
|
| 35 |
-
would be indistinguishable from a working call.
|
| 36 |
-
"""
|
| 37 |
-
frozen = 0
|
| 38 |
-
for module in model.modules():
|
| 39 |
-
if isinstance(module, nn.modules.batchnorm._BatchNorm):
|
| 40 |
-
module.eval()
|
| 41 |
-
module.weight.requires_grad_(False)
|
| 42 |
-
module.bias.requires_grad_(False)
|
| 43 |
-
frozen += 1
|
| 44 |
-
return frozen
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
class FundusGrader(nn.Module):
|
| 48 |
-
def __init__(self, backbone: nn.Module, freeze_bn: bool = True):
|
| 49 |
-
super().__init__()
|
| 50 |
-
self.backbone = backbone
|
| 51 |
-
self.freeze_bn = freeze_bn
|
| 52 |
-
if freeze_bn:
|
| 53 |
-
self.n_frozen_bn = freeze_batchnorm(self.backbone)
|
| 54 |
-
|
| 55 |
-
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 56 |
-
return self.backbone(x)
|
| 57 |
-
|
| 58 |
-
def train(self, mode: bool = True):
|
| 59 |
-
"""Keep frozen BN in eval mode even when the module is set to train.
|
| 60 |
-
|
| 61 |
-
Without this override, `model.train()` silently re-enables running-
|
| 62 |
-
statistic updates and undoes the freeze -- a bug that produces no error
|
| 63 |
-
and only shows up as unstable validation metrics.
|
| 64 |
-
"""
|
| 65 |
-
super().train(mode)
|
| 66 |
-
if self.freeze_bn:
|
| 67 |
-
for module in self.backbone.modules():
|
| 68 |
-
if isinstance(module, nn.modules.batchnorm._BatchNorm):
|
| 69 |
-
module.eval()
|
| 70 |
-
return self
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
def build_model(
|
| 74 |
-
name: str = "efficientnet_b0",
|
| 75 |
-
*,
|
| 76 |
-
num_outputs: int = 5,
|
| 77 |
-
pretrained: bool = True,
|
| 78 |
-
freeze_bn: bool = True,
|
| 79 |
-
drop_rate: float = 0.2,
|
| 80 |
-
) -> FundusGrader:
|
| 81 |
-
import timm
|
| 82 |
-
|
| 83 |
-
backbone = timm.create_model(
|
| 84 |
-
name, pretrained=pretrained, num_classes=num_outputs, drop_rate=drop_rate
|
| 85 |
-
)
|
| 86 |
-
return FundusGrader(backbone, freeze_bn=freeze_bn)
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def count_parameters(model: nn.Module) -> tuple[int, int]:
|
| 90 |
-
total = sum(p.numel() for p in model.parameters())
|
| 91 |
-
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 92 |
-
return total, trainable
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/grading/module.py
DELETED
|
@@ -1,256 +0,0 @@
|
|
| 1 |
-
"""Lightning module for DR grading.
|
| 2 |
-
|
| 3 |
-
Phase 1 is a plain cross-entropy baseline. It is deliberately un-tuned: its job
|
| 4 |
-
is to produce a number to beat, and every later improvement is measured as a
|
| 5 |
-
delta against it.
|
| 6 |
-
|
| 7 |
-
Per-class recall is logged every epoch, not only aggregate QWK. On APTOS, grade
|
| 8 |
-
3 is 5.3% of the data and grade 1 is 10.1%; a model can post a respectable QWK
|
| 9 |
-
while completely failing those classes, and aggregate metrics will not say so.
|
| 10 |
-
"""
|
| 11 |
-
|
| 12 |
-
from __future__ import annotations
|
| 13 |
-
|
| 14 |
-
import lightning as L
|
| 15 |
-
import numpy as np
|
| 16 |
-
import torch
|
| 17 |
-
import torch.nn as nn
|
| 18 |
-
|
| 19 |
-
from drdetect.eval.metrics import (
|
| 20 |
-
binary_scores,
|
| 21 |
-
quadratic_weighted_kappa,
|
| 22 |
-
referable_labels,
|
| 23 |
-
sensitivity_at_specificity_floor,
|
| 24 |
-
youden_j,
|
| 25 |
-
)
|
| 26 |
-
from drdetect.grading.losses import build_loss, decode_output, naive_referable_cut
|
| 27 |
-
|
| 28 |
-
__all__ = ["GradingModule"]
|
| 29 |
-
|
| 30 |
-
CLASS_NAMES = ["No DR", "Mild", "Moderate", "Severe", "PDR"]
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
class GradingModule(L.LightningModule):
|
| 34 |
-
def __init__(
|
| 35 |
-
self,
|
| 36 |
-
model: nn.Module,
|
| 37 |
-
*,
|
| 38 |
-
lr: float = 1e-4,
|
| 39 |
-
weight_decay: float = 1e-4,
|
| 40 |
-
num_classes: int = 5,
|
| 41 |
-
loss_name: str = "ce",
|
| 42 |
-
class_weights: list[float] | None = None,
|
| 43 |
-
task_pos_weights: list[float] | None = None,
|
| 44 |
-
warmup_epochs: int = 3,
|
| 45 |
-
max_epochs: int = 40,
|
| 46 |
-
spec_floor: float = 0.85,
|
| 47 |
-
):
|
| 48 |
-
super().__init__()
|
| 49 |
-
# `model` is a live module; saving it into the checkpoint hyperparameters
|
| 50 |
-
# would serialise the whole network twice.
|
| 51 |
-
self.save_hyperparameters(ignore=["model"])
|
| 52 |
-
self.model = model
|
| 53 |
-
self.num_classes = num_classes
|
| 54 |
-
self.loss_name = loss_name
|
| 55 |
-
self.criterion = build_loss(
|
| 56 |
-
loss_name,
|
| 57 |
-
num_classes=num_classes,
|
| 58 |
-
class_weights=class_weights,
|
| 59 |
-
task_pos_weights=task_pos_weights,
|
| 60 |
-
)
|
| 61 |
-
|
| 62 |
-
self._val_logits: list[torch.Tensor] = []
|
| 63 |
-
self._val_targets: list[torch.Tensor] = []
|
| 64 |
-
self._first_epoch_loss: float | None = None
|
| 65 |
-
self._grad_norms: list[float] = []
|
| 66 |
-
|
| 67 |
-
@property
|
| 68 |
-
def _epoch(self) -> int:
|
| 69 |
-
"""current_epoch without requiring an attached trainer.
|
| 70 |
-
|
| 71 |
-
Used only in error messages. A guard that raises AttributeError while
|
| 72 |
-
constructing its own diagnostic is worse than no guard.
|
| 73 |
-
"""
|
| 74 |
-
try:
|
| 75 |
-
return int(self.current_epoch)
|
| 76 |
-
except (AttributeError, RuntimeError):
|
| 77 |
-
return -1
|
| 78 |
-
|
| 79 |
-
def forward(self, x):
|
| 80 |
-
return self.model(x)
|
| 81 |
-
|
| 82 |
-
def training_step(self, batch, batch_idx):
|
| 83 |
-
x, y = batch
|
| 84 |
-
loss = self.criterion(self(x), y)
|
| 85 |
-
if not torch.isfinite(loss):
|
| 86 |
-
raise RuntimeError(
|
| 87 |
-
f"Training loss is {loss.item()} at epoch {self._epoch}, step "
|
| 88 |
-
f"{batch_idx}. Diverged -- lower --lr or check the data pipeline."
|
| 89 |
-
)
|
| 90 |
-
self.log("train/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
|
| 91 |
-
return loss
|
| 92 |
-
|
| 93 |
-
def on_train_epoch_end(self):
|
| 94 |
-
"""Abort a diverged run immediately rather than after hours.
|
| 95 |
-
|
| 96 |
-
Small-batch training (batch 4, forced here by available RAM) produces
|
| 97 |
-
noisy gradients; with too high a learning rate the loss can explode by
|
| 98 |
-
two orders of magnitude in a single epoch. Measured on this project:
|
| 99 |
-
train loss went 0.786 -> 239.35 when warmup ended at lr 3e-4, and the
|
| 100 |
-
model collapsed to predicting grade 0 for everything. Detecting that in
|
| 101 |
-
one epoch instead of forty is the difference between five minutes and
|
| 102 |
-
two and a half hours.
|
| 103 |
-
"""
|
| 104 |
-
if self._grad_norms:
|
| 105 |
-
import numpy as _np
|
| 106 |
-
|
| 107 |
-
arr = _np.asarray(self._grad_norms)
|
| 108 |
-
clip = getattr(self.trainer, "gradient_clip_val", None) or 0.0
|
| 109 |
-
self.log("train/grad_norm", float(arr.mean()))
|
| 110 |
-
self.log("train/grad_norm_median", float(_np.median(arr)))
|
| 111 |
-
if clip:
|
| 112 |
-
self.log("train/grad_clipped_frac", float((arr > clip).mean()))
|
| 113 |
-
self._grad_norms.clear()
|
| 114 |
-
|
| 115 |
-
loss = self.trainer.callback_metrics.get("train/loss")
|
| 116 |
-
if loss is None:
|
| 117 |
-
return
|
| 118 |
-
loss = float(loss)
|
| 119 |
-
if self._first_epoch_loss is None:
|
| 120 |
-
self._first_epoch_loss = loss
|
| 121 |
-
return
|
| 122 |
-
if loss > max(10.0 * self._first_epoch_loss, 5.0):
|
| 123 |
-
raise RuntimeError(
|
| 124 |
-
f"Training diverged: loss {loss:.2f} at epoch {self._epoch} vs "
|
| 125 |
-
f"{self._first_epoch_loss:.3f} at epoch 0.\n"
|
| 126 |
-
f"With batch size 4 this usually means the learning rate is too high. "
|
| 127 |
-
f"Try --lr 5e-5, and confirm gradient clipping is enabled."
|
| 128 |
-
)
|
| 129 |
-
|
| 130 |
-
def on_before_optimizer_step(self, optimizer):
|
| 131 |
-
"""Collect the PRE-clip gradient norm; logging happens at epoch end.
|
| 132 |
-
|
| 133 |
-
This hook fires before configure_gradient_clipping, so it sees the
|
| 134 |
-
unclipped gradient. It is the direct instrument for the epoch-3 question:
|
| 135 |
-
clipping acts on the accumulation MEAN, whose norm is smaller than a
|
| 136 |
-
4-sample gradient's, so clip 1.0 binds less often at higher accumulation.
|
| 137 |
-
Without it, "the clip bound less" and "the noise was lower" are
|
| 138 |
-
indistinguishable in the logs.
|
| 139 |
-
|
| 140 |
-
The values are BUFFERED rather than logged here. Logging new metric keys
|
| 141 |
-
mid-epoch makes CSVLogger flush rows, discover keys it has not seen, and
|
| 142 |
-
then fail in _rewrite_with_new_header with "dict contains fields not in
|
| 143 |
-
fieldnames" -- which killed two runs after one epoch. Emitting them from
|
| 144 |
-
on_train_epoch_end keeps every train/* key on the same cadence.
|
| 145 |
-
"""
|
| 146 |
-
from lightning.pytorch.utilities import grad_norm
|
| 147 |
-
|
| 148 |
-
total = grad_norm(self, norm_type=2).get("grad_2.0_norm_total")
|
| 149 |
-
if total is not None:
|
| 150 |
-
self._grad_norms.append(float(total))
|
| 151 |
-
|
| 152 |
-
def validation_step(self, batch, batch_idx):
|
| 153 |
-
x, y = batch
|
| 154 |
-
logits = self(x)
|
| 155 |
-
loss = self.criterion(logits, y)
|
| 156 |
-
self.log("val/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
|
| 157 |
-
self._val_logits.append(logits.detach().float().cpu())
|
| 158 |
-
self._val_targets.append(y.detach().cpu())
|
| 159 |
-
|
| 160 |
-
def decode(self, output: torch.Tensor) -> tuple[np.ndarray, np.ndarray]:
|
| 161 |
-
"""Delegate to the shared decoder so training and evaluation cannot drift."""
|
| 162 |
-
return decode_output(output, self.loss_name)
|
| 163 |
-
|
| 164 |
-
def on_validation_epoch_end(self):
|
| 165 |
-
if not self._val_logits:
|
| 166 |
-
return
|
| 167 |
-
|
| 168 |
-
logits = torch.cat(self._val_logits)
|
| 169 |
-
targets = torch.cat(self._val_targets).numpy()
|
| 170 |
-
preds, p_ref = self.decode(logits)
|
| 171 |
-
|
| 172 |
-
self.log("val/qwk", quadratic_weighted_kappa(targets, preds), prog_bar=True)
|
| 173 |
-
self.log("val/acc", float((preds == targets).mean()))
|
| 174 |
-
|
| 175 |
-
# Referable DR (grade >= 2) at the default 0.5 operating point. The
|
| 176 |
-
# calibrated, sensitivity-targeted threshold is chosen in Phase 5 -- this
|
| 177 |
-
# is only a progress signal, not the reported operating point.
|
| 178 |
-
y_ref = referable_labels(targets)
|
| 179 |
-
# 0.5 is a naive cut and is only a progress signal; the reported
|
| 180 |
-
# operating point is chosen for target sensitivity in scripts/evaluate.py.
|
| 181 |
-
cut = naive_referable_cut(self.loss_name)
|
| 182 |
-
scores = binary_scores(y_ref, (p_ref >= cut).astype(int))
|
| 183 |
-
self.log("val/sensitivity_referable", scores.sensitivity, prog_bar=True)
|
| 184 |
-
self.log("val/specificity_referable", scores.specificity)
|
| 185 |
-
|
| 186 |
-
# Sensitivity-at-a-specificity-floor, and Youden's J for comparison.
|
| 187 |
-
# Plain sensitivity is not safe to select on: checked against this
|
| 188 |
-
# project's own logs, val/sensitivity_referable peaks at the exact
|
| 189 |
-
# epoch that collapses to predicting referable for nearly everyone
|
| 190 |
-
# (grade-2 recall ~1.0, specificity ~0.80) in 3 of 4 CE runs. The
|
| 191 |
-
# floor-constrained version operationalises this project's own stated
|
| 192 |
-
# target (sens >90%, spec >85%) instead of a symmetric combination, so
|
| 193 |
-
# it cannot be satisfied by that shortcut. See
|
| 194 |
-
# drdetect.eval.metrics.sensitivity_at_specificity_floor.
|
| 195 |
-
#
|
| 196 |
-
# The floor is configurable (train.py --spec-floor) because 0.85
|
| 197 |
-
# saturates on this task: it compressed 16 epochs spanning 0.106 QWK
|
| 198 |
-
# into a 0.013 band, and ModelCheckpoint then selected on noise
|
| 199 |
-
# (docs/07_PHASE3_RESULTS.md, Result 9). The logged key stays
|
| 200 |
-
# "val/sens_at_spec85" regardless of the floor actually used, for
|
| 201 |
-
# continuity with --monitor's fixed choices and every existing CSV --
|
| 202 |
-
# the TRUE floor for a given run is always in that run's
|
| 203 |
-
# summary.json config, not in this key's name.
|
| 204 |
-
self.log(
|
| 205 |
-
"val/sens_at_spec85",
|
| 206 |
-
sensitivity_at_specificity_floor(y_ref, p_ref, spec_floor=self.hparams.spec_floor),
|
| 207 |
-
prog_bar=True,
|
| 208 |
-
)
|
| 209 |
-
self.log("val/youden_j", youden_j(y_ref, p_ref, threshold=cut))
|
| 210 |
-
|
| 211 |
-
# Per-class recall: aggregate QWK can look fine while a rare class is
|
| 212 |
-
# never predicted at all.
|
| 213 |
-
for cls in range(self.num_classes):
|
| 214 |
-
mask = targets == cls
|
| 215 |
-
recall = float((preds[mask] == cls).mean()) if mask.any() else float("nan")
|
| 216 |
-
self.log(f"val/recall_{cls}_{CLASS_NAMES[cls].replace(' ', '')}", recall)
|
| 217 |
-
|
| 218 |
-
# Macro-averaged recall: the unweighted mean over classes, so a rare
|
| 219 |
-
# class counts as much as a common one. Logged as a selection
|
| 220 |
-
# alternative to QWK, which on this data tracks grade-2 recall
|
| 221 |
-
# (r=+0.51 over 70 epochs) and, for balanced CORN, was strongly
|
| 222 |
-
# ANTI-correlated with grade-1 recall (r=-0.70). Selecting that run on
|
| 223 |
-
# QWK cost 0.100 macro-recall to gain 0.0097 QWK.
|
| 224 |
-
recalls = [
|
| 225 |
-
float((preds[targets == c] == c).mean())
|
| 226 |
-
for c in range(self.num_classes)
|
| 227 |
-
if (targets == c).any()
|
| 228 |
-
]
|
| 229 |
-
self.log("val/macro_recall", float(np.mean(recalls)) if recalls else 0.0)
|
| 230 |
-
|
| 231 |
-
self._val_logits.clear()
|
| 232 |
-
self._val_targets.clear()
|
| 233 |
-
|
| 234 |
-
def configure_optimizers(self):
|
| 235 |
-
# Only parameters that actually require grad -- frozen BN affine weights
|
| 236 |
-
# must not be handed to the optimiser, or AdamW allocates moment buffers
|
| 237 |
-
# for tensors it will never update.
|
| 238 |
-
params = [p for p in self.model.parameters() if p.requires_grad]
|
| 239 |
-
optimiser = torch.optim.AdamW(
|
| 240 |
-
params, lr=self.hparams.lr, weight_decay=self.hparams.weight_decay
|
| 241 |
-
)
|
| 242 |
-
|
| 243 |
-
warmup = max(self.hparams.warmup_epochs, 0)
|
| 244 |
-
total = max(self.hparams.max_epochs, warmup + 1)
|
| 245 |
-
|
| 246 |
-
def lr_lambda(epoch: int) -> float:
|
| 247 |
-
if epoch < warmup:
|
| 248 |
-
return (epoch + 1) / (warmup + 1)
|
| 249 |
-
progress = (epoch - warmup) / max(total - warmup, 1)
|
| 250 |
-
return float(0.5 * (1.0 + np.cos(np.pi * min(progress, 1.0))))
|
| 251 |
-
|
| 252 |
-
scheduler = torch.optim.lr_scheduler.LambdaLR(optimiser, lr_lambda)
|
| 253 |
-
return {
|
| 254 |
-
"optimizer": optimiser,
|
| 255 |
-
"lr_scheduler": {"scheduler": scheduler, "interval": "epoch"},
|
| 256 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/quality/__init__.py
DELETED
|
File without changes
|
src/src/drdetect/quality/assessment.py
DELETED
|
@@ -1,127 +0,0 @@
|
|
| 1 |
-
"""Handcrafted fundus image quality gate.
|
| 2 |
-
|
| 3 |
-
This is the "handcrafted quality features" half of Phase 2's quality stage
|
| 4 |
-
(docs/04_ROADMAP.md), not the learned half. The roadmap also calls for an
|
| 5 |
-
EfficientNet-B0 classifier trained on EyeQ; that needs a new dataset download
|
| 6 |
-
and a training run and is deliberately deferred. Everything here is an
|
| 7 |
-
uncalibrated heuristic, not a validated clinical cutoff -- the thresholds are
|
| 8 |
-
defensible starting points from classical blur/exposure-detection practice,
|
| 9 |
-
not numbers measured against this project's own data. Replace them with the
|
| 10 |
-
EyeQ classifier before trusting this gate on real field images, the same way
|
| 11 |
-
`apply_clahe`'s clip_limit is flagged as a knob to ablate, not a fact.
|
| 12 |
-
"""
|
| 13 |
-
|
| 14 |
-
from __future__ import annotations
|
| 15 |
-
|
| 16 |
-
from dataclasses import dataclass, field
|
| 17 |
-
|
| 18 |
-
import cv2
|
| 19 |
-
import numpy as np
|
| 20 |
-
|
| 21 |
-
from drdetect.enhance.preprocessing import circle_crop, crop_from_gray
|
| 22 |
-
|
| 23 |
-
__all__ = ["QualityResult", "assess_quality"]
|
| 24 |
-
|
| 25 |
-
# Laplacian variance below this, on the FOV resized to a fixed working size,
|
| 26 |
-
# reads as blurry. Variance of the Laplacian scales with image size and FOV
|
| 27 |
-
# fraction, which is why the FOV is isolated and resized before measuring it
|
| 28 |
-
# -- otherwise the same physical sharpness would score differently depending
|
| 29 |
-
# on the camera's raw resolution.
|
| 30 |
-
#
|
| 31 |
-
# 80.0 (a generic blur-detection tutorial default) was the first value tried
|
| 32 |
-
# here, and it rejected 28 of 30 real APTOS training images -- images that
|
| 33 |
-
# were sharp enough for an ophthalmologist to grade. That default was tuned
|
| 34 |
-
# for a different normalisation scale, not this one. 15.0 is recalibrated
|
| 35 |
-
# against this project's own pipeline on a 60-image APTOS sample (measured
|
| 36 |
-
# range 7-108, with a visible cluster of genuinely-degraded outliers at 7-13
|
| 37 |
-
# and a continuous spread from ~17 up); it is still an eyeballed cut on a
|
| 38 |
-
# small sample, not the learned EyeQ classifier the roadmap calls for.
|
| 39 |
-
_BLUR_WORKING_SIZE = 512
|
| 40 |
-
_MIN_SHARPNESS = 15.0
|
| 41 |
-
|
| 42 |
-
# Mean intensity of the FOV, on a 0-255 scale. Outside this band the image is
|
| 43 |
-
# globally under- or over-exposed rather than locally noisy. Lower bound has
|
| 44 |
-
# a margin below the measured real-data minimum (36 on the same 60-image
|
| 45 |
-
# sample); upper bound is untested against real overexposed captures.
|
| 46 |
-
_MIN_MEAN_BRIGHTNESS = 30.0
|
| 47 |
-
_MAX_MEAN_BRIGHTNESS = 225.0
|
| 48 |
-
|
| 49 |
-
# Fraction of FOV pixels that are crushed (near-black) or blown out
|
| 50 |
-
# (near-white). High values mean local exposure failure even when the mean
|
| 51 |
-
# brightness looks fine.
|
| 52 |
-
_MAX_DARK_FRACTION = 0.45
|
| 53 |
-
_MAX_BRIGHT_FRACTION = 0.15
|
| 54 |
-
|
| 55 |
-
# Fraction of the full frame the detected FOV must occupy. Too small usually
|
| 56 |
-
# means the camera missed the retina (misalignment, eyelash occlusion) rather
|
| 57 |
-
# than a genuinely small pupil.
|
| 58 |
-
_MIN_FOV_FRACTION = 0.25
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
@dataclass(frozen=True)
|
| 62 |
-
class QualityResult:
|
| 63 |
-
usable: bool
|
| 64 |
-
reasons: list[str] = field(default_factory=list)
|
| 65 |
-
sharpness: float = 0.0
|
| 66 |
-
mean_brightness: float = 0.0
|
| 67 |
-
dark_fraction: float = 0.0
|
| 68 |
-
bright_fraction: float = 0.0
|
| 69 |
-
fov_fraction: float = 0.0
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
def assess_quality(image: np.ndarray) -> QualityResult:
|
| 73 |
-
"""Gate a raw (unpreprocessed) fundus photo before grading.
|
| 74 |
-
|
| 75 |
-
Args:
|
| 76 |
-
image: HxWx3 uint8 RGB, as captured -- not yet Ben Graham-normalised.
|
| 77 |
-
Judging exposure and blur on the enhanced image would be
|
| 78 |
-
circular: `ben_graham` actively rewrites local contrast.
|
| 79 |
-
|
| 80 |
-
Returns:
|
| 81 |
-
QualityResult with `usable=False` and `reasons` naming every check
|
| 82 |
-
that failed, not just the first -- a rejected image should tell the
|
| 83 |
-
operator what to fix on recapture, not just that something is wrong.
|
| 84 |
-
"""
|
| 85 |
-
fov = crop_from_gray(image)
|
| 86 |
-
if fov.size == 0 or min(fov.shape[:2]) < 8:
|
| 87 |
-
return QualityResult(usable=False, reasons=["no retinal field of view detected"])
|
| 88 |
-
|
| 89 |
-
gray = cv2.cvtColor(fov, cv2.COLOR_RGB2GRAY)
|
| 90 |
-
|
| 91 |
-
working = cv2.resize(
|
| 92 |
-
gray, (_BLUR_WORKING_SIZE, _BLUR_WORKING_SIZE), interpolation=cv2.INTER_AREA
|
| 93 |
-
)
|
| 94 |
-
sharpness = float(cv2.Laplacian(working, cv2.CV_64F).var())
|
| 95 |
-
|
| 96 |
-
mean_brightness = float(gray.mean())
|
| 97 |
-
dark_fraction = float((gray < 20).mean())
|
| 98 |
-
bright_fraction = float((gray > 245).mean())
|
| 99 |
-
|
| 100 |
-
circle = circle_crop(image)
|
| 101 |
-
fov_fraction = float((cv2.cvtColor(circle, cv2.COLOR_RGB2GRAY) > 0).sum()) / (
|
| 102 |
-
image.shape[0] * image.shape[1]
|
| 103 |
-
)
|
| 104 |
-
|
| 105 |
-
reasons = []
|
| 106 |
-
if sharpness < _MIN_SHARPNESS:
|
| 107 |
-
reasons.append(f"image too blurry (sharpness {sharpness:.0f} < {_MIN_SHARPNESS:.0f})")
|
| 108 |
-
if mean_brightness < _MIN_MEAN_BRIGHTNESS:
|
| 109 |
-
reasons.append(f"underexposed (mean brightness {mean_brightness:.0f})")
|
| 110 |
-
if mean_brightness > _MAX_MEAN_BRIGHTNESS:
|
| 111 |
-
reasons.append(f"overexposed (mean brightness {mean_brightness:.0f})")
|
| 112 |
-
if dark_fraction > _MAX_DARK_FRACTION:
|
| 113 |
-
reasons.append(f"too much of the field is near-black ({dark_fraction:.0%})")
|
| 114 |
-
if bright_fraction > _MAX_BRIGHT_FRACTION:
|
| 115 |
-
reasons.append(f"too much of the field is blown out ({bright_fraction:.0%})")
|
| 116 |
-
if fov_fraction < _MIN_FOV_FRACTION:
|
| 117 |
-
reasons.append(f"retinal field of view too small ({fov_fraction:.0%} of frame)")
|
| 118 |
-
|
| 119 |
-
return QualityResult(
|
| 120 |
-
usable=not reasons,
|
| 121 |
-
reasons=reasons,
|
| 122 |
-
sharpness=sharpness,
|
| 123 |
-
mean_brightness=mean_brightness,
|
| 124 |
-
dark_fraction=dark_fraction,
|
| 125 |
-
bright_fraction=bright_fraction,
|
| 126 |
-
fov_fraction=fov_fraction,
|
| 127 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/segmentation/__init__.py
DELETED
|
File without changes
|
src/src/drdetect/segmentation/dataset.py
DELETED
|
@@ -1,174 +0,0 @@
|
|
| 1 |
-
"""IDRiD lesion segmentation dataset: on-the-fly patch sampling from full-resolution images.
|
| 2 |
-
|
| 3 |
-
IDRiD images are 2848x4288 -- about 17x the pixel count of the 1024px grading
|
| 4 |
-
images this project trains at elsewhere, and holding even one at full
|
| 5 |
-
resolution through a segmentation decoder is not something this project's
|
| 6 |
-
measured memory budget (docs/05_PROTOTYPE_SCOPE.md) supports. The roadmap's
|
| 7 |
-
own plan is "patch sampling at full resolution" (docs/04_ROADMAP.md, Phase 4),
|
| 8 |
-
not downscaling -- a hard exudate here is 0.07% of the image by pixel count
|
| 9 |
-
(measured directly, IDRiD_41_EX.tif), so shrinking the image shrinks the
|
| 10 |
-
lesion out of existence before the model ever sees it.
|
| 11 |
-
|
| 12 |
-
Patches are drawn fresh every `__getitem__` call, not pre-extracted once,
|
| 13 |
-
biased toward lesion-containing regions (`lesion_patch_prob`) -- a uniformly
|
| 14 |
-
random 512x512 crop of a 2848x4288 image with <0.1% positive pixels would
|
| 15 |
-
return an all-background patch almost every time, and a model trained on
|
| 16 |
-
"predict background" batches learns exactly that.
|
| 17 |
-
"""
|
| 18 |
-
|
| 19 |
-
from __future__ import annotations
|
| 20 |
-
|
| 21 |
-
from dataclasses import dataclass
|
| 22 |
-
from pathlib import Path
|
| 23 |
-
|
| 24 |
-
import albumentations as A
|
| 25 |
-
import cv2
|
| 26 |
-
import numpy as np
|
| 27 |
-
from albumentations.pytorch import ToTensorV2
|
| 28 |
-
from torch.utils.data import Dataset
|
| 29 |
-
|
| 30 |
-
__all__ = [
|
| 31 |
-
"LesionImagePair",
|
| 32 |
-
"find_idrid_lesion_pairs",
|
| 33 |
-
"IDRiDLesionDataset",
|
| 34 |
-
"build_patch_transforms",
|
| 35 |
-
]
|
| 36 |
-
|
| 37 |
-
LESION_FOLDERS = {
|
| 38 |
-
"microaneurysms": "1. Microaneurysms",
|
| 39 |
-
"haemorrhages": "2. Haemorrhages",
|
| 40 |
-
"hard_exudates": "3. Hard Exudates",
|
| 41 |
-
"soft_exudates": "4. Soft Exudates",
|
| 42 |
-
"optic_disc": "5. Optic Disc",
|
| 43 |
-
}
|
| 44 |
-
# IDRiD's own mask filename suffixes, per lesion type -- not derivable from
|
| 45 |
-
# the folder name alone (e.g. "Hard Exudates" -> "_EX", not "_HardExudates").
|
| 46 |
-
LESION_SUFFIXES = {
|
| 47 |
-
"microaneurysms": "_MA",
|
| 48 |
-
"haemorrhages": "_HE",
|
| 49 |
-
"hard_exudates": "_EX",
|
| 50 |
-
"soft_exudates": "_SE",
|
| 51 |
-
"optic_disc": "_OD",
|
| 52 |
-
}
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
@dataclass(frozen=True)
|
| 56 |
-
class LesionImagePair:
|
| 57 |
-
image_id: str
|
| 58 |
-
image_path: Path
|
| 59 |
-
mask_path: Path
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def find_idrid_lesion_pairs(
|
| 63 |
-
idrid_root: str | Path, lesion: str, split: str
|
| 64 |
-
) -> list[LesionImagePair]:
|
| 65 |
-
"""Match IDRiD images to their mask for one lesion type and split.
|
| 66 |
-
|
| 67 |
-
Args:
|
| 68 |
-
idrid_root: e.g. "data/raw/idrid".
|
| 69 |
-
lesion: a key of LESION_FOLDERS.
|
| 70 |
-
split: "train" or "test".
|
| 71 |
-
"""
|
| 72 |
-
if lesion not in LESION_FOLDERS:
|
| 73 |
-
raise ValueError(f"unknown lesion {lesion!r}; expected one of {sorted(LESION_FOLDERS)}")
|
| 74 |
-
root = Path(idrid_root)
|
| 75 |
-
split_dir = "a. Training Set" if split == "train" else "b. Testing Set"
|
| 76 |
-
image_dir = root / "A. Segmentation" / "1. Original Images" / split_dir
|
| 77 |
-
mask_dir = (
|
| 78 |
-
root
|
| 79 |
-
/ "A. Segmentation"
|
| 80 |
-
/ "2. All Segmentation Groundtruths"
|
| 81 |
-
/ split_dir
|
| 82 |
-
/ LESION_FOLDERS[lesion]
|
| 83 |
-
)
|
| 84 |
-
|
| 85 |
-
pairs = []
|
| 86 |
-
for image_path in sorted(image_dir.glob("*.jpg")):
|
| 87 |
-
mask_path = mask_dir / f"{image_path.stem}{LESION_SUFFIXES[lesion]}.tif"
|
| 88 |
-
if mask_path.exists():
|
| 89 |
-
pairs.append(
|
| 90 |
-
LesionImagePair(
|
| 91 |
-
image_id=image_path.stem, image_path=image_path, mask_path=mask_path
|
| 92 |
-
)
|
| 93 |
-
)
|
| 94 |
-
return pairs
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
def build_patch_transforms(patch_size: int, train: bool) -> A.Compose:
|
| 98 |
-
"""Same reasoning as drdetect.data.dataset.build_transforms: flips/rotation
|
| 99 |
-
are label-preserving for a fundus with no canonical orientation, and no
|
| 100 |
-
blur/noise, since it would erase small lesions the same way it would
|
| 101 |
-
erase microaneurysms in the grading pipeline."""
|
| 102 |
-
normalise = A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225))
|
| 103 |
-
if not train:
|
| 104 |
-
return A.Compose([normalise, ToTensorV2()])
|
| 105 |
-
return A.Compose(
|
| 106 |
-
[
|
| 107 |
-
A.HorizontalFlip(p=0.5),
|
| 108 |
-
A.VerticalFlip(p=0.5),
|
| 109 |
-
A.RandomRotate90(p=0.5),
|
| 110 |
-
A.RandomBrightnessContrast(brightness_limit=0.15, contrast_limit=0.15, p=0.5),
|
| 111 |
-
normalise,
|
| 112 |
-
ToTensorV2(),
|
| 113 |
-
]
|
| 114 |
-
)
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
class IDRiDLesionDataset(Dataset):
|
| 118 |
-
"""One lesion type, patch-sampled on the fly.
|
| 119 |
-
|
| 120 |
-
`patches_per_image` fixes an epoch's length to something meaningful for a
|
| 121 |
-
54-image training set -- without it, `len(dataset) == len(pairs)` would
|
| 122 |
-
make one epoch see 54 patches total, most of a 2848x4288 image never
|
| 123 |
-
sampled at all.
|
| 124 |
-
"""
|
| 125 |
-
|
| 126 |
-
def __init__(
|
| 127 |
-
self,
|
| 128 |
-
pairs: list[LesionImagePair],
|
| 129 |
-
*,
|
| 130 |
-
patch_size: int = 512,
|
| 131 |
-
train: bool = True,
|
| 132 |
-
patches_per_image: int = 20,
|
| 133 |
-
lesion_patch_prob: float = 0.8,
|
| 134 |
-
seed: int = 42,
|
| 135 |
-
):
|
| 136 |
-
self.pairs = pairs
|
| 137 |
-
self.patch_size = patch_size
|
| 138 |
-
self.train = train
|
| 139 |
-
self.patches_per_image = patches_per_image
|
| 140 |
-
self.lesion_patch_prob = lesion_patch_prob
|
| 141 |
-
self.transform = build_patch_transforms(patch_size, train)
|
| 142 |
-
self._rng = np.random.default_rng(seed)
|
| 143 |
-
|
| 144 |
-
def __len__(self) -> int:
|
| 145 |
-
return len(self.pairs) * self.patches_per_image
|
| 146 |
-
|
| 147 |
-
def _load(self, pair: LesionImagePair) -> tuple[np.ndarray, np.ndarray]:
|
| 148 |
-
image = cv2.imread(str(pair.image_path), cv2.IMREAD_COLOR)
|
| 149 |
-
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
| 150 |
-
mask = cv2.imread(str(pair.mask_path), cv2.IMREAD_GRAYSCALE)
|
| 151 |
-
mask = (mask > 0).astype(np.uint8)
|
| 152 |
-
return image, mask
|
| 153 |
-
|
| 154 |
-
def _sample_patch(self, image: np.ndarray, mask: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
| 155 |
-
h, w = mask.shape
|
| 156 |
-
p = self.patch_size
|
| 157 |
-
ys, xs = np.where(mask > 0)
|
| 158 |
-
want_lesion_patch = len(ys) > 0 and self._rng.random() < self.lesion_patch_prob
|
| 159 |
-
if want_lesion_patch:
|
| 160 |
-
i = self._rng.integers(0, len(ys))
|
| 161 |
-
cy, cx = int(ys[i]), int(xs[i])
|
| 162 |
-
top = int(np.clip(cy - p // 2 + self._rng.integers(-p // 4, p // 4 + 1), 0, h - p))
|
| 163 |
-
left = int(np.clip(cx - p // 2 + self._rng.integers(-p // 4, p // 4 + 1), 0, w - p))
|
| 164 |
-
else:
|
| 165 |
-
top = int(self._rng.integers(0, max(h - p, 1)))
|
| 166 |
-
left = int(self._rng.integers(0, max(w - p, 1)))
|
| 167 |
-
return image[top : top + p, left : left + p], mask[top : top + p, left : left + p]
|
| 168 |
-
|
| 169 |
-
def __getitem__(self, idx: int):
|
| 170 |
-
pair = self.pairs[idx % len(self.pairs)]
|
| 171 |
-
image, mask = self._load(pair)
|
| 172 |
-
patch_img, patch_mask = self._sample_patch(image, mask)
|
| 173 |
-
out = self.transform(image=patch_img, mask=patch_mask)
|
| 174 |
-
return out["image"], out["mask"].float().unsqueeze(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/segmentation/localization.py
DELETED
|
@@ -1,289 +0,0 @@
|
|
| 1 |
-
"""IDRiD optic disc / fovea localisation: heatmap regression, not segmentation.
|
| 2 |
-
|
| 3 |
-
A genuinely different task from lesion or vessel segmentation -- the target
|
| 4 |
-
is a single (x, y) point per structure, not a per-pixel mask. Following the
|
| 5 |
-
roadmap's own specified approach ("heatmap regression"), each target point
|
| 6 |
-
becomes a 2D Gaussian blob at working resolution; the model predicts a
|
| 7 |
-
2-channel heatmap (OD, fovea) and inference reads the point back off as the
|
| 8 |
-
argmax of each channel.
|
| 9 |
-
|
| 10 |
-
IDRiD's "C. Localization" task ships a genuine official split -- 413
|
| 11 |
-
training / 103 test images, each in its own CSV with (x, y) pixel
|
| 12 |
-
coordinates at full (2848x4288) resolution, for both the optic disc and the
|
| 13 |
-
fovea. This is the largest, cleanest-labelled subset of IDRiD used in this
|
| 14 |
-
project so far (segmentation's hard-exudate harness has 81 images total;
|
| 15 |
-
this one has 516).
|
| 16 |
-
|
| 17 |
-
OD-diameter normalisation: the roadmap's success criterion is "mean
|
| 18 |
-
localisation error < 0.5 x OD diameter", but per-image OD diameter isn't
|
| 19 |
-
available for the localisation set itself -- only the disjoint 81-image
|
| 20 |
-
segmentation set ships OD masks, and the two subsets use different filename
|
| 21 |
-
numbering (IDRiD_01..54 vs IDRiD_001..413/103), so they cannot be joined by
|
| 22 |
-
ID. The mean OD diameter measured directly from the 54 real segmentation
|
| 23 |
-
masks (527.7px at full 2848x4288 resolution) is used as a fixed proxy
|
| 24 |
-
constant instead of a per-image value or an invented one.
|
| 25 |
-
"""
|
| 26 |
-
|
| 27 |
-
from __future__ import annotations
|
| 28 |
-
|
| 29 |
-
import csv
|
| 30 |
-
import math
|
| 31 |
-
from dataclasses import dataclass
|
| 32 |
-
from pathlib import Path
|
| 33 |
-
|
| 34 |
-
import albumentations as A
|
| 35 |
-
import cv2
|
| 36 |
-
import lightning as L
|
| 37 |
-
import numpy as np
|
| 38 |
-
import torch
|
| 39 |
-
import torch.nn as nn
|
| 40 |
-
from albumentations.pytorch import ToTensorV2
|
| 41 |
-
from torch.utils.data import Dataset
|
| 42 |
-
|
| 43 |
-
__all__ = [
|
| 44 |
-
"IDRiDLocalizationPair",
|
| 45 |
-
"find_idrid_localization_pairs",
|
| 46 |
-
"build_localization_transforms",
|
| 47 |
-
"make_gaussian_heatmap",
|
| 48 |
-
"heatmap_argmax",
|
| 49 |
-
"IDRiDLocalizationDataset",
|
| 50 |
-
"LocalizationModule",
|
| 51 |
-
"MEAN_OD_DIAMETER_PX_FULL_RES",
|
| 52 |
-
"working_res_od_diameter",
|
| 53 |
-
"euclidean",
|
| 54 |
-
]
|
| 55 |
-
|
| 56 |
-
# Measured directly from the 54 real IDRiD segmentation-task OD masks (see
|
| 57 |
-
# module docstring for why this is a cross-subset proxy, not a per-image
|
| 58 |
-
# ground truth value).
|
| 59 |
-
MEAN_OD_DIAMETER_PX_FULL_RES = 527.7
|
| 60 |
-
_FULL_RES_H = 2848
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
@dataclass(frozen=True)
|
| 64 |
-
class IDRiDLocalizationPair:
|
| 65 |
-
image_id: str
|
| 66 |
-
image_path: Path
|
| 67 |
-
od_xy: tuple[float, float]
|
| 68 |
-
fovea_xy: tuple[float, float]
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
def _read_coordinate_csv(path: Path) -> dict[str, tuple[float, float]]:
|
| 72 |
-
"""IDRiD's markup CSVs ship ~50 trailing empty columns per row and a
|
| 73 |
-
header row with inconsistent spacing ("X- Coordinate", "Y - Coordinate")
|
| 74 |
-
-- read positionally (columns 0-2), not by header name."""
|
| 75 |
-
coords = {}
|
| 76 |
-
with open(path, newline="") as f:
|
| 77 |
-
rows = list(csv.reader(f))
|
| 78 |
-
for row in rows[1:]:
|
| 79 |
-
if not row or not row[0].strip():
|
| 80 |
-
continue
|
| 81 |
-
coords[row[0].strip()] = (float(row[1]), float(row[2]))
|
| 82 |
-
return coords
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
def find_idrid_localization_pairs(
|
| 86 |
-
idrid_root: str | Path, split: str
|
| 87 |
-
) -> list[IDRiDLocalizationPair]:
|
| 88 |
-
"""Args: idrid_root e.g. "data/raw/idrid"; split "train" or "test"."""
|
| 89 |
-
root = Path(idrid_root) / "C. Localization"
|
| 90 |
-
split_dir = "a. Training Set" if split == "train" else "b. Testing Set"
|
| 91 |
-
split_label = "Training Set" if split == "train" else "Testing Set"
|
| 92 |
-
image_dir = root / "1. Original Images" / split_dir
|
| 93 |
-
|
| 94 |
-
od_csv = (
|
| 95 |
-
root
|
| 96 |
-
/ "2. Groundtruths"
|
| 97 |
-
/ "1. Optic Disc Center Location"
|
| 98 |
-
/ f"{'a' if split == 'train' else 'b'}. IDRiD_OD_Center_{split_label}_Markups.csv"
|
| 99 |
-
)
|
| 100 |
-
fovea_csv = (
|
| 101 |
-
root
|
| 102 |
-
/ "2. Groundtruths"
|
| 103 |
-
/ "2. Fovea Center Location"
|
| 104 |
-
/ f"IDRiD_Fovea_Center_{split_label}_Markups.csv"
|
| 105 |
-
)
|
| 106 |
-
od_coords = _read_coordinate_csv(od_csv)
|
| 107 |
-
fovea_coords = _read_coordinate_csv(fovea_csv)
|
| 108 |
-
|
| 109 |
-
pairs = []
|
| 110 |
-
for image_path in sorted(image_dir.glob("*.jpg")):
|
| 111 |
-
image_id = image_path.stem
|
| 112 |
-
if image_id in od_coords and image_id in fovea_coords:
|
| 113 |
-
pairs.append(
|
| 114 |
-
IDRiDLocalizationPair(
|
| 115 |
-
image_id=image_id,
|
| 116 |
-
image_path=image_path,
|
| 117 |
-
od_xy=od_coords[image_id],
|
| 118 |
-
fovea_xy=fovea_coords[image_id],
|
| 119 |
-
)
|
| 120 |
-
)
|
| 121 |
-
return pairs
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
def build_localization_transforms(size: tuple[int, int], train: bool) -> A.Compose:
|
| 125 |
-
"""`size` is (height, width). Flips are included, unlike segmentation's
|
| 126 |
-
IDRiD transforms sharing the same rotation-invariance reasoning does NOT
|
| 127 |
-
apply here -- flipping DOES change which anatomical side is which, but
|
| 128 |
-
that's fine: the (x, y) keypoints are flipped consistently with the
|
| 129 |
-
image by albumentations' own keypoint handling, and the model is trained
|
| 130 |
-
to predict two independent points, not a rotation-invariant blob."""
|
| 131 |
-
normalise = A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225))
|
| 132 |
-
keypoint_params = A.KeypointParams(format="xy", remove_invisible=False)
|
| 133 |
-
if not train:
|
| 134 |
-
return A.Compose(
|
| 135 |
-
[A.Resize(*size), normalise, ToTensorV2()], keypoint_params=keypoint_params
|
| 136 |
-
)
|
| 137 |
-
return A.Compose(
|
| 138 |
-
[
|
| 139 |
-
A.Resize(*size),
|
| 140 |
-
A.HorizontalFlip(p=0.5),
|
| 141 |
-
A.VerticalFlip(p=0.5),
|
| 142 |
-
A.RandomBrightnessContrast(brightness_limit=0.15, contrast_limit=0.15, p=0.5),
|
| 143 |
-
normalise,
|
| 144 |
-
ToTensorV2(),
|
| 145 |
-
],
|
| 146 |
-
keypoint_params=keypoint_params,
|
| 147 |
-
)
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
def make_gaussian_heatmap(h: int, w: int, cx: float, cy: float, sigma: float) -> np.ndarray:
|
| 151 |
-
"""A single-channel float32 Gaussian blob peaking at 1.0 at (cx, cy)."""
|
| 152 |
-
ys, xs = np.mgrid[0:h, 0:w]
|
| 153 |
-
heatmap = np.exp(-(((xs - cx) ** 2 + (ys - cy) ** 2) / (2.0 * sigma**2)))
|
| 154 |
-
return heatmap.astype(np.float32)
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
def heatmap_argmax(heatmap: np.ndarray) -> tuple[float, float]:
|
| 158 |
-
"""(x, y) of a 2D array's peak."""
|
| 159 |
-
y, x = np.unravel_index(np.argmax(heatmap), heatmap.shape)
|
| 160 |
-
return float(x), float(y)
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
class IDRiDLocalizationDataset(Dataset):
|
| 164 |
-
def __init__(
|
| 165 |
-
self,
|
| 166 |
-
pairs: list[IDRiDLocalizationPair],
|
| 167 |
-
*,
|
| 168 |
-
size: tuple[int, int] = (512, 768),
|
| 169 |
-
train: bool = True,
|
| 170 |
-
sigma: float = 12.0,
|
| 171 |
-
):
|
| 172 |
-
self.pairs = pairs
|
| 173 |
-
self.size = size
|
| 174 |
-
self.sigma = sigma
|
| 175 |
-
self.transform = build_localization_transforms(size, train)
|
| 176 |
-
|
| 177 |
-
def __len__(self) -> int:
|
| 178 |
-
return len(self.pairs)
|
| 179 |
-
|
| 180 |
-
def __getitem__(self, idx: int):
|
| 181 |
-
pair = self.pairs[idx]
|
| 182 |
-
image = cv2.cvtColor(cv2.imread(str(pair.image_path), cv2.IMREAD_COLOR), cv2.COLOR_BGR2RGB)
|
| 183 |
-
keypoints = [pair.od_xy, pair.fovea_xy]
|
| 184 |
-
|
| 185 |
-
out = self.transform(image=image, keypoints=keypoints)
|
| 186 |
-
h, w = self.size
|
| 187 |
-
(od_x, od_y), (fov_x, fov_y) = out["keypoints"]
|
| 188 |
-
|
| 189 |
-
od_heatmap = make_gaussian_heatmap(h, w, od_x, od_y, self.sigma)
|
| 190 |
-
fovea_heatmap = make_gaussian_heatmap(h, w, fov_x, fov_y, self.sigma)
|
| 191 |
-
heatmaps = torch.from_numpy(np.stack([od_heatmap, fovea_heatmap]))
|
| 192 |
-
|
| 193 |
-
targets_xy = torch.tensor([[od_x, od_y], [fov_x, fov_y]], dtype=torch.float32)
|
| 194 |
-
return out["image"], heatmaps, targets_xy
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
def working_res_od_diameter(size: tuple[int, int]) -> float:
|
| 198 |
-
"""MEAN_OD_DIAMETER_PX_FULL_RES rescaled to a `size=(h, w)` working
|
| 199 |
-
resolution, assuming the uniform-scale resize `build_localization_transforms`
|
| 200 |
-
actually performs (height ratio == width ratio, since the working
|
| 201 |
-
aspect ratio is chosen to match IDRiD's real one)."""
|
| 202 |
-
scale = size[0] / _FULL_RES_H
|
| 203 |
-
return MEAN_OD_DIAMETER_PX_FULL_RES * scale
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
def euclidean(a: tuple[float, float], b: tuple[float, float]) -> float:
|
| 207 |
-
return math.hypot(a[0] - b[0], a[1] - b[1])
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
class LocalizationModule(L.LightningModule):
|
| 211 |
-
"""MSE between predicted (sigmoid) heatmaps and target Gaussians --
|
| 212 |
-
standard for heatmap-regression landmark detection. Validation reads the
|
| 213 |
-
point back off each predicted channel's argmax and reports Euclidean
|
| 214 |
-
error in OD-diameter units, the roadmap's own stated success unit.
|
| 215 |
-
"""
|
| 216 |
-
|
| 217 |
-
def __init__(
|
| 218 |
-
self,
|
| 219 |
-
model: nn.Module,
|
| 220 |
-
*,
|
| 221 |
-
lr: float = 1e-4,
|
| 222 |
-
weight_decay: float = 1e-4,
|
| 223 |
-
working_od_diameter: float = 1.0,
|
| 224 |
-
max_epochs: int = 40,
|
| 225 |
-
):
|
| 226 |
-
super().__init__()
|
| 227 |
-
self.save_hyperparameters(ignore=["model"])
|
| 228 |
-
self.model = model
|
| 229 |
-
self._val_pred_heatmaps: list[torch.Tensor] = []
|
| 230 |
-
self._val_target_xy: list[torch.Tensor] = []
|
| 231 |
-
|
| 232 |
-
def forward(self, x):
|
| 233 |
-
return self.model(x)
|
| 234 |
-
|
| 235 |
-
def _loss(self, logits: torch.Tensor, target_heatmaps: torch.Tensor) -> torch.Tensor:
|
| 236 |
-
probs = torch.sigmoid(logits)
|
| 237 |
-
return nn.functional.mse_loss(probs, target_heatmaps)
|
| 238 |
-
|
| 239 |
-
def training_step(self, batch, batch_idx):
|
| 240 |
-
x, heatmaps, _ = batch
|
| 241 |
-
logits = self(x)
|
| 242 |
-
loss = self._loss(logits, heatmaps)
|
| 243 |
-
if not torch.isfinite(loss):
|
| 244 |
-
raise RuntimeError(f"Localisation loss is {loss.item()} at step {batch_idx}. Diverged.")
|
| 245 |
-
self.log("train/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
|
| 246 |
-
return loss
|
| 247 |
-
|
| 248 |
-
def validation_step(self, batch, batch_idx):
|
| 249 |
-
x, heatmaps, targets_xy = batch
|
| 250 |
-
logits = self(x)
|
| 251 |
-
loss = self._loss(logits, heatmaps)
|
| 252 |
-
self.log("val/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
|
| 253 |
-
self._val_pred_heatmaps.append(torch.sigmoid(logits).detach().float().cpu())
|
| 254 |
-
self._val_target_xy.append(targets_xy.detach().cpu())
|
| 255 |
-
|
| 256 |
-
def on_validation_epoch_end(self):
|
| 257 |
-
if not self._val_pred_heatmaps:
|
| 258 |
-
return
|
| 259 |
-
preds = torch.cat(self._val_pred_heatmaps).numpy()
|
| 260 |
-
targets = torch.cat(self._val_target_xy).numpy()
|
| 261 |
-
|
| 262 |
-
od_errors, fovea_errors = [], []
|
| 263 |
-
diameter = self.hparams.working_od_diameter
|
| 264 |
-
for i in range(preds.shape[0]):
|
| 265 |
-
od_pred = heatmap_argmax(preds[i, 0])
|
| 266 |
-
fovea_pred = heatmap_argmax(preds[i, 1])
|
| 267 |
-
od_errors.append(euclidean(od_pred, tuple(targets[i, 0])) / diameter)
|
| 268 |
-
fovea_errors.append(euclidean(fovea_pred, tuple(targets[i, 1])) / diameter)
|
| 269 |
-
|
| 270 |
-
od_mean = float(np.mean(od_errors))
|
| 271 |
-
fovea_mean = float(np.mean(fovea_errors))
|
| 272 |
-
self.log("val/od_error_diameters", od_mean, prog_bar=True)
|
| 273 |
-
self.log("val/fovea_error_diameters", fovea_mean, prog_bar=True)
|
| 274 |
-
self.log("val/mean_error_diameters", (od_mean + fovea_mean) / 2.0, prog_bar=True)
|
| 275 |
-
|
| 276 |
-
self._val_pred_heatmaps.clear()
|
| 277 |
-
self._val_target_xy.clear()
|
| 278 |
-
|
| 279 |
-
def configure_optimizers(self):
|
| 280 |
-
optimiser = torch.optim.AdamW(
|
| 281 |
-
self.model.parameters(), lr=self.hparams.lr, weight_decay=self.hparams.weight_decay
|
| 282 |
-
)
|
| 283 |
-
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
|
| 284 |
-
optimiser, T_max=self.hparams.max_epochs
|
| 285 |
-
)
|
| 286 |
-
return {
|
| 287 |
-
"optimizer": optimiser,
|
| 288 |
-
"lr_scheduler": {"scheduler": scheduler, "interval": "epoch"},
|
| 289 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/segmentation/metrics.py
DELETED
|
@@ -1,131 +0,0 @@
|
|
| 1 |
-
"""Pixel-level segmentation metrics.
|
| 2 |
-
|
| 3 |
-
AUPRC, not AUROC, is the metric of record here -- the roadmap says why
|
| 4 |
-
(docs/04_ROADMAP.md, Phase 4): "AUROC is meaningless at <0.1% positive
|
| 5 |
-
pixels." Measured directly on this project's own data (IDRiD_41_EX.tif):
|
| 6 |
-
0.068% positive. AUROC's false-positive-rate denominator is dominated by the
|
| 7 |
-
~99.9% true negatives regardless of model quality, so a model that is barely
|
| 8 |
-
better than chance can still post a high AUROC; AUPRC's precision term is not
|
| 9 |
-
protected by that denominator and actually penalises a flood of false
|
| 10 |
-
positives.
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
-
from __future__ import annotations
|
| 14 |
-
|
| 15 |
-
import numpy as np
|
| 16 |
-
|
| 17 |
-
__all__ = [
|
| 18 |
-
"pixel_auprc",
|
| 19 |
-
"pixel_auroc",
|
| 20 |
-
"dice_coefficient",
|
| 21 |
-
"best_dice_threshold",
|
| 22 |
-
"best_f1_threshold",
|
| 23 |
-
]
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
def pixel_auprc(y_true: np.ndarray, y_score: np.ndarray) -> float:
|
| 27 |
-
"""Average precision over every pixel passed in, pooled -- not averaged
|
| 28 |
-
per image first. Per-image AUPRC is unstable (often undefined) for an
|
| 29 |
-
image with zero or a handful of positive pixels; pooling across images
|
| 30 |
-
before scoring is what IDRiD-scale lesion evaluation actually needs.
|
| 31 |
-
|
| 32 |
-
Args:
|
| 33 |
-
y_true: any shape, values in {0, 1} (or bool).
|
| 34 |
-
y_score: same shape, predicted probability in [0, 1].
|
| 35 |
-
"""
|
| 36 |
-
from sklearn.metrics import average_precision_score
|
| 37 |
-
|
| 38 |
-
y_true = np.asarray(y_true).ravel().astype(int)
|
| 39 |
-
y_score = np.asarray(y_score).ravel().astype(float)
|
| 40 |
-
if y_true.sum() == 0:
|
| 41 |
-
raise ValueError("no positive pixels in y_true -- AUPRC is undefined without any")
|
| 42 |
-
return float(average_precision_score(y_true, y_score))
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def pixel_auroc(y_true: np.ndarray, y_score: np.ndarray) -> float:
|
| 46 |
-
"""AUROC, pooled the same way as pixel_auprc. Meaningless at IDRiD-lesion-scale
|
| 47 |
-
imbalance (<0.1% positive, see pixel_auprc's own docstring) but the roadmap
|
| 48 |
-
names it explicitly as the vessel-segmentation target metric -- vessels are
|
| 49 |
-
a measured ~7.5% of DRIVE pixels, not extreme enough for AUROC's
|
| 50 |
-
false-positive-rate denominator to be swamped by trivial true negatives the
|
| 51 |
-
way it is for lesions."""
|
| 52 |
-
from sklearn.metrics import roc_auc_score
|
| 53 |
-
|
| 54 |
-
y_true = np.asarray(y_true).ravel().astype(int)
|
| 55 |
-
y_score = np.asarray(y_score).ravel().astype(float)
|
| 56 |
-
if y_true.sum() == 0 or y_true.sum() == len(y_true):
|
| 57 |
-
raise ValueError("AUROC is undefined with only one class present in y_true")
|
| 58 |
-
return float(roc_auc_score(y_true, y_score))
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
def dice_coefficient(y_true: np.ndarray, y_pred_binary: np.ndarray, *, eps: float = 1e-7) -> float:
|
| 62 |
-
"""2*|A∩B| / (|A|+|B|) at a fixed threshold -- reported alongside AUPRC,
|
| 63 |
-
not instead of it, since Dice needs a threshold AUPRC does not."""
|
| 64 |
-
y_true = np.asarray(y_true).ravel().astype(bool)
|
| 65 |
-
y_pred = np.asarray(y_pred_binary).ravel().astype(bool)
|
| 66 |
-
intersection = np.logical_and(y_true, y_pred).sum()
|
| 67 |
-
return float((2.0 * intersection + eps) / (y_true.sum() + y_pred.sum() + eps))
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
def best_dice_threshold(
|
| 71 |
-
y_true: np.ndarray, y_score: np.ndarray, *, thresholds: np.ndarray | None = None
|
| 72 |
-
) -> tuple[float, float]:
|
| 73 |
-
"""Threshold that maximises Dice, swept on a fixed grid (not every unique
|
| 74 |
-
score value, unlike drdetect.eval.metrics.choose_threshold_for_sensitivity
|
| 75 |
-
-- pixel-level score arrays here run into the hundreds of millions of
|
| 76 |
-
values, and a per-unique-value sweep at that scale does not finish).
|
| 77 |
-
|
| 78 |
-
**Call this on the VALIDATION set only** -- e.g. the internal fold's held-
|
| 79 |
-
out images, never the 27 official IDRiD test images. Freeze the returned
|
| 80 |
-
threshold and apply it to the test set with dice_coefficient(), the same
|
| 81 |
-
selection/evaluation separation drdetect.eval.metrics already documents
|
| 82 |
-
for the grading model's operating point.
|
| 83 |
-
|
| 84 |
-
Fixed 0.5 is not a safe default here: an undertrained model's sigmoid
|
| 85 |
-
outputs can sit almost entirely below 0.5 even where its pixel ranking is
|
| 86 |
-
good, so Dice@0.5 can read close to zero while AUPRC (threshold-free)
|
| 87 |
-
reads far higher -- measured directly on a 2-epoch checkpoint (AUPRC
|
| 88 |
-
0.409, Dice@0.5 0.037) versus the fully-trained one (AUPRC 0.830, Dice@0.5
|
| 89 |
-
0.703) on the identical 27 test images.
|
| 90 |
-
|
| 91 |
-
Returns:
|
| 92 |
-
(best_threshold, best_dice) from the grid.
|
| 93 |
-
"""
|
| 94 |
-
y_true = np.asarray(y_true).ravel()
|
| 95 |
-
y_score = np.asarray(y_score).ravel()
|
| 96 |
-
if thresholds is None:
|
| 97 |
-
thresholds = np.linspace(0.025, 0.975, 39)
|
| 98 |
-
|
| 99 |
-
best_threshold, best_dice = 0.5, -1.0
|
| 100 |
-
for t in thresholds:
|
| 101 |
-
dice = dice_coefficient(y_true, y_score > t)
|
| 102 |
-
if dice > best_dice:
|
| 103 |
-
best_threshold, best_dice = float(t), dice
|
| 104 |
-
return best_threshold, best_dice
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
def best_f1_threshold(
|
| 108 |
-
y_true: np.ndarray, y_score: np.ndarray, *, thresholds: np.ndarray | None = None
|
| 109 |
-
) -> tuple[float, float]:
|
| 110 |
-
"""Same grid-sweep pattern as `best_dice_threshold`, scored with F1
|
| 111 |
-
instead of Dice -- used where examples are discrete items (microaneurysm
|
| 112 |
-
*candidates*, one score each) rather than pixels, so a pixel-overlap
|
| 113 |
-
ratio doesn't apply but the precision/recall trade-off it's standing in
|
| 114 |
-
for still does. Tune on validation only, freeze, apply once to test --
|
| 115 |
-
same separation `best_dice_threshold` already documents.
|
| 116 |
-
"""
|
| 117 |
-
y_true = np.asarray(y_true).ravel().astype(bool)
|
| 118 |
-
y_score = np.asarray(y_score).ravel()
|
| 119 |
-
if thresholds is None:
|
| 120 |
-
thresholds = np.linspace(0.025, 0.975, 39)
|
| 121 |
-
|
| 122 |
-
best_threshold, best_f1 = 0.5, -1.0
|
| 123 |
-
for t in thresholds:
|
| 124 |
-
pred = y_score > t
|
| 125 |
-
tp = int(np.logical_and(y_true, pred).sum())
|
| 126 |
-
fp = int(np.logical_and(~y_true, pred).sum())
|
| 127 |
-
fn = int(np.logical_and(y_true, ~pred).sum())
|
| 128 |
-
f1 = (2 * tp) / (2 * tp + fp + fn) if (2 * tp + fp + fn) > 0 else 0.0
|
| 129 |
-
if f1 > best_f1:
|
| 130 |
-
best_threshold, best_f1 = float(t), f1
|
| 131 |
-
return best_threshold, best_f1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/segmentation/microaneurysms.py
DELETED
|
@@ -1,495 +0,0 @@
|
|
| 1 |
-
"""Microaneurysm detection: candidate generation + small-CNN classification.
|
| 2 |
-
|
| 3 |
-
The roadmap (docs/04_ROADMAP.md, Phase 4) is explicit that this lesion type
|
| 4 |
-
does *not* use the same DeepLabV3+ pixel-segmentation harness as the other
|
| 5 |
-
four: "morphological top-hat + matched filter candidate generation -> small
|
| 6 |
-
CNN classifier. Do not expect plain segmentation to work here." Measured
|
| 7 |
-
directly from all 81 IDRiD microaneurysm masks (train+test): 3,497 instances,
|
| 8 |
-
equivalent-diameter median 17.3px, p5 10.0px, p95 29.9px at full (2848x4288)
|
| 9 |
-
resolution -- resolvable at full res, but this project's own Phase 3 findings
|
| 10 |
-
(docs/07_PHASE3_RESULTS.md) already established that lesions this small are
|
| 11 |
-
destroyed by the working resolutions (384-1024px) used elsewhere in this
|
| 12 |
-
project, and a patch-sampled decoder trained at 512px (the other lesions'
|
| 13 |
-
harness) would be learning from images where a microaneurysm is ~2px wide.
|
| 14 |
-
|
| 15 |
-
Candidate generation runs a black top-hat (highlights small dark blobs
|
| 16 |
-
against a brighter surround -- microaneurysms are dark red dots) on the green
|
| 17 |
-
channel at full resolution, where the classes of interest are still several
|
| 18 |
-
pixels wide. This is a classical, deterministic, untrained step -- it proposes
|
| 19 |
-
candidates, it does not decide; the small CNN afterwards is what actually
|
| 20 |
-
distinguishes a true microaneurysm from a vessel cross-section, noise speckle,
|
| 21 |
-
or the countless other things a black top-hat also lights up.
|
| 22 |
-
"""
|
| 23 |
-
|
| 24 |
-
from __future__ import annotations
|
| 25 |
-
|
| 26 |
-
import gc
|
| 27 |
-
from dataclasses import dataclass
|
| 28 |
-
|
| 29 |
-
import cv2
|
| 30 |
-
import lightning as L
|
| 31 |
-
import numpy as np
|
| 32 |
-
import torch
|
| 33 |
-
import torch.nn as nn
|
| 34 |
-
import torch.nn.functional as F
|
| 35 |
-
from torch.utils.data import Dataset
|
| 36 |
-
|
| 37 |
-
__all__ = [
|
| 38 |
-
"Candidate",
|
| 39 |
-
"tophat_response",
|
| 40 |
-
"find_candidates",
|
| 41 |
-
"label_candidates",
|
| 42 |
-
"generation_recall",
|
| 43 |
-
"extract_patch",
|
| 44 |
-
"build_candidate_examples",
|
| 45 |
-
"PatchDataset",
|
| 46 |
-
"PatchClassifier",
|
| 47 |
-
"score_candidates",
|
| 48 |
-
"ClassifierModule",
|
| 49 |
-
]
|
| 50 |
-
|
| 51 |
-
DEFAULT_PATCH_SIZE = 33
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
@dataclass(frozen=True)
|
| 55 |
-
class Candidate:
|
| 56 |
-
cx: float
|
| 57 |
-
cy: float
|
| 58 |
-
area: int
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
def tophat_response(image_bgr: np.ndarray, *, disk_radius: int = 16) -> np.ndarray:
|
| 62 |
-
"""Black top-hat (closing - image) on the median-denoised green channel.
|
| 63 |
-
|
| 64 |
-
`disk_radius=16` (33px-diameter structuring element): the disk must be
|
| 65 |
-
larger than a microaneurysm for the closing step to erase it and leave a
|
| 66 |
-
peak in the top-hat response -- 33px comfortably exceeds the measured p95
|
| 67 |
-
equivalent diameter (29.9px) without being so large it also erases small
|
| 68 |
-
real vessel gaps, which would flood the candidate set with vessel debris.
|
| 69 |
-
"""
|
| 70 |
-
green = image_bgr[:, :, 1]
|
| 71 |
-
green = cv2.medianBlur(green, 3)
|
| 72 |
-
ksize = 2 * disk_radius + 1
|
| 73 |
-
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (ksize, ksize))
|
| 74 |
-
return cv2.morphologyEx(green, cv2.MORPH_BLACKHAT, kernel)
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
def find_candidates(
|
| 78 |
-
image_bgr: np.ndarray,
|
| 79 |
-
*,
|
| 80 |
-
disk_radius: int = 16,
|
| 81 |
-
response_percentile: float = 95.0,
|
| 82 |
-
min_area: int = 3,
|
| 83 |
-
max_area: int = 1500,
|
| 84 |
-
min_gray: float = 15.0,
|
| 85 |
-
) -> list[Candidate]:
|
| 86 |
-
"""Percentile-threshold the top-hat response, then connected-component it
|
| 87 |
-
into candidate blobs.
|
| 88 |
-
|
| 89 |
-
**Not Otsu, measured, not assumed**: Otsu was the first thing tried here,
|
| 90 |
-
and it fails badly on this response. Checked directly on IDRiD_01: Otsu
|
| 91 |
-
picks a threshold of 4 (out of a max response of 71), marking 33.8% of
|
| 92 |
-
the *entire image* as foreground -- because the top-hat response over a
|
| 93 |
-
12.2-megapixel fundus image is a skewed, long-tailed distribution
|
| 94 |
-
dominated by low-level texture and JPEG noise, not the clean bimodal
|
| 95 |
-
signal Otsu assumes. The 33.8% "foreground" merges into huge connected
|
| 96 |
-
blobs (one component covered 1.76M px), which then fail `max_area` and
|
| 97 |
-
get discarded entirely -- taking any real microaneurysm sitting inside
|
| 98 |
-
them down with it. Measured recall on IDRiD_01 with Otsu: 3/18 true
|
| 99 |
-
instances. A fixed percentile of the response's own distribution sidesteps
|
| 100 |
-
the bimodality assumption entirely and adapts per-image; swept on 5 real
|
| 101 |
-
training images (IDRiD_01/02/10/20/33), the 95th percentile recovered
|
| 102 |
-
91-100% of true instances per image (100%, 97.6%, 92.1%, 91.3%, 93.7%)
|
| 103 |
-
vs. Otsu's 16.7% on IDRiD_01 (3/18) -- at a candidate count Otsu actually
|
| 104 |
-
made *worse*, not better, despite being far more permissive.
|
| 105 |
-
|
| 106 |
-
`min_area=3, max_area=1500`: measured true-instance area at full res
|
| 107 |
-
ranges 13-2231px (median 234, p95 704) -- the lower bound is set below
|
| 108 |
-
the smallest real instance to avoid discarding true microaneurysms before
|
| 109 |
-
the classifier even sees them (a generation-stage miss can never be
|
| 110 |
-
recovered later; a false positive can still be rejected), the upper
|
| 111 |
-
bound well above p95 to keep merged/clustered annotations reachable while
|
| 112 |
-
still rejecting obviously-too-large blobs (a stray haemorrhage or vessel
|
| 113 |
-
segment surviving the top-hat).
|
| 114 |
-
|
| 115 |
-
`min_gray=15.0`: IDRiD images carry a near-black margin outside the
|
| 116 |
-
circular fundus; without this check, JPEG ringing at that boundary
|
| 117 |
-
produces top-hat "candidates" that are pure imaging artifact, not tissue.
|
| 118 |
-
"""
|
| 119 |
-
response = tophat_response(image_bgr, disk_radius=disk_radius)
|
| 120 |
-
threshold = float(np.percentile(response, response_percentile))
|
| 121 |
-
binary = (response > threshold).astype(np.uint8) * 255
|
| 122 |
-
n, _labels, stats, centroids = cv2.connectedComponentsWithStats(binary, connectivity=8)
|
| 123 |
-
gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
|
| 124 |
-
|
| 125 |
-
candidates = []
|
| 126 |
-
for i in range(1, n): # 0 is the background component
|
| 127 |
-
area = int(stats[i, cv2.CC_STAT_AREA])
|
| 128 |
-
if area < min_area or area > max_area:
|
| 129 |
-
continue
|
| 130 |
-
cx, cy = centroids[i]
|
| 131 |
-
if gray[int(round(cy)), int(round(cx))] < min_gray:
|
| 132 |
-
continue
|
| 133 |
-
candidates.append(Candidate(cx=float(cx), cy=float(cy), area=area))
|
| 134 |
-
return candidates
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
def label_candidates(candidates: list[Candidate], mask: np.ndarray) -> np.ndarray:
|
| 138 |
-
"""True where a candidate's centroid lands on a positive mask pixel."""
|
| 139 |
-
labels = np.zeros(len(candidates), dtype=bool)
|
| 140 |
-
h, w = mask.shape[:2]
|
| 141 |
-
for i, c in enumerate(candidates):
|
| 142 |
-
x, y = int(round(c.cx)), int(round(c.cy))
|
| 143 |
-
if 0 <= y < h and 0 <= x < w:
|
| 144 |
-
labels[i] = mask[y, x] > 0
|
| 145 |
-
return labels
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
def generation_recall(candidates: list[Candidate], mask: np.ndarray) -> tuple[int, int]:
|
| 149 |
-
"""(instances with >=1 candidate landing inside them, total true instances).
|
| 150 |
-
|
| 151 |
-
This is the ceiling end-to-end recall can ever reach: the classifier only
|
| 152 |
-
accepts or rejects candidates the generator already proposed, so a true
|
| 153 |
-
microaneurysm the generator never proposed is unrecoverable downstream.
|
| 154 |
-
Reported separately from the classifier's own recall so a low end-to-end
|
| 155 |
-
number can be attributed to the right stage.
|
| 156 |
-
"""
|
| 157 |
-
binary_mask = (mask > 0).astype(np.uint8)
|
| 158 |
-
n_components, comp_labels = cv2.connectedComponents(binary_mask, connectivity=8)
|
| 159 |
-
n_true = n_components - 1
|
| 160 |
-
if n_true == 0:
|
| 161 |
-
return 0, 0
|
| 162 |
-
h, w = mask.shape[:2]
|
| 163 |
-
recovered_ids: set[int] = set()
|
| 164 |
-
for c in candidates:
|
| 165 |
-
x, y = int(round(c.cx)), int(round(c.cy))
|
| 166 |
-
if 0 <= y < h and 0 <= x < w:
|
| 167 |
-
comp_id = int(comp_labels[y, x])
|
| 168 |
-
if comp_id > 0:
|
| 169 |
-
recovered_ids.add(comp_id)
|
| 170 |
-
return len(recovered_ids), n_true
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
def extract_patch(image_bgr: np.ndarray, cx: float, cy: float, size: int) -> np.ndarray:
|
| 174 |
-
"""A `size`x`size` crop centred on (cx, cy), reflect-padded at borders
|
| 175 |
-
(a candidate near the image edge is common -- fundus lesions don't avoid
|
| 176 |
-
the periphery -- and reflect padding keeps the patch's local texture
|
| 177 |
-
statistics sane instead of introducing a hard black edge a real
|
| 178 |
-
microaneurysm patch would never have).
|
| 179 |
-
|
| 180 |
-
Pads only the small overhang beyond the image edge, not the whole image.
|
| 181 |
-
An earlier version called `cv2.copyMakeBorder` on the full `image_bgr`
|
| 182 |
-
first and cropped afterwards -- measured directly, that allocates a full
|
| 183 |
-
~36MB padded copy of the *entire* 2848x4288 image on every single call,
|
| 184 |
-
and this is called once per kept candidate (positives plus up to
|
| 185 |
-
hundreds of negatives, per image). Across even a handful of images that
|
| 186 |
-
drove this project's development machine to a 39GB peak memory
|
| 187 |
-
footprint and an aborted process; cropping first and padding only the
|
| 188 |
-
(at most `size`-pixel) overhang produces bit-identical output at a
|
| 189 |
-
fraction of a percent of the cost.
|
| 190 |
-
"""
|
| 191 |
-
h, w = image_bgr.shape[:2]
|
| 192 |
-
half = size // 2
|
| 193 |
-
x, y = int(round(cx)), int(round(cy))
|
| 194 |
-
top, left = y - half, x - half
|
| 195 |
-
bottom, right = top + size, left + size
|
| 196 |
-
|
| 197 |
-
top_clip, left_clip = max(top, 0), max(left, 0)
|
| 198 |
-
bottom_clip, right_clip = min(bottom, h), min(right, w)
|
| 199 |
-
crop = image_bgr[top_clip:bottom_clip, left_clip:right_clip]
|
| 200 |
-
|
| 201 |
-
pad_top, pad_left = top_clip - top, left_clip - left
|
| 202 |
-
pad_bottom, pad_right = bottom - bottom_clip, right - right_clip
|
| 203 |
-
if pad_top or pad_left or pad_bottom or pad_right:
|
| 204 |
-
crop = cv2.copyMakeBorder(
|
| 205 |
-
crop, pad_top, pad_bottom, pad_left, pad_right, cv2.BORDER_REFLECT_101
|
| 206 |
-
)
|
| 207 |
-
return crop
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
def build_candidate_examples(
|
| 211 |
-
pairs,
|
| 212 |
-
*,
|
| 213 |
-
disk_radius: int = 16,
|
| 214 |
-
response_percentile: float = 95.0,
|
| 215 |
-
patch_size: int = DEFAULT_PATCH_SIZE,
|
| 216 |
-
negative_ratio: float = 10.0,
|
| 217 |
-
max_negatives_per_image: int = 300,
|
| 218 |
-
seed: int = 42,
|
| 219 |
-
) -> tuple[np.ndarray, np.ndarray, dict]:
|
| 220 |
-
"""Run candidate generation + ground-truth matching across a list of
|
| 221 |
-
`LesionImagePair`s (see drdetect.segmentation.dataset), returning
|
| 222 |
-
`(patches[N,size,size,3] uint8 BGR, labels[N] int64, stats)`.
|
| 223 |
-
|
| 224 |
-
Negatives are heavily subsampled per image: a full-resolution top-hat
|
| 225 |
-
routinely proposes far more spurious blobs than there are true
|
| 226 |
-
microaneurysms in an image, and keeping every one would both blow up
|
| 227 |
-
memory and swamp the true positives the way naive whole-image BCE
|
| 228 |
-
already does for pixel segmentation (drdetect.segmentation.module). Kept
|
| 229 |
-
at `negative_ratio` times that image's positive count, floored at 50 so
|
| 230 |
-
an image with zero recovered positives still contributes hard-negative
|
| 231 |
-
signal rather than none, capped at `max_negatives_per_image` so no single
|
| 232 |
-
image dominates the pool.
|
| 233 |
-
"""
|
| 234 |
-
rng = np.random.default_rng(seed)
|
| 235 |
-
all_patches: list[np.ndarray] = []
|
| 236 |
-
all_labels: list[bool] = []
|
| 237 |
-
total_recovered, total_true = 0, 0
|
| 238 |
-
per_image = []
|
| 239 |
-
|
| 240 |
-
for pair in pairs:
|
| 241 |
-
image = cv2.imread(str(pair.image_path))
|
| 242 |
-
mask = cv2.imread(str(pair.mask_path), cv2.IMREAD_GRAYSCALE)
|
| 243 |
-
candidates = find_candidates(
|
| 244 |
-
image, disk_radius=disk_radius, response_percentile=response_percentile
|
| 245 |
-
)
|
| 246 |
-
labels = label_candidates(candidates, mask)
|
| 247 |
-
recovered, n_true = generation_recall(candidates, mask)
|
| 248 |
-
total_recovered += recovered
|
| 249 |
-
total_true += n_true
|
| 250 |
-
|
| 251 |
-
pos_idx = np.flatnonzero(labels)
|
| 252 |
-
neg_idx = np.flatnonzero(~labels)
|
| 253 |
-
n_neg_keep = min(len(neg_idx), max(50, int(len(pos_idx) * negative_ratio)))
|
| 254 |
-
n_neg_keep = min(n_neg_keep, max_negatives_per_image)
|
| 255 |
-
neg_keep = (
|
| 256 |
-
rng.choice(neg_idx, size=n_neg_keep, replace=False) if n_neg_keep > 0 else neg_idx[:0]
|
| 257 |
-
)
|
| 258 |
-
keep_idx = np.concatenate([pos_idx, neg_keep])
|
| 259 |
-
|
| 260 |
-
for idx in keep_idx:
|
| 261 |
-
c = candidates[idx]
|
| 262 |
-
all_patches.append(extract_patch(image, c.cx, c.cy, patch_size))
|
| 263 |
-
all_labels.append(bool(labels[idx]))
|
| 264 |
-
|
| 265 |
-
per_image.append(
|
| 266 |
-
{
|
| 267 |
-
"image_id": pair.image_id,
|
| 268 |
-
"n_candidates": len(candidates),
|
| 269 |
-
"n_positive_candidates": int(labels.sum()),
|
| 270 |
-
"n_recovered_instances": recovered,
|
| 271 |
-
"n_true_instances": n_true,
|
| 272 |
-
"n_examples_kept": int(len(keep_idx)),
|
| 273 |
-
}
|
| 274 |
-
)
|
| 275 |
-
# Measured directly: candidate generation on one full-resolution
|
| 276 |
-
# (2848x4288) IDRiD image peaks around 640MB (connectedComponentsWithStats'
|
| 277 |
-
# internal bookkeeping over tens of thousands of components, on top of
|
| 278 |
-
# the image/response/binary/labels/gray arrays it holds at once). That
|
| 279 |
-
# is fine for one image; left to Python's own GC timing across a full
|
| 280 |
-
# train+val pair list it measurably compounded into severe system-wide
|
| 281 |
-
# memory pressure on this project's 16GB development machine. An
|
| 282 |
-
# explicit del + collect between images is cheap insurance against that.
|
| 283 |
-
del image, mask, candidates, labels
|
| 284 |
-
gc.collect()
|
| 285 |
-
|
| 286 |
-
if all_patches:
|
| 287 |
-
patches = np.stack(all_patches).astype(np.uint8)
|
| 288 |
-
else:
|
| 289 |
-
patches = np.zeros((0, patch_size, patch_size, 3), dtype=np.uint8)
|
| 290 |
-
labels_arr = np.array(all_labels, dtype=np.int64)
|
| 291 |
-
|
| 292 |
-
stats = {
|
| 293 |
-
"n_images": len(pairs),
|
| 294 |
-
"n_true_instances": total_true,
|
| 295 |
-
"n_recovered_instances": total_recovered,
|
| 296 |
-
"generation_recall": (total_recovered / total_true) if total_true else float("nan"),
|
| 297 |
-
"n_candidates_total": int(sum(p["n_candidates"] for p in per_image)),
|
| 298 |
-
"n_positive_candidates": int(labels_arr.sum()),
|
| 299 |
-
"n_examples_kept": int(len(labels_arr)),
|
| 300 |
-
"per_image": per_image,
|
| 301 |
-
}
|
| 302 |
-
return patches, labels_arr, stats
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
_IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
| 306 |
-
_IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
class PatchDataset(Dataset):
|
| 310 |
-
"""Pre-extracted candidate patches, normalised on the fly.
|
| 311 |
-
|
| 312 |
-
Patches are tiny (33x33x3 uint8 by default, ~3KB each) and already
|
| 313 |
-
extracted by `build_candidate_examples` -- unlike `IDRiDLesionDataset`,
|
| 314 |
-
there's no full-resolution image I/O left to amortise per `__getitem__`,
|
| 315 |
-
so a plain in-memory array is simpler than another on-the-fly sampler.
|
| 316 |
-
"""
|
| 317 |
-
|
| 318 |
-
def __init__(self, patches: np.ndarray, labels: np.ndarray, *, train: bool = True):
|
| 319 |
-
self.patches = patches
|
| 320 |
-
self.labels = labels
|
| 321 |
-
self.train = train
|
| 322 |
-
|
| 323 |
-
def __len__(self) -> int:
|
| 324 |
-
return len(self.labels)
|
| 325 |
-
|
| 326 |
-
def __getitem__(self, idx: int) -> tuple[torch.Tensor, torch.Tensor]:
|
| 327 |
-
patch = self.patches[idx]
|
| 328 |
-
if self.train:
|
| 329 |
-
if np.random.rand() < 0.5:
|
| 330 |
-
patch = patch[:, ::-1]
|
| 331 |
-
if np.random.rand() < 0.5:
|
| 332 |
-
patch = patch[::-1, :]
|
| 333 |
-
patch = np.rot90(patch, k=int(np.random.randint(4)))
|
| 334 |
-
rgb = np.ascontiguousarray(patch[:, :, ::-1]).astype(np.float32) / 255.0
|
| 335 |
-
rgb = (rgb - _IMAGENET_MEAN) / _IMAGENET_STD
|
| 336 |
-
tensor = torch.from_numpy(rgb.transpose(2, 0, 1).copy()).float()
|
| 337 |
-
label = torch.tensor(float(self.labels[idx]), dtype=torch.float32)
|
| 338 |
-
return tensor, label
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
class PatchClassifier(nn.Module):
|
| 342 |
-
"""Small CNN: a candidate patch in, one accept/reject logit out.
|
| 343 |
-
|
| 344 |
-
Deliberately tiny (~26K params) next to the 22.4M-param DeepLabV3+ used
|
| 345 |
-
for pixel segmentation elsewhere in this project (drdetect.segmentation.
|
| 346 |
-
model) -- the roadmap specifies a "small CNN classifier" because the
|
| 347 |
-
input here is a fixed, already-localised, tiny patch needing only a yes/no
|
| 348 |
-
answer, not a full image needing spatial decoding back to pixel masks.
|
| 349 |
-
"""
|
| 350 |
-
|
| 351 |
-
def __init__(self, in_channels: int = 3):
|
| 352 |
-
super().__init__()
|
| 353 |
-
self.features = nn.Sequential(
|
| 354 |
-
nn.Conv2d(in_channels, 16, 3, padding=1),
|
| 355 |
-
nn.BatchNorm2d(16),
|
| 356 |
-
nn.ReLU(inplace=True),
|
| 357 |
-
nn.MaxPool2d(2),
|
| 358 |
-
nn.Conv2d(16, 32, 3, padding=1),
|
| 359 |
-
nn.BatchNorm2d(32),
|
| 360 |
-
nn.ReLU(inplace=True),
|
| 361 |
-
nn.MaxPool2d(2),
|
| 362 |
-
nn.Conv2d(32, 64, 3, padding=1),
|
| 363 |
-
nn.BatchNorm2d(64),
|
| 364 |
-
nn.ReLU(inplace=True),
|
| 365 |
-
nn.AdaptiveAvgPool2d(1),
|
| 366 |
-
)
|
| 367 |
-
self.classifier = nn.Linear(64, 1)
|
| 368 |
-
|
| 369 |
-
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 370 |
-
features = self.features(x).flatten(1)
|
| 371 |
-
return self.classifier(features).squeeze(-1)
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
def score_candidates(
|
| 375 |
-
image_bgr: np.ndarray,
|
| 376 |
-
mask: np.ndarray,
|
| 377 |
-
model: nn.Module,
|
| 378 |
-
*,
|
| 379 |
-
device: str = "cpu",
|
| 380 |
-
disk_radius: int = 16,
|
| 381 |
-
response_percentile: float = 95.0,
|
| 382 |
-
patch_size: int = DEFAULT_PATCH_SIZE,
|
| 383 |
-
batch_size: int = 256,
|
| 384 |
-
) -> dict:
|
| 385 |
-
"""Run generation + classification on one image end to end, in batches --
|
| 386 |
-
used by scripts/evaluate_microaneurysms.py once per official test image.
|
| 387 |
-
|
| 388 |
-
Unlike `build_candidate_examples` (which subsamples negatives for
|
| 389 |
-
training), this keeps *every* candidate: evaluation needs the true,
|
| 390 |
-
unsampled precision/recall/false-positive-rate the deployed pipeline
|
| 391 |
-
would actually see, not a class-balanced training view of it. Patches are
|
| 392 |
-
extracted and scored one batch at a time rather than materialising every
|
| 393 |
-
candidate patch for the image up front -- a single full-resolution IDRiD
|
| 394 |
-
image can propose tens of thousands of candidates.
|
| 395 |
-
"""
|
| 396 |
-
candidates = find_candidates(
|
| 397 |
-
image_bgr, disk_radius=disk_radius, response_percentile=response_percentile
|
| 398 |
-
)
|
| 399 |
-
labels = label_candidates(candidates, mask)
|
| 400 |
-
recovered, n_true = generation_recall(candidates, mask)
|
| 401 |
-
|
| 402 |
-
scores = np.zeros(len(candidates), dtype=np.float32)
|
| 403 |
-
model.eval()
|
| 404 |
-
with torch.no_grad():
|
| 405 |
-
for start in range(0, len(candidates), batch_size):
|
| 406 |
-
batch = candidates[start : start + batch_size]
|
| 407 |
-
patches = np.stack([extract_patch(image_bgr, c.cx, c.cy, patch_size) for c in batch])
|
| 408 |
-
rgb = patches[:, :, :, ::-1].astype(np.float32) / 255.0
|
| 409 |
-
rgb = (rgb - _IMAGENET_MEAN) / _IMAGENET_STD
|
| 410 |
-
tensor = torch.from_numpy(rgb.transpose(0, 3, 1, 2).copy()).float().to(device)
|
| 411 |
-
logits = model(tensor)
|
| 412 |
-
scores[start : start + len(batch)] = torch.sigmoid(logits).cpu().numpy()
|
| 413 |
-
|
| 414 |
-
return {
|
| 415 |
-
"candidates": candidates,
|
| 416 |
-
"scores": scores,
|
| 417 |
-
"labels": labels,
|
| 418 |
-
"n_recovered": recovered,
|
| 419 |
-
"n_true": n_true,
|
| 420 |
-
}
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
class ClassifierModule(L.LightningModule):
|
| 424 |
-
"""BCE-only (no Dice term): unlike pixel segmentation, there is no
|
| 425 |
-
spatial overlap to score here -- each example is one candidate, one
|
| 426 |
-
label, so plain weighted BCE is the whole loss, with `pos_weight` set
|
| 427 |
-
from the same-style empirical measurement used throughout this project
|
| 428 |
-
(the true ratio in the actually-sampled, negative-subsampled candidate
|
| 429 |
-
pool, not a theoretical one)."""
|
| 430 |
-
|
| 431 |
-
def __init__(
|
| 432 |
-
self,
|
| 433 |
-
model: nn.Module,
|
| 434 |
-
*,
|
| 435 |
-
lr: float = 1e-3,
|
| 436 |
-
weight_decay: float = 1e-4,
|
| 437 |
-
pos_weight: float = 1.0,
|
| 438 |
-
max_epochs: int = 30,
|
| 439 |
-
):
|
| 440 |
-
super().__init__()
|
| 441 |
-
self.save_hyperparameters(ignore=["model"])
|
| 442 |
-
self.model = model
|
| 443 |
-
self.register_buffer("_pos_weight", torch.tensor(pos_weight))
|
| 444 |
-
self._val_logits: list[torch.Tensor] = []
|
| 445 |
-
self._val_targets: list[torch.Tensor] = []
|
| 446 |
-
|
| 447 |
-
def forward(self, x):
|
| 448 |
-
return self.model(x)
|
| 449 |
-
|
| 450 |
-
def training_step(self, batch, batch_idx):
|
| 451 |
-
x, y = batch
|
| 452 |
-
logits = self(x)
|
| 453 |
-
loss = F.binary_cross_entropy_with_logits(logits, y, pos_weight=self._pos_weight)
|
| 454 |
-
if not torch.isfinite(loss):
|
| 455 |
-
raise RuntimeError(f"Classifier loss is {loss.item()} at step {batch_idx}. Diverged.")
|
| 456 |
-
self.log("train/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
|
| 457 |
-
return loss
|
| 458 |
-
|
| 459 |
-
def validation_step(self, batch, batch_idx):
|
| 460 |
-
x, y = batch
|
| 461 |
-
logits = self(x)
|
| 462 |
-
loss = F.binary_cross_entropy_with_logits(logits, y, pos_weight=self._pos_weight)
|
| 463 |
-
self.log("val/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
|
| 464 |
-
self._val_logits.append(logits.detach().float().cpu())
|
| 465 |
-
self._val_targets.append(y.detach().cpu())
|
| 466 |
-
|
| 467 |
-
def on_validation_epoch_end(self):
|
| 468 |
-
if not self._val_logits:
|
| 469 |
-
return
|
| 470 |
-
logits = torch.cat(self._val_logits)
|
| 471 |
-
targets = torch.cat(self._val_targets)
|
| 472 |
-
probs = torch.sigmoid(logits).numpy()
|
| 473 |
-
targets_np = targets.numpy()
|
| 474 |
-
|
| 475 |
-
if targets_np.sum() > 0:
|
| 476 |
-
# pixel_auprc is a generic ravel + average_precision_score despite
|
| 477 |
-
# its name -- reused as-is rather than duplicated for candidates.
|
| 478 |
-
from drdetect.segmentation.metrics import pixel_auprc
|
| 479 |
-
|
| 480 |
-
self.log("val/auprc", pixel_auprc(targets_np, probs), prog_bar=True)
|
| 481 |
-
self.log("val/accuracy", float(((probs > 0.5) == (targets_np > 0.5)).mean()))
|
| 482 |
-
self._val_logits.clear()
|
| 483 |
-
self._val_targets.clear()
|
| 484 |
-
|
| 485 |
-
def configure_optimizers(self):
|
| 486 |
-
optimiser = torch.optim.AdamW(
|
| 487 |
-
self.model.parameters(), lr=self.hparams.lr, weight_decay=self.hparams.weight_decay
|
| 488 |
-
)
|
| 489 |
-
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
|
| 490 |
-
optimiser, T_max=self.hparams.max_epochs
|
| 491 |
-
)
|
| 492 |
-
return {
|
| 493 |
-
"optimizer": optimiser,
|
| 494 |
-
"lr_scheduler": {"scheduler": scheduler, "interval": "epoch"},
|
| 495 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/segmentation/model.py
DELETED
|
@@ -1,47 +0,0 @@
|
|
| 1 |
-
"""Lesion segmentation backbone.
|
| 2 |
-
|
| 3 |
-
DeepLabV3+ (Chen et al. 2018), not plain U-Net: the roadmap names both as
|
| 4 |
-
options, and DeepLabV3+'s atrous spatial pyramid pooling gives it multi-scale
|
| 5 |
-
context that plain U-Net's decoder lacks -- useful here because IDRiD's
|
| 6 |
-
lesion classes span very different physical sizes (a single-pixel
|
| 7 |
-
microaneurysm vs. a hard-exudate cluster), even though this module only
|
| 8 |
-
trains hard exudates so far.
|
| 9 |
-
|
| 10 |
-
Uses segmentation_models_pytorch (already a declared but, until now, unused
|
| 11 |
-
dependency -- same situation grad-cam and reportlab were in before Phase 2),
|
| 12 |
-
not a hand-rolled decoder, for the same reason drdetect.grading.model uses
|
| 13 |
-
timm instead of a hand-rolled EfficientNet: a maintained, widely-checked
|
| 14 |
-
reference implementation over a bespoke one.
|
| 15 |
-
"""
|
| 16 |
-
|
| 17 |
-
from __future__ import annotations
|
| 18 |
-
|
| 19 |
-
import torch.nn as nn
|
| 20 |
-
|
| 21 |
-
__all__ = ["build_segmentation_model"]
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def build_segmentation_model(
|
| 25 |
-
encoder_name: str = "resnet34",
|
| 26 |
-
*,
|
| 27 |
-
pretrained: bool = True,
|
| 28 |
-
classes: int = 1,
|
| 29 |
-
) -> nn.Module:
|
| 30 |
-
"""Binary (or multi-class) lesion segmentation model.
|
| 31 |
-
|
| 32 |
-
`classes=1`: one lesion type per model, matching how this project's IDRiD
|
| 33 |
-
masks ship -- a separate binary mask file per lesion type, not one
|
| 34 |
-
multi-class mask. Training one model per lesion type (rather than a
|
| 35 |
-
shared multi-head model) also means a lesion with very little training
|
| 36 |
-
signal (soft exudates, per the roadmap's own scope-cut list) cannot drag
|
| 37 |
-
down a better-supported class's gradient.
|
| 38 |
-
"""
|
| 39 |
-
import segmentation_models_pytorch as smp
|
| 40 |
-
|
| 41 |
-
return smp.DeepLabV3Plus(
|
| 42 |
-
encoder_name=encoder_name,
|
| 43 |
-
encoder_weights="imagenet" if pretrained else None,
|
| 44 |
-
in_channels=3,
|
| 45 |
-
classes=classes,
|
| 46 |
-
activation=None, # raw logits out; loss/inference apply sigmoid explicitly
|
| 47 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/segmentation/module.py
DELETED
|
@@ -1,120 +0,0 @@
|
|
| 1 |
-
"""Lightning module for lesion segmentation.
|
| 2 |
-
|
| 3 |
-
BCE + Dice, not plain BCE: at <0.1% positive pixels (measured, see
|
| 4 |
-
dataset.py), unweighted BCE's gradient is dominated by the trivial
|
| 5 |
-
"predict background everywhere" solution long before it ever learns the
|
| 6 |
-
lesion class -- the same class-imbalance failure mode this project already
|
| 7 |
-
diagnosed once for CORN's conditional subsets (docs/07_PHASE3_RESULTS.md,
|
| 8 |
-
Result 2), showing up again in a different part of the pipeline. Dice loss
|
| 9 |
-
is imbalance-robust by construction (it scores overlap ratio, not per-pixel
|
| 10 |
-
classification), so combining it with BCE keeps BCE's well-behaved gradients
|
| 11 |
-
while Dice supplies the signal BCE alone would drown out.
|
| 12 |
-
"""
|
| 13 |
-
|
| 14 |
-
from __future__ import annotations
|
| 15 |
-
|
| 16 |
-
import lightning as L
|
| 17 |
-
import torch
|
| 18 |
-
import torch.nn as nn
|
| 19 |
-
import torch.nn.functional as F
|
| 20 |
-
|
| 21 |
-
from drdetect.segmentation.metrics import dice_coefficient, pixel_auprc
|
| 22 |
-
|
| 23 |
-
__all__ = ["SegmentationModule", "soft_dice_loss"]
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
def soft_dice_loss(
|
| 27 |
-
logits: torch.Tensor, targets: torch.Tensor, *, eps: float = 1.0
|
| 28 |
-
) -> torch.Tensor:
|
| 29 |
-
"""Differentiable Dice loss on probabilities, not thresholded predictions.
|
| 30 |
-
|
| 31 |
-
`eps=1.0` (not 1e-7): with this few positive pixels per patch, a tiny eps
|
| 32 |
-
lets a patch with zero true positives and zero predicted positives produce
|
| 33 |
-
a huge, meaningless gradient from floating-point noise in the ratio. A
|
| 34 |
-
Dice-smoothing epsilon of 1.0 is standard practice for exactly this
|
| 35 |
-
regime, not an arbitrary choice.
|
| 36 |
-
"""
|
| 37 |
-
probs = torch.sigmoid(logits)
|
| 38 |
-
intersection = (probs * targets).sum(dim=(1, 2, 3))
|
| 39 |
-
union = probs.sum(dim=(1, 2, 3)) + targets.sum(dim=(1, 2, 3))
|
| 40 |
-
dice = (2.0 * intersection + eps) / (union + eps)
|
| 41 |
-
return 1.0 - dice.mean()
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
class SegmentationModule(L.LightningModule):
|
| 45 |
-
def __init__(
|
| 46 |
-
self,
|
| 47 |
-
model: nn.Module,
|
| 48 |
-
*,
|
| 49 |
-
lr: float = 1e-4,
|
| 50 |
-
weight_decay: float = 1e-4,
|
| 51 |
-
pos_weight: float = 1.0,
|
| 52 |
-
dice_weight: float = 1.0,
|
| 53 |
-
max_epochs: int = 40,
|
| 54 |
-
):
|
| 55 |
-
super().__init__()
|
| 56 |
-
self.save_hyperparameters(ignore=["model"])
|
| 57 |
-
self.model = model
|
| 58 |
-
self.register_buffer("_pos_weight", torch.tensor(pos_weight))
|
| 59 |
-
|
| 60 |
-
self._val_logits: list[torch.Tensor] = []
|
| 61 |
-
self._val_targets: list[torch.Tensor] = []
|
| 62 |
-
|
| 63 |
-
def forward(self, x):
|
| 64 |
-
return self.model(x)
|
| 65 |
-
|
| 66 |
-
def _loss(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
|
| 67 |
-
bce = F.binary_cross_entropy_with_logits(logits, targets, pos_weight=self._pos_weight)
|
| 68 |
-
dice = soft_dice_loss(logits, targets)
|
| 69 |
-
return bce + self.hparams.dice_weight * dice
|
| 70 |
-
|
| 71 |
-
def training_step(self, batch, batch_idx):
|
| 72 |
-
x, y = batch
|
| 73 |
-
logits = self(x)
|
| 74 |
-
loss = self._loss(logits, y)
|
| 75 |
-
if not torch.isfinite(loss):
|
| 76 |
-
raise RuntimeError(f"Segmentation loss is {loss.item()} at step {batch_idx}. Diverged.")
|
| 77 |
-
self.log("train/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
|
| 78 |
-
return loss
|
| 79 |
-
|
| 80 |
-
def validation_step(self, batch, batch_idx):
|
| 81 |
-
x, y = batch
|
| 82 |
-
logits = self(x)
|
| 83 |
-
loss = self._loss(logits, y)
|
| 84 |
-
self.log("val/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
|
| 85 |
-
self._val_logits.append(logits.detach().float().cpu())
|
| 86 |
-
self._val_targets.append(y.detach().cpu())
|
| 87 |
-
|
| 88 |
-
def on_validation_epoch_end(self):
|
| 89 |
-
if not self._val_logits:
|
| 90 |
-
return
|
| 91 |
-
logits = torch.cat(self._val_logits)
|
| 92 |
-
targets = torch.cat(self._val_targets)
|
| 93 |
-
probs = torch.sigmoid(logits).numpy()
|
| 94 |
-
targets_np = targets.numpy()
|
| 95 |
-
|
| 96 |
-
if targets_np.sum() > 0:
|
| 97 |
-
auprc = pixel_auprc(targets_np, probs)
|
| 98 |
-
self.log("val/auprc", auprc, prog_bar=True)
|
| 99 |
-
dice = dice_coefficient(targets_np, probs > 0.5)
|
| 100 |
-
self.log("val/dice", dice, prog_bar=True)
|
| 101 |
-
else:
|
| 102 |
-
# A validation split with zero positive patches this epoch --
|
| 103 |
-
# log nothing rather than a fabricated 0.0 that would look like a
|
| 104 |
-
# real (bad) score instead of an undefined one.
|
| 105 |
-
pass
|
| 106 |
-
|
| 107 |
-
self._val_logits.clear()
|
| 108 |
-
self._val_targets.clear()
|
| 109 |
-
|
| 110 |
-
def configure_optimizers(self):
|
| 111 |
-
optimiser = torch.optim.AdamW(
|
| 112 |
-
self.model.parameters(), lr=self.hparams.lr, weight_decay=self.hparams.weight_decay
|
| 113 |
-
)
|
| 114 |
-
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
|
| 115 |
-
optimiser, T_max=self.hparams.max_epochs
|
| 116 |
-
)
|
| 117 |
-
return {
|
| 118 |
-
"optimizer": optimiser,
|
| 119 |
-
"lr_scheduler": {"scheduler": scheduler, "interval": "epoch"},
|
| 120 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/segmentation/quadrants.py
DELETED
|
@@ -1,69 +0,0 @@
|
|
| 1 |
-
"""Quadrant mapping from the OD-fovea axis.
|
| 2 |
-
|
| 3 |
-
Needed for ICDR's 4-2-1 rule (severe NPDR: haemorrhages in 4 quadrants, or
|
| 4 |
-
venous beading in 2, or IRMA in 1), which counts lesions per retinal
|
| 5 |
-
quadrant. This module defines the four quadrants geometrically, as the
|
| 6 |
-
roadmap specifies: one dividing line through the optic disc along the
|
| 7 |
-
OD-fovea axis, one perpendicular to it through the same point.
|
| 8 |
-
|
| 9 |
-
**Labelling deliberately avoids true anatomical nasal/temporal names.**
|
| 10 |
-
Whether the "away from fovea" side of the OD is nasal or temporal depends on
|
| 11 |
-
which eye (OD/OS) the image is of, and neither IDRiD nor this project's
|
| 12 |
-
other datasets carry reliable per-image eye-laterality labels to resolve
|
| 13 |
-
that. The labels used here -- "foveal-side" / "disc-side" for the axis-
|
| 14 |
-
parallel direction, "superior" / "inferior" for the perpendicular one --
|
| 15 |
-
describe the geometry precisely without asserting an anatomical mapping
|
| 16 |
-
this project cannot verify. A deployment with real laterality metadata
|
| 17 |
-
could remap these to true nasal/temporal in one place (`QUADRANT_LABELS`)
|
| 18 |
-
without touching the geometry.
|
| 19 |
-
"""
|
| 20 |
-
|
| 21 |
-
from __future__ import annotations
|
| 22 |
-
|
| 23 |
-
import numpy as np
|
| 24 |
-
|
| 25 |
-
__all__ = ["quadrant_axes", "assign_quadrant", "QUADRANT_LABELS"]
|
| 26 |
-
|
| 27 |
-
QUADRANT_LABELS = {
|
| 28 |
-
(1, 1): "superior-foveal-side",
|
| 29 |
-
(1, -1): "inferior-foveal-side",
|
| 30 |
-
(-1, 1): "superior-disc-side",
|
| 31 |
-
(-1, -1): "inferior-disc-side",
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def quadrant_axes(
|
| 36 |
-
od_xy: tuple[float, float], fovea_xy: tuple[float, float]
|
| 37 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 38 |
-
"""Unit vectors (along_axis, perpendicular_axis) through the OD, where
|
| 39 |
-
along_axis points from OD toward the fovea. Raises if OD and fovea
|
| 40 |
-
coincide (undefined axis) -- that should never happen for real data and
|
| 41 |
-
signals a genuine upstream bug (e.g. a broken localisation checkpoint)
|
| 42 |
-
if it does.
|
| 43 |
-
"""
|
| 44 |
-
od = np.asarray(od_xy, dtype=float)
|
| 45 |
-
fovea = np.asarray(fovea_xy, dtype=float)
|
| 46 |
-
axis = fovea - od
|
| 47 |
-
norm = np.linalg.norm(axis)
|
| 48 |
-
if norm < 1e-6:
|
| 49 |
-
raise ValueError(f"OD and fovea coincide ({od_xy} == {fovea_xy}) -- cannot define an axis.")
|
| 50 |
-
along = axis / norm
|
| 51 |
-
# Perpendicular, image coordinates (y grows downward): rotate -90 degrees
|
| 52 |
-
# so positive `perp` points toward smaller y (up the image, superior).
|
| 53 |
-
perp = np.array([along[1], -along[0]])
|
| 54 |
-
return along, perp
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
def assign_quadrant(
|
| 58 |
-
point_xy: tuple[float, float], od_xy: tuple[float, float], fovea_xy: tuple[float, float]
|
| 59 |
-
) -> str:
|
| 60 |
-
"""Which of the 4 quadrants `point_xy` falls in, relative to the OD as
|
| 61 |
-
origin and the OD-fovea line as one dividing axis. See module docstring
|
| 62 |
-
for why the labels describe geometry, not asserted anatomy."""
|
| 63 |
-
along, perp = quadrant_axes(od_xy, fovea_xy)
|
| 64 |
-
od = np.asarray(od_xy, dtype=float)
|
| 65 |
-
v = np.asarray(point_xy, dtype=float) - od
|
| 66 |
-
|
| 67 |
-
along_sign = 1 if np.dot(v, along) >= 0 else -1
|
| 68 |
-
perp_sign = 1 if np.dot(v, perp) >= 0 else -1
|
| 69 |
-
return QUADRANT_LABELS[(along_sign, perp_sign)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/segmentation/vessels.py
DELETED
|
@@ -1,230 +0,0 @@
|
|
| 1 |
-
"""DRIVE vessel segmentation: dataset, model, and FOV-aware Lightning module.
|
| 2 |
-
|
| 3 |
-
Deliberately separate from dataset.py/module.py (the IDRiD lesion harness),
|
| 4 |
-
not sharing a base class with them, for reasons that are architectural, not
|
| 5 |
-
just organisational:
|
| 6 |
-
|
| 7 |
-
* DRIVE images are small and uniform (584x565) -- full images are trained
|
| 8 |
-
on directly, no patch sampling.
|
| 9 |
-
* Vessels are a measured ~7.5% of pixels, not IDRiD's <0.1% -- AUROC is a
|
| 10 |
-
meaningful metric here (see metrics.pixel_auroc), where it is not for
|
| 11 |
-
lesions.
|
| 12 |
-
* Every DRIVE image has a field-of-view mask, and pixels outside it (the
|
| 13 |
-
black surround around the circular retinal image) are not tissue at
|
| 14 |
-
all -- they must be excluded from both loss and metrics, or a model that
|
| 15 |
-
trivially gets the black border right looks better than it is. IDRiD's
|
| 16 |
-
lesion harness has no equivalent concept.
|
| 17 |
-
|
| 18 |
-
DRIVE's own public distribution ships vessel ground truth (`1st_manual/`)
|
| 19 |
-
only for the 20 "training" images -- the 20 "test" images have no publicly
|
| 20 |
-
released annotations (confirmed directly: `data/raw/drive/test/` contains
|
| 21 |
-
only `images/` and `mask/`, no manual-annotation directory). All 20 labelled
|
| 22 |
-
images are therefore used for k-fold cross-validation; there is no separate
|
| 23 |
-
locked test split for this lesion type the way IDRiD has one for hard
|
| 24 |
-
exudates.
|
| 25 |
-
"""
|
| 26 |
-
|
| 27 |
-
from __future__ import annotations
|
| 28 |
-
|
| 29 |
-
from dataclasses import dataclass
|
| 30 |
-
from pathlib import Path
|
| 31 |
-
|
| 32 |
-
import albumentations as A
|
| 33 |
-
import cv2
|
| 34 |
-
import lightning as L
|
| 35 |
-
import numpy as np
|
| 36 |
-
import torch
|
| 37 |
-
import torch.nn.functional as F
|
| 38 |
-
from albumentations.pytorch import ToTensorV2
|
| 39 |
-
from torch.utils.data import Dataset
|
| 40 |
-
|
| 41 |
-
from drdetect.segmentation.metrics import dice_coefficient, pixel_auroc
|
| 42 |
-
|
| 43 |
-
__all__ = [
|
| 44 |
-
"DriveVesselPair",
|
| 45 |
-
"find_drive_vessel_pairs",
|
| 46 |
-
"DriveVesselDataset",
|
| 47 |
-
"VesselSegmentationModule",
|
| 48 |
-
]
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
@dataclass(frozen=True)
|
| 52 |
-
class DriveVesselPair:
|
| 53 |
-
image_id: str
|
| 54 |
-
image_path: Path
|
| 55 |
-
mask_path: Path
|
| 56 |
-
fov_path: Path
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
def find_drive_vessel_pairs(drive_root: str | Path) -> list[DriveVesselPair]:
|
| 60 |
-
"""The 20 DRIVE "training" images -- the only ones with public vessel
|
| 61 |
-
ground truth. Numbered 21-40 in DRIVE's own convention."""
|
| 62 |
-
root = Path(drive_root) / "training"
|
| 63 |
-
image_dir, mask_dir, fov_dir = root / "images", root / "1st_manual", root / "mask"
|
| 64 |
-
pairs = []
|
| 65 |
-
for image_path in sorted(image_dir.glob("*.tif")):
|
| 66 |
-
image_id = image_path.stem.split("_")[0]
|
| 67 |
-
mask_path = mask_dir / f"{image_id}_manual1.gif"
|
| 68 |
-
fov_path = fov_dir / f"{image_id}_training_mask.gif"
|
| 69 |
-
if mask_path.exists() and fov_path.exists():
|
| 70 |
-
pairs.append(
|
| 71 |
-
DriveVesselPair(
|
| 72 |
-
image_id=image_id, image_path=image_path, mask_path=mask_path, fov_path=fov_path
|
| 73 |
-
)
|
| 74 |
-
)
|
| 75 |
-
return pairs
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
def _pad_to_multiple(arr: np.ndarray, multiple: int = 32, *, reflect: bool) -> np.ndarray:
|
| 79 |
-
"""Pad to a SQUARE size (both axes padded to the same target, from the
|
| 80 |
-
larger of the two), not just each axis to its own nearest multiple.
|
| 81 |
-
|
| 82 |
-
DRIVE images are rectangular (584x565) and `RandomRotate90` is one of
|
| 83 |
-
this dataset's augmentations -- a 90 degree turn swaps height and width,
|
| 84 |
-
so a rectangular pad target produces a *different* output shape for a
|
| 85 |
-
rotated image than an unrotated one in the same batch, and
|
| 86 |
-
`torch.stack` cannot collate a batch of mismatched shapes. Padding both
|
| 87 |
-
axes to one shared square target makes every rotation shape-invariant.
|
| 88 |
-
"""
|
| 89 |
-
h, w = arr.shape[:2]
|
| 90 |
-
target = -(-max(h, w) // multiple) * multiple # round up to the next multiple
|
| 91 |
-
ph, pw = target - h, target - w
|
| 92 |
-
border = cv2.BORDER_REFLECT_101 if reflect else cv2.BORDER_CONSTANT
|
| 93 |
-
return cv2.copyMakeBorder(arr, 0, ph, 0, pw, border, value=0)
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
def build_vessel_transforms(train: bool) -> A.Compose:
|
| 97 |
-
"""Same reasoning as the grading and lesion pipelines: flips/rotation are
|
| 98 |
-
label-preserving for a fundus with no canonical orientation; no blur,
|
| 99 |
-
since vessels include capillary-scale structures a blur would erase."""
|
| 100 |
-
normalise = A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225))
|
| 101 |
-
if not train:
|
| 102 |
-
return A.Compose([normalise, ToTensorV2()], additional_targets={"fov": "mask"})
|
| 103 |
-
return A.Compose(
|
| 104 |
-
[
|
| 105 |
-
A.HorizontalFlip(p=0.5),
|
| 106 |
-
A.VerticalFlip(p=0.5),
|
| 107 |
-
A.RandomRotate90(p=0.5),
|
| 108 |
-
normalise,
|
| 109 |
-
ToTensorV2(),
|
| 110 |
-
],
|
| 111 |
-
additional_targets={"fov": "mask"},
|
| 112 |
-
)
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
class DriveVesselDataset(Dataset):
|
| 116 |
-
"""One full (padded) DRIVE image per item -- no patch sampling, unlike
|
| 117 |
-
the IDRiD lesion dataset, since these images are already small enough
|
| 118 |
-
to train on whole."""
|
| 119 |
-
|
| 120 |
-
def __init__(self, pairs: list[DriveVesselPair], *, train: bool = True):
|
| 121 |
-
self.pairs = pairs
|
| 122 |
-
self.transform = build_vessel_transforms(train)
|
| 123 |
-
|
| 124 |
-
def __len__(self) -> int:
|
| 125 |
-
return len(self.pairs)
|
| 126 |
-
|
| 127 |
-
def __getitem__(self, idx: int):
|
| 128 |
-
pair = self.pairs[idx]
|
| 129 |
-
image = cv2.cvtColor(cv2.imread(str(pair.image_path), cv2.IMREAD_COLOR), cv2.COLOR_BGR2RGB)
|
| 130 |
-
mask = (cv2.imread(str(pair.mask_path), cv2.IMREAD_GRAYSCALE) > 0).astype(np.uint8)
|
| 131 |
-
fov = (cv2.imread(str(pair.fov_path), cv2.IMREAD_GRAYSCALE) > 0).astype(np.uint8)
|
| 132 |
-
|
| 133 |
-
# Reflect-pad the image (real content), zero-pad mask/FOV (synthetic
|
| 134 |
-
# border pixels are never vessel and never inside the field of view,
|
| 135 |
-
# so this makes the padding automatically excluded everywhere the
|
| 136 |
-
# FOV mask is already used to exclude the real black surround).
|
| 137 |
-
image = _pad_to_multiple(image, reflect=True)
|
| 138 |
-
mask = _pad_to_multiple(mask, reflect=False)
|
| 139 |
-
fov = _pad_to_multiple(fov, reflect=False)
|
| 140 |
-
|
| 141 |
-
out = self.transform(image=image, mask=mask, fov=fov)
|
| 142 |
-
return out["image"], out["mask"].float().unsqueeze(0), out["fov"].float().unsqueeze(0)
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
class VesselSegmentationModule(L.LightningModule):
|
| 146 |
-
def __init__(
|
| 147 |
-
self,
|
| 148 |
-
model,
|
| 149 |
-
*,
|
| 150 |
-
lr: float = 1e-4,
|
| 151 |
-
weight_decay: float = 1e-4,
|
| 152 |
-
pos_weight: float = 1.0,
|
| 153 |
-
dice_weight: float = 1.0,
|
| 154 |
-
max_epochs: int = 40,
|
| 155 |
-
):
|
| 156 |
-
super().__init__()
|
| 157 |
-
self.save_hyperparameters(ignore=["model"])
|
| 158 |
-
self.model = model
|
| 159 |
-
self.register_buffer("_pos_weight", torch.tensor(pos_weight))
|
| 160 |
-
self._val_probs: list[torch.Tensor] = []
|
| 161 |
-
self._val_targets: list[torch.Tensor] = []
|
| 162 |
-
self._val_fov: list[torch.Tensor] = []
|
| 163 |
-
|
| 164 |
-
def forward(self, x):
|
| 165 |
-
return self.model(x)
|
| 166 |
-
|
| 167 |
-
def _loss(self, logits: torch.Tensor, targets: torch.Tensor, fov: torch.Tensor) -> torch.Tensor:
|
| 168 |
-
bce_per_pixel = F.binary_cross_entropy_with_logits(
|
| 169 |
-
logits, targets, pos_weight=self._pos_weight, reduction="none"
|
| 170 |
-
)
|
| 171 |
-
bce = (bce_per_pixel * fov).sum() / fov.sum().clamp(min=1.0)
|
| 172 |
-
|
| 173 |
-
probs = torch.sigmoid(logits) * fov
|
| 174 |
-
targets_in_fov = targets * fov
|
| 175 |
-
intersection = (probs * targets_in_fov).sum(dim=(1, 2, 3))
|
| 176 |
-
union = probs.sum(dim=(1, 2, 3)) + targets_in_fov.sum(dim=(1, 2, 3))
|
| 177 |
-
dice_loss = 1.0 - ((2.0 * intersection + 1.0) / (union + 1.0)).mean()
|
| 178 |
-
|
| 179 |
-
return bce + self.hparams.dice_weight * dice_loss
|
| 180 |
-
|
| 181 |
-
def training_step(self, batch, batch_idx):
|
| 182 |
-
x, y, fov = batch
|
| 183 |
-
logits = self(x)
|
| 184 |
-
loss = self._loss(logits, y, fov)
|
| 185 |
-
if not torch.isfinite(loss):
|
| 186 |
-
raise RuntimeError(
|
| 187 |
-
f"Vessel segmentation loss is {loss.item()} at step {batch_idx}. Diverged."
|
| 188 |
-
)
|
| 189 |
-
self.log("train/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
|
| 190 |
-
return loss
|
| 191 |
-
|
| 192 |
-
def validation_step(self, batch, batch_idx):
|
| 193 |
-
x, y, fov = batch
|
| 194 |
-
logits = self(x)
|
| 195 |
-
loss = self._loss(logits, y, fov)
|
| 196 |
-
self.log("val/loss", loss, prog_bar=True, on_step=False, on_epoch=True)
|
| 197 |
-
self._val_probs.append(torch.sigmoid(logits).detach().float().cpu())
|
| 198 |
-
self._val_targets.append(y.detach().cpu())
|
| 199 |
-
self._val_fov.append(fov.detach().cpu())
|
| 200 |
-
|
| 201 |
-
def on_validation_epoch_end(self):
|
| 202 |
-
if not self._val_probs:
|
| 203 |
-
return
|
| 204 |
-
probs = torch.cat(self._val_probs).numpy()
|
| 205 |
-
targets = torch.cat(self._val_targets).numpy()
|
| 206 |
-
fov = torch.cat(self._val_fov).numpy().astype(bool)
|
| 207 |
-
|
| 208 |
-
in_fov_probs = probs[fov]
|
| 209 |
-
in_fov_targets = targets[fov]
|
| 210 |
-
if in_fov_targets.sum() > 0:
|
| 211 |
-
auroc = pixel_auroc(in_fov_targets, in_fov_probs)
|
| 212 |
-
self.log("val/auroc", auroc, prog_bar=True)
|
| 213 |
-
dice = dice_coefficient(in_fov_targets, in_fov_probs > 0.5)
|
| 214 |
-
self.log("val/dice", dice, prog_bar=True)
|
| 215 |
-
|
| 216 |
-
self._val_probs.clear()
|
| 217 |
-
self._val_targets.clear()
|
| 218 |
-
self._val_fov.clear()
|
| 219 |
-
|
| 220 |
-
def configure_optimizers(self):
|
| 221 |
-
optimiser = torch.optim.AdamW(
|
| 222 |
-
self.model.parameters(), lr=self.hparams.lr, weight_decay=self.hparams.weight_decay
|
| 223 |
-
)
|
| 224 |
-
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
|
| 225 |
-
optimiser, T_max=self.hparams.max_epochs
|
| 226 |
-
)
|
| 227 |
-
return {
|
| 228 |
-
"optimizer": optimiser,
|
| 229 |
-
"lr_scheduler": {"scheduler": scheduler, "interval": "epoch"},
|
| 230 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/src/drdetect/serve/__init__.py
DELETED
|
File without changes
|
src/src/drdetect/serve/api.py
DELETED
|
@@ -1,123 +0,0 @@
|
|
| 1 |
-
"""FastAPI service wrapping the Phase 2 pipeline (docs/04_ROADMAP.md, Phase 9).
|
| 2 |
-
|
| 3 |
-
CPU-only, single global model instance loaded once at startup -- the same
|
| 4 |
-
design constraint `scripts/predict.py` and `load_grader` already state
|
| 5 |
-
explicitly: a district server is not assumed to have a GPU. This is a
|
| 6 |
-
research prototype, not a medical device (docs/01_PROJECT_ANALYSIS.md §11);
|
| 7 |
-
every response carries that disclaimer rather than only the PDF report doing so.
|
| 8 |
-
|
| 9 |
-
Run locally:
|
| 10 |
-
uvicorn drdetect.serve.api:app --host 0.0.0.0 --port 8000
|
| 11 |
-
|
| 12 |
-
Configuration is via environment variables (not CLI flags) because this is
|
| 13 |
-
meant to run inside a container (Dockerfile) where env vars are the natural
|
| 14 |
-
knob, not argv:
|
| 15 |
-
DRDETECT_CHECKPOINT path to the grading checkpoint (required)
|
| 16 |
-
DRDETECT_BACKBONE default: efficientnet_b0
|
| 17 |
-
DRDETECT_LOSS default: ce
|
| 18 |
-
DRDETECT_SIZE default: 512
|
| 19 |
-
"""
|
| 20 |
-
|
| 21 |
-
from __future__ import annotations
|
| 22 |
-
|
| 23 |
-
import os
|
| 24 |
-
from contextlib import asynccontextmanager
|
| 25 |
-
from pathlib import Path
|
| 26 |
-
from typing import Any
|
| 27 |
-
|
| 28 |
-
import cv2
|
| 29 |
-
import numpy as np
|
| 30 |
-
from fastapi import FastAPI, File, HTTPException, UploadFile
|
| 31 |
-
from pydantic import BaseModel
|
| 32 |
-
|
| 33 |
-
from drdetect.calibration.temperature import load_temperature
|
| 34 |
-
from drdetect.serve.pipeline import load_grader, run_pipeline
|
| 35 |
-
|
| 36 |
-
NOT_A_MEDICAL_DEVICE = (
|
| 37 |
-
"Research prototype only. NOT a medical device and NOT a substitute for "
|
| 38 |
-
"clinical judgement. Every result requires confirmation by a qualified "
|
| 39 |
-
"ophthalmologist before any care decision is made."
|
| 40 |
-
)
|
| 41 |
-
|
| 42 |
-
_state: dict[str, Any] = {}
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
@asynccontextmanager
|
| 46 |
-
async def lifespan(app: FastAPI):
|
| 47 |
-
checkpoint = os.environ.get("DRDETECT_CHECKPOINT")
|
| 48 |
-
if not checkpoint or not Path(checkpoint).exists():
|
| 49 |
-
raise RuntimeError(
|
| 50 |
-
"DRDETECT_CHECKPOINT env var must point to an existing checkpoint file. "
|
| 51 |
-
f"Got: {checkpoint!r}"
|
| 52 |
-
)
|
| 53 |
-
backbone = os.environ.get("DRDETECT_BACKBONE", "efficientnet_b0")
|
| 54 |
-
loss_name = os.environ.get("DRDETECT_LOSS", "ce")
|
| 55 |
-
_state["size"] = int(os.environ.get("DRDETECT_SIZE", "512"))
|
| 56 |
-
_state["loss_name"] = loss_name
|
| 57 |
-
_state["checkpoint"] = checkpoint
|
| 58 |
-
_state["model"] = load_grader(checkpoint, backbone=backbone, loss_name=loss_name, device="cpu")
|
| 59 |
-
_state["temperature"] = load_temperature(checkpoint)
|
| 60 |
-
yield
|
| 61 |
-
_state.clear()
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
app = FastAPI(
|
| 65 |
-
title="drdetect: DR screening API",
|
| 66 |
-
description=NOT_A_MEDICAL_DEVICE,
|
| 67 |
-
version="0.1.0",
|
| 68 |
-
lifespan=lifespan,
|
| 69 |
-
)
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
class GradeResponse(BaseModel):
|
| 73 |
-
usable: bool
|
| 74 |
-
quality_reasons: list[str]
|
| 75 |
-
grade: int | None = None
|
| 76 |
-
grade_name: str | None = None
|
| 77 |
-
referable: bool | None = None
|
| 78 |
-
confidence: float | None = None
|
| 79 |
-
calibrated: bool = False
|
| 80 |
-
class_probs: list[float] | None = None
|
| 81 |
-
disclaimer: str = NOT_A_MEDICAL_DEVICE
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
@app.get("/health")
|
| 85 |
-
def health() -> dict:
|
| 86 |
-
return {
|
| 87 |
-
"status": "ok",
|
| 88 |
-
"checkpoint": _state.get("checkpoint"),
|
| 89 |
-
"disclaimer": NOT_A_MEDICAL_DEVICE,
|
| 90 |
-
}
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
@app.post("/grade", response_model=GradeResponse)
|
| 94 |
-
async def grade(image: UploadFile = File(...)) -> GradeResponse: # noqa: B008 -- FastAPI's own required-file-upload idiom
|
| 95 |
-
if "model" not in _state:
|
| 96 |
-
raise HTTPException(status_code=503, detail="Model not loaded")
|
| 97 |
-
|
| 98 |
-
raw_bytes = await image.read()
|
| 99 |
-
arr = np.frombuffer(raw_bytes, dtype=np.uint8)
|
| 100 |
-
bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
| 101 |
-
if bgr is None:
|
| 102 |
-
raise HTTPException(status_code=400, detail="Could not decode image")
|
| 103 |
-
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
| 104 |
-
|
| 105 |
-
result = run_pipeline(
|
| 106 |
-
rgb,
|
| 107 |
-
_state["model"],
|
| 108 |
-
loss_name=_state["loss_name"],
|
| 109 |
-
size=_state["size"],
|
| 110 |
-
device="cpu",
|
| 111 |
-
temperature=_state["temperature"],
|
| 112 |
-
)
|
| 113 |
-
|
| 114 |
-
return GradeResponse(
|
| 115 |
-
usable=result.quality.usable,
|
| 116 |
-
quality_reasons=result.quality.reasons,
|
| 117 |
-
grade=result.grade,
|
| 118 |
-
grade_name=result.grade_name,
|
| 119 |
-
referable=result.referable,
|
| 120 |
-
confidence=result.confidence,
|
| 121 |
-
calibrated=result.calibrated,
|
| 122 |
-
class_probs=result.class_probs,
|
| 123 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|