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