Spaces:
Sleeping
Sleeping
Deploy inference service from GitHub Actions
Browse files- Dockerfile +0 -5
- README.md +4 -0
- inference/Dockerfile +0 -5
- inference/__init__.py +1 -0
- inference/app.py +144 -165
- inference/gradcam.py +40 -66
- inference/model_loader.py +11 -17
- inference/predict.py +109 -90
- inference/requirements.txt +2 -0
- training/config.yaml +3 -5
- training/cross_validation.py +12 -57
- training/evaluation/metrics.py +6 -13
- training/models/convnext_tiny.py +3 -9
- training/models/efficientnet_b4.py +6 -33
- training/models/efficientnetv2_s.py +3 -10
- training/models/ensemble.py +15 -51
- training/push_model_to_hf.py +15 -49
- training/train.py +26 -81
- training/utils/augmentation.py +12 -17
- training/utils/dataset.py +2 -3
- training/utils/preprocessing.py +5 -0
Dockerfile
CHANGED
|
@@ -3,23 +3,18 @@ FROM python:3.11-slim
|
|
| 3 |
|
| 4 |
WORKDIR /app
|
| 5 |
|
| 6 |
-
# System deps for OpenCV
|
| 7 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 8 |
libglib2.0-0 libsm6 libxrender1 libxext6 curl \
|
| 9 |
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
|
| 11 |
-
# Install Python deps
|
| 12 |
COPY inference/requirements.txt requirements.txt
|
| 13 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 14 |
|
| 15 |
-
# Copy training package (needed for model classes)
|
| 16 |
COPY training/ training/
|
| 17 |
COPY inference/ inference/
|
| 18 |
|
| 19 |
-
# Expose port
|
| 20 |
EXPOSE 7860
|
| 21 |
|
| 22 |
-
# Health check
|
| 23 |
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
| 24 |
CMD sh -c 'curl -f "http://localhost:${PORT:-7860}/health" || exit 1'
|
| 25 |
|
|
|
|
| 3 |
|
| 4 |
WORKDIR /app
|
| 5 |
|
|
|
|
| 6 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
libglib2.0-0 libsm6 libxrender1 libxext6 curl \
|
| 8 |
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
|
|
|
|
| 10 |
COPY inference/requirements.txt requirements.txt
|
| 11 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
|
|
|
|
| 13 |
COPY training/ training/
|
| 14 |
COPY inference/ inference/
|
| 15 |
|
|
|
|
| 16 |
EXPOSE 7860
|
| 17 |
|
|
|
|
| 18 |
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
| 19 |
CMD sh -c 'curl -f "http://localhost:${PORT:-7860}/health" || exit 1'
|
| 20 |
|
README.md
CHANGED
|
@@ -12,3 +12,7 @@ license: mit
|
|
| 12 |
# AnemiaScan
|
| 13 |
|
| 14 |
Research-only non-invasive anemia screening demo.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
# AnemiaScan
|
| 13 |
|
| 14 |
Research-only non-invasive anemia screening demo.
|
| 15 |
+
|
| 16 |
+
Concept, design, build, training, deployment, testing by: Dr Siddalingaiah H S, Professor, Community Medicine, Shridevi Institute of Medical Sciences and Research Hospital, Tumkur, hssling@yahoo.com, 8941087719.
|
| 17 |
+
|
| 18 |
+
ORCID: 0000-0002-4771-8285
|
inference/Dockerfile
CHANGED
|
@@ -3,23 +3,18 @@ FROM python:3.11-slim
|
|
| 3 |
|
| 4 |
WORKDIR /app
|
| 5 |
|
| 6 |
-
# System deps for OpenCV
|
| 7 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 8 |
libglib2.0-0 libsm6 libxrender1 libxext6 curl \
|
| 9 |
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
|
| 11 |
-
# Install Python deps
|
| 12 |
COPY inference/requirements.txt requirements.txt
|
| 13 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 14 |
|
| 15 |
-
# Copy training package (needed for model classes)
|
| 16 |
COPY training/ training/
|
| 17 |
COPY inference/ inference/
|
| 18 |
|
| 19 |
-
# Expose port
|
| 20 |
EXPOSE 7860
|
| 21 |
|
| 22 |
-
# Health check
|
| 23 |
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
| 24 |
CMD sh -c 'curl -f "http://localhost:${PORT:-7860}/health" || exit 1'
|
| 25 |
|
|
|
|
| 3 |
|
| 4 |
WORKDIR /app
|
| 5 |
|
|
|
|
| 6 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
libglib2.0-0 libsm6 libxrender1 libxext6 curl \
|
| 8 |
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
|
|
|
|
| 10 |
COPY inference/requirements.txt requirements.txt
|
| 11 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
|
|
|
|
| 13 |
COPY training/ training/
|
| 14 |
COPY inference/ inference/
|
| 15 |
|
|
|
|
| 16 |
EXPOSE 7860
|
| 17 |
|
|
|
|
| 18 |
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
| 19 |
CMD sh -c 'curl -f "http://localhost:${PORT:-7860}/health" || exit 1'
|
| 20 |
|
inference/__init__.py
CHANGED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# inference/__init__.py
|
inference/app.py
CHANGED
|
@@ -1,16 +1,12 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
AnemiaScan Inference Server -- FastAPI + Gradio
|
| 4 |
-
|
| 5 |
-
Routes:
|
| 6 |
-
GET /health -> {"status": "ok", "models_loaded": [...]}
|
| 7 |
-
POST /api/predict -> JSON prediction result
|
| 8 |
-
GET /demo -> Gradio demo interface
|
| 9 |
-
"""
|
| 10 |
|
|
|
|
| 11 |
import io
|
| 12 |
import logging
|
| 13 |
import os
|
|
|
|
|
|
|
| 14 |
|
| 15 |
import gradio as gr
|
| 16 |
import uvicorn
|
|
@@ -20,218 +16,201 @@ from fastapi.responses import RedirectResponse
|
|
| 20 |
from PIL import Image
|
| 21 |
|
| 22 |
from inference.gradcam import generate_gradcam
|
| 23 |
-
from inference.model_loader import load_model,
|
| 24 |
from inference.predict import preprocess_image, run_full_prediction
|
| 25 |
|
| 26 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 27 |
log = logging.getLogger(__name__)
|
| 28 |
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
title="AnemiaScan Inference API",
|
| 31 |
-
description="Non-invasive anemia screening from
|
| 32 |
-
version="
|
| 33 |
)
|
| 34 |
|
| 35 |
-
|
| 36 |
CORSMiddleware,
|
| 37 |
-
allow_origins=["*"],
|
| 38 |
allow_methods=["GET", "POST"],
|
| 39 |
allow_headers=["*"],
|
| 40 |
)
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
.
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
@
|
| 111 |
-
def health():
|
| 112 |
-
from inference.model_loader import _MODEL_CACHE
|
| 113 |
-
|
| 114 |
-
return {"status": "ok", "models_loaded": list(_MODEL_CACHE.keys())}
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
@app.get("/")
|
| 118 |
-
def root():
|
| 119 |
-
return RedirectResponse(url="/demo/")
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
@app.post("/api/predict")
|
| 123 |
async def predict(
|
| 124 |
conjunctiva_image: UploadFile | None = File(default=None),
|
| 125 |
nailbed_image: UploadFile | None = File(default=None),
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
)
|
| 129 |
-
|
| 130 |
-
raise HTTPException(status_code=400, detail="Provide at least one image.")
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
if conjunctiva_image is not None:
|
| 136 |
-
raw = await conjunctiva_image.read()
|
| 137 |
-
conj_pil = _open_image(raw)
|
| 138 |
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
raise HTTPException(status_code=503, detail=f"Model loading failed: {e}")
|
| 148 |
|
| 149 |
try:
|
| 150 |
result = run_full_prediction(
|
| 151 |
-
conj_img=
|
| 152 |
-
nail_img=
|
| 153 |
conj_model=conj_model,
|
| 154 |
nail_model=nail_model,
|
| 155 |
w_conj=W_CONJ,
|
| 156 |
w_nail=W_NAIL,
|
| 157 |
)
|
| 158 |
-
except ValueError as
|
| 159 |
-
raise HTTPException(status_code=400, detail=str(
|
| 160 |
-
except Exception:
|
| 161 |
-
log.exception("Prediction failed")
|
| 162 |
-
raise HTTPException(status_code=500, detail="Internal prediction error.")
|
| 163 |
-
|
| 164 |
-
if include_gradcam.lower() == "true":
|
| 165 |
-
try:
|
| 166 |
-
gc_img = conj_pil if conj_pil else nail_pil
|
| 167 |
-
gc_site = "conjunctiva" if conj_pil else "nailbed"
|
| 168 |
-
gc_model = load_model(gc_site)
|
| 169 |
-
gc_tensor = preprocess_image(gc_img)
|
| 170 |
-
result["gradcam_b64"] = generate_gradcam(gc_model, gc_tensor)
|
| 171 |
-
except Exception as e:
|
| 172 |
-
log.warning(f"Grad-CAM generation failed: {e}")
|
| 173 |
-
result["gradcam_b64"] = None
|
| 174 |
|
| 175 |
-
|
|
|
|
| 176 |
|
| 177 |
|
| 178 |
-
def gradio_predict(conj_img, nail_img):
|
| 179 |
-
"""Gradio wrapper for the prediction endpoint."""
|
| 180 |
conj_pil = Image.fromarray(conj_img) if conj_img is not None else None
|
| 181 |
nail_pil = Image.fromarray(nail_img) if nail_img is not None else None
|
| 182 |
|
| 183 |
-
if conj_pil is None and nail_pil is None:
|
| 184 |
-
return "Please upload at least one image.", {}
|
| 185 |
-
|
| 186 |
try:
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
result = run_full_prediction(
|
| 191 |
-
conj_img=
|
| 192 |
-
nail_img=
|
| 193 |
conj_model=conj_model,
|
| 194 |
nail_model=nail_model,
|
| 195 |
w_conj=W_CONJ,
|
| 196 |
w_nail=W_NAIL,
|
| 197 |
)
|
| 198 |
-
|
| 199 |
-
|
|
|
|
| 200 |
|
| 201 |
summary = (
|
| 202 |
-
f"**Hb Estimate:** {result['hb_estimate']} g/dL
|
| 203 |
-
f"
|
| 204 |
-
f"**Classification:** {result['classification'].
|
| 205 |
-
f"
|
| 206 |
)
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
|
| 209 |
|
| 210 |
demo = gr.Interface(
|
| 211 |
fn=gradio_predict,
|
| 212 |
inputs=[
|
| 213 |
-
gr.Image(label="Conjunctiva Image
|
| 214 |
-
gr.Image(label="Nail-bed Image
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
],
|
| 216 |
outputs=[
|
| 217 |
gr.Markdown(label="Result"),
|
| 218 |
gr.Label(label="Class Probabilities", num_top_classes=4),
|
|
|
|
| 219 |
],
|
| 220 |
-
title="AnemiaScan
|
| 221 |
description=(
|
| 222 |
-
"Upload
|
| 223 |
-
"
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
),
|
| 231 |
-
css=SPACE_CSS,
|
| 232 |
)
|
| 233 |
|
| 234 |
-
app = gr.mount_gradio_app(
|
|
|
|
| 235 |
|
| 236 |
if __name__ == "__main__":
|
| 237 |
-
uvicorn.run("inference.app:app", host="0.0.0.0", port=
|
|
|
|
| 1 |
+
"""FastAPI inference service with a mounted Gradio demo."""
|
| 2 |
+
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
+
import base64
|
| 5 |
import io
|
| 6 |
import logging
|
| 7 |
import os
|
| 8 |
+
from contextlib import suppress
|
| 9 |
+
from typing import Any
|
| 10 |
|
| 11 |
import gradio as gr
|
| 12 |
import uvicorn
|
|
|
|
| 16 |
from PIL import Image
|
| 17 |
|
| 18 |
from inference.gradcam import generate_gradcam
|
| 19 |
+
from inference.model_loader import _MODEL_CACHE, load_model, preload_available_models
|
| 20 |
from inference.predict import preprocess_image, run_full_prediction
|
| 21 |
|
| 22 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 23 |
log = logging.getLogger(__name__)
|
| 24 |
|
| 25 |
+
PORT = int(os.getenv("PORT", "7860"))
|
| 26 |
+
W_CONJ = float(os.getenv("ENSEMBLE_W_CONJ", "1.0"))
|
| 27 |
+
W_NAIL = float(os.getenv("ENSEMBLE_W_NAIL", "0.0"))
|
| 28 |
+
|
| 29 |
+
base_app = FastAPI(
|
| 30 |
title="AnemiaScan Inference API",
|
| 31 |
+
description="Non-invasive anemia screening from conjunctiva and nail-bed images",
|
| 32 |
+
version="0.4.0",
|
| 33 |
)
|
| 34 |
|
| 35 |
+
base_app.add_middleware(
|
| 36 |
CORSMiddleware,
|
| 37 |
+
allow_origins=["*"],
|
| 38 |
allow_methods=["GET", "POST"],
|
| 39 |
allow_headers=["*"],
|
| 40 |
)
|
| 41 |
|
| 42 |
+
|
| 43 |
+
def _read_upload_as_pil(upload: UploadFile | None) -> Image.Image | None:
|
| 44 |
+
if upload is None:
|
| 45 |
+
return None
|
| 46 |
+
return Image.open(io.BytesIO(upload.file.read())).convert("RGB")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _try_load_model(site: str):
|
| 50 |
+
with suppress(Exception):
|
| 51 |
+
return load_model(site)
|
| 52 |
+
return None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _select_inputs_for_mode(
|
| 56 |
+
mode: str,
|
| 57 |
+
conj_img: Image.Image | None,
|
| 58 |
+
nail_img: Image.Image | None,
|
| 59 |
+
) -> tuple[Image.Image | None, Image.Image | None]:
|
| 60 |
+
normalized = mode.lower().strip()
|
| 61 |
+
if normalized == "conjunctiva":
|
| 62 |
+
return conj_img, None
|
| 63 |
+
if normalized == "nailbed":
|
| 64 |
+
return None, nail_img
|
| 65 |
+
return conj_img, nail_img
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _augment_with_gradcam(
|
| 69 |
+
result: dict[str, Any],
|
| 70 |
+
conj_img: Image.Image | None,
|
| 71 |
+
nail_img: Image.Image | None,
|
| 72 |
+
conj_model,
|
| 73 |
+
nail_model,
|
| 74 |
+
) -> dict[str, Any]:
|
| 75 |
+
primary_img = conj_img if conj_img is not None else nail_img
|
| 76 |
+
primary_model = conj_model if conj_img is not None else nail_model
|
| 77 |
+
if primary_img is None or primary_model is None:
|
| 78 |
+
result["gradcam_b64"] = None
|
| 79 |
+
return result
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
gradcam_tensor = preprocess_image(primary_img)
|
| 83 |
+
result["gradcam_b64"] = generate_gradcam(primary_model, gradcam_tensor)
|
| 84 |
+
except Exception as exc:
|
| 85 |
+
log.warning("Grad-CAM generation failed: %s", exc)
|
| 86 |
+
result["gradcam_b64"] = None
|
| 87 |
+
return result
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@base_app.on_event("startup")
|
| 91 |
+
async def startup_event() -> None:
|
| 92 |
+
log.info("Preloading available inference models")
|
| 93 |
+
preload_available_models()
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@base_app.get("/")
|
| 97 |
+
def root() -> RedirectResponse:
|
| 98 |
+
return RedirectResponse(url="/demo")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@base_app.get("/health")
|
| 102 |
+
def health() -> dict[str, Any]:
|
| 103 |
+
return {
|
| 104 |
+
"status": "ok",
|
| 105 |
+
"models_loaded": sorted(_MODEL_CACHE.keys()),
|
| 106 |
+
"default_mode": "conjunctiva",
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@base_app.post("/api/predict")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
async def predict(
|
| 112 |
conjunctiva_image: UploadFile | None = File(default=None),
|
| 113 |
nailbed_image: UploadFile | None = File(default=None),
|
| 114 |
+
model: str = Form(default="ensemble"),
|
| 115 |
+
) -> dict[str, Any]:
|
| 116 |
+
conj_img = _read_upload_as_pil(conjunctiva_image)
|
| 117 |
+
nail_img = _read_upload_as_pil(nailbed_image)
|
|
|
|
| 118 |
|
| 119 |
+
if conj_img is None and nail_img is None:
|
| 120 |
+
raise HTTPException(status_code=400, detail="Provide at least one image.")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
+
conj_img, nail_img = _select_inputs_for_mode(model, conj_img, nail_img)
|
| 123 |
+
conj_model = _try_load_model("conjunctiva") if conj_img is not None else None
|
| 124 |
+
nail_model = _try_load_model("nailbed") if nail_img is not None else None
|
| 125 |
|
| 126 |
+
if conj_img is not None and conj_model is None and nail_img is None:
|
| 127 |
+
raise HTTPException(status_code=503, detail="Conjunctiva model is unavailable.")
|
| 128 |
+
if nail_img is not None and nail_model is None and conj_img is None:
|
| 129 |
+
raise HTTPException(status_code=503, detail="Nail-bed model is unavailable.")
|
|
|
|
| 130 |
|
| 131 |
try:
|
| 132 |
result = run_full_prediction(
|
| 133 |
+
conj_img=conj_img,
|
| 134 |
+
nail_img=nail_img,
|
| 135 |
conj_model=conj_model,
|
| 136 |
nail_model=nail_model,
|
| 137 |
w_conj=W_CONJ,
|
| 138 |
w_nail=W_NAIL,
|
| 139 |
)
|
| 140 |
+
except ValueError as exc:
|
| 141 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
+
result["requested_mode"] = model
|
| 144 |
+
return _augment_with_gradcam(result, conj_img, nail_img, conj_model, nail_model)
|
| 145 |
|
| 146 |
|
| 147 |
+
def gradio_predict(conj_img, nail_img, mode):
|
|
|
|
| 148 |
conj_pil = Image.fromarray(conj_img) if conj_img is not None else None
|
| 149 |
nail_pil = Image.fromarray(nail_img) if nail_img is not None else None
|
| 150 |
|
|
|
|
|
|
|
|
|
|
| 151 |
try:
|
| 152 |
+
selected_conj, selected_nail = _select_inputs_for_mode(mode, conj_pil, nail_pil)
|
| 153 |
+
conj_model = _try_load_model("conjunctiva") if selected_conj is not None else None
|
| 154 |
+
nail_model = _try_load_model("nailbed") if selected_nail is not None else None
|
| 155 |
result = run_full_prediction(
|
| 156 |
+
conj_img=selected_conj,
|
| 157 |
+
nail_img=selected_nail,
|
| 158 |
conj_model=conj_model,
|
| 159 |
nail_model=nail_model,
|
| 160 |
w_conj=W_CONJ,
|
| 161 |
w_nail=W_NAIL,
|
| 162 |
)
|
| 163 |
+
result = _augment_with_gradcam(result, selected_conj, selected_nail, conj_model, nail_model)
|
| 164 |
+
except Exception as exc:
|
| 165 |
+
return f"Prediction failed: {exc}", {}, None
|
| 166 |
|
| 167 |
summary = (
|
| 168 |
+
f"**Hb Estimate:** {result['hb_estimate']} g/dL\n\n"
|
| 169 |
+
f"**95% CI:** {result['hb_ci_95'][0]} to {result['hb_ci_95'][1]}\n\n"
|
| 170 |
+
f"**Classification:** {result['classification'].title()}\n\n"
|
| 171 |
+
f"**Disclaimer:** {result['disclaimer']}"
|
| 172 |
)
|
| 173 |
+
gradcam_image = None
|
| 174 |
+
if result["gradcam_b64"] is not None:
|
| 175 |
+
gradcam_bytes = io.BytesIO()
|
| 176 |
+
gradcam_bytes.write(base64.b64decode(result["gradcam_b64"]))
|
| 177 |
+
gradcam_bytes.seek(0)
|
| 178 |
+
gradcam_image = Image.open(gradcam_bytes)
|
| 179 |
+
|
| 180 |
+
return summary, result["class_probabilities"], gradcam_image
|
| 181 |
|
| 182 |
|
| 183 |
demo = gr.Interface(
|
| 184 |
fn=gradio_predict,
|
| 185 |
inputs=[
|
| 186 |
+
gr.Image(label="Conjunctiva Image", type="numpy"),
|
| 187 |
+
gr.Image(label="Nail-bed Image", type="numpy"),
|
| 188 |
+
gr.Radio(
|
| 189 |
+
choices=["ensemble", "conjunctiva", "nailbed"],
|
| 190 |
+
value="conjunctiva",
|
| 191 |
+
label="Prediction mode",
|
| 192 |
+
),
|
| 193 |
],
|
| 194 |
outputs=[
|
| 195 |
gr.Markdown(label="Result"),
|
| 196 |
gr.Label(label="Class Probabilities", num_top_classes=4),
|
| 197 |
+
gr.Image(label="Grad-CAM", type="pil"),
|
| 198 |
],
|
| 199 |
+
title="AnemiaScan",
|
| 200 |
description=(
|
| 201 |
+
"Upload conjunctiva and/or nail-bed images for research-only Hb estimation.\n\n"
|
| 202 |
+
"Model summary: EfficientNet-B4 dual-head model, ImageNet-pretrained and fine-tuned on 380x380 RGB images. "
|
| 203 |
+
"Inference includes MC-dropout uncertainty and Grad-CAM explanations. "
|
| 204 |
+
"Tracked evaluation metrics include MAE, RMSE, Pearson r, AUC, F1, sensitivity, specificity, and Bland-Altman analysis. "
|
| 205 |
+
"Current live deployment loads conjunctiva weights first; nail-bed support follows once model weights are uploaded.\n\n"
|
| 206 |
+
"<sub>Concept, design, build, training, deployment, testing by: Dr Siddalingaiah H S, "
|
| 207 |
+
"Professor, Community Medicine, Shridevi Institute of Medical Sciences and Research "
|
| 208 |
+
"Hospital, Tumkur, hssling@yahoo.com, 8941087719. ORCID: 0000-0002-4771-8285.</sub>"
|
| 209 |
),
|
|
|
|
| 210 |
)
|
| 211 |
|
| 212 |
+
app = gr.mount_gradio_app(base_app, demo, path="/demo")
|
| 213 |
+
|
| 214 |
|
| 215 |
if __name__ == "__main__":
|
| 216 |
+
uvicorn.run("inference.app:app", host="0.0.0.0", port=PORT, reload=False)
|
inference/gradcam.py
CHANGED
|
@@ -1,12 +1,8 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
Grad-CAM heatmap generation for inference explanations.
|
| 4 |
-
Returns a base64-encoded PNG overlay for the API response.
|
| 5 |
-
"""
|
| 6 |
|
| 7 |
import base64
|
| 8 |
import io
|
| 9 |
-
import logging
|
| 10 |
|
| 11 |
import cv2
|
| 12 |
import numpy as np
|
|
@@ -14,7 +10,15 @@ import torch
|
|
| 14 |
import torch.nn.functional as F
|
| 15 |
from PIL import Image
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
def generate_gradcam(
|
|
@@ -22,89 +26,59 @@ def generate_gradcam(
|
|
| 22 |
image_tensor: torch.Tensor,
|
| 23 |
target_class: int | None = None,
|
| 24 |
) -> str:
|
| 25 |
-
"""
|
| 26 |
-
Generate a Grad-CAM heatmap for the given image and model.
|
| 27 |
-
|
| 28 |
-
Args:
|
| 29 |
-
model: AnemiaModel instance
|
| 30 |
-
image_tensor: (1, 3, H, W) preprocessed tensor
|
| 31 |
-
target_class: class index to visualise (default: argmax of classification head)
|
| 32 |
-
|
| 33 |
-
Returns:
|
| 34 |
-
Base64-encoded PNG string of the heatmap overlay.
|
| 35 |
-
"""
|
| 36 |
model.eval()
|
| 37 |
activations: dict[str, torch.Tensor] = {}
|
| 38 |
gradients: dict[str, torch.Tensor] = {}
|
| 39 |
|
| 40 |
target_layer = _get_last_conv_layer(model)
|
| 41 |
|
| 42 |
-
def forward_hook(
|
| 43 |
activations["value"] = output.detach()
|
| 44 |
|
| 45 |
-
def backward_hook(
|
| 46 |
gradients["value"] = grad_output[0].detach()
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
|
| 51 |
try:
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
if target_class is None:
|
| 56 |
target_class = int(cls_logits.argmax(dim=1).item())
|
| 57 |
|
| 58 |
-
score = cls_logits[
|
| 59 |
-
model.zero_grad()
|
| 60 |
score.backward()
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
cam = (weights[:, None, None] * act).sum(dim=0) # (H, W)
|
| 67 |
cam = F.relu(cam)
|
| 68 |
cam = cam - cam.min()
|
| 69 |
cam = cam / (cam.max() + 1e-8)
|
| 70 |
cam_np = cam.cpu().numpy()
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
| 75 |
heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB)
|
| 76 |
|
| 77 |
-
orig = image_tensor.squeeze().detach().cpu().numpy()
|
| 78 |
mean = np.array([0.485, 0.456, 0.406])[:, None, None]
|
| 79 |
std = np.array([0.229, 0.224, 0.225])[:, None, None]
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
finally:
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
def _get_last_conv_layer(model: torch.nn.Module) -> torch.nn.Module:
|
| 95 |
-
"""Return the last convolutional layer of the backbone (EfficientNet-B4)."""
|
| 96 |
-
try:
|
| 97 |
-
blocks = list(model.backbone.blocks)
|
| 98 |
-
for block in reversed(blocks):
|
| 99 |
-
for layer in reversed(list(block.modules())):
|
| 100 |
-
if isinstance(layer, torch.nn.Conv2d):
|
| 101 |
-
return layer
|
| 102 |
-
except AttributeError:
|
| 103 |
-
pass
|
| 104 |
-
last_conv = None
|
| 105 |
-
for layer in model.modules():
|
| 106 |
-
if isinstance(layer, torch.nn.Conv2d):
|
| 107 |
-
last_conv = layer
|
| 108 |
-
if last_conv is None:
|
| 109 |
-
raise RuntimeError("No Conv2d layer found in model")
|
| 110 |
-
return last_conv
|
|
|
|
| 1 |
+
"""Grad-CAM helper that returns a base64 encoded overlay."""
|
| 2 |
+
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
import base64
|
| 5 |
import io
|
|
|
|
| 6 |
|
| 7 |
import cv2
|
| 8 |
import numpy as np
|
|
|
|
| 10 |
import torch.nn.functional as F
|
| 11 |
from PIL import Image
|
| 12 |
|
| 13 |
+
|
| 14 |
+
def _get_last_conv_layer(model: torch.nn.Module) -> torch.nn.Module:
|
| 15 |
+
last_conv = None
|
| 16 |
+
for layer in model.modules():
|
| 17 |
+
if isinstance(layer, torch.nn.Conv2d):
|
| 18 |
+
last_conv = layer
|
| 19 |
+
if last_conv is None:
|
| 20 |
+
raise RuntimeError("No convolutional layer found for Grad-CAM.")
|
| 21 |
+
return last_conv
|
| 22 |
|
| 23 |
|
| 24 |
def generate_gradcam(
|
|
|
|
| 26 |
image_tensor: torch.Tensor,
|
| 27 |
target_class: int | None = None,
|
| 28 |
) -> str:
|
| 29 |
+
"""Generate a PNG overlay encoded as base64."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
model.eval()
|
| 31 |
activations: dict[str, torch.Tensor] = {}
|
| 32 |
gradients: dict[str, torch.Tensor] = {}
|
| 33 |
|
| 34 |
target_layer = _get_last_conv_layer(model)
|
| 35 |
|
| 36 |
+
def forward_hook(_module, _inputs, output):
|
| 37 |
activations["value"] = output.detach()
|
| 38 |
|
| 39 |
+
def backward_hook(_module, _grad_input, grad_output):
|
| 40 |
gradients["value"] = grad_output[0].detach()
|
| 41 |
|
| 42 |
+
forward_handle = target_layer.register_forward_hook(forward_hook)
|
| 43 |
+
backward_handle = target_layer.register_full_backward_hook(backward_hook)
|
| 44 |
|
| 45 |
try:
|
| 46 |
+
input_tensor = image_tensor.clone().detach().requires_grad_(True)
|
| 47 |
+
_, cls_logits = model(input_tensor)
|
|
|
|
| 48 |
if target_class is None:
|
| 49 |
target_class = int(cls_logits.argmax(dim=1).item())
|
| 50 |
|
| 51 |
+
score = cls_logits[:, target_class].sum()
|
| 52 |
+
model.zero_grad(set_to_none=True)
|
| 53 |
score.backward()
|
| 54 |
|
| 55 |
+
activation = activations["value"].squeeze(0)
|
| 56 |
+
gradient = gradients["value"].squeeze(0)
|
| 57 |
+
weights = gradient.mean(dim=(1, 2))
|
| 58 |
+
cam = (weights[:, None, None] * activation).sum(dim=0)
|
|
|
|
| 59 |
cam = F.relu(cam)
|
| 60 |
cam = cam - cam.min()
|
| 61 |
cam = cam / (cam.max() + 1e-8)
|
| 62 |
cam_np = cam.cpu().numpy()
|
| 63 |
|
| 64 |
+
height, width = input_tensor.shape[2], input_tensor.shape[3]
|
| 65 |
+
heatmap = cv2.applyColorMap(
|
| 66 |
+
np.uint8(255 * cv2.resize(cam_np, (width, height))),
|
| 67 |
+
cv2.COLORMAP_JET,
|
| 68 |
+
)
|
| 69 |
heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB)
|
| 70 |
|
|
|
|
| 71 |
mean = np.array([0.485, 0.456, 0.406])[:, None, None]
|
| 72 |
std = np.array([0.229, 0.224, 0.225])[:, None, None]
|
| 73 |
+
original = input_tensor.squeeze(0).detach().cpu().numpy()
|
| 74 |
+
original = np.clip((original * std + mean) * 255.0, 0, 255).astype(np.uint8)
|
| 75 |
+
original = original.transpose(1, 2, 0)
|
| 76 |
+
|
| 77 |
+
overlay = cv2.addWeighted(original, 0.6, heatmap, 0.4, 0)
|
| 78 |
+
image = Image.fromarray(overlay)
|
| 79 |
+
buffer = io.BytesIO()
|
| 80 |
+
image.save(buffer, format="PNG")
|
| 81 |
+
return base64.b64encode(buffer.getvalue()).decode("utf-8")
|
| 82 |
finally:
|
| 83 |
+
forward_handle.remove()
|
| 84 |
+
backward_handle.remove()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
inference/model_loader.py
CHANGED
|
@@ -1,9 +1,5 @@
|
|
| 1 |
# inference/model_loader.py
|
| 2 |
-
"""
|
| 3 |
-
Download and cache model weights from HuggingFace Hub.
|
| 4 |
-
Models are loaded once at startup and cached in memory.
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
import logging
|
| 8 |
import os
|
| 9 |
|
|
@@ -15,42 +11,40 @@ from training.models.efficientnet_b4 import AnemiaModel
|
|
| 15 |
|
| 16 |
log = logging.getLogger(__name__)
|
| 17 |
|
| 18 |
-
_MODEL_CACHE: dict
|
| 19 |
|
| 20 |
HF_REPOS = {
|
| 21 |
"conjunctiva": os.getenv("HF_CONJ_MODEL_REPO", "hssling/anemia-efficientnet-b4-conjunctiva"),
|
| 22 |
-
"nailbed":
|
| 23 |
}
|
| 24 |
-
HF_TOKEN = os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN")
|
| 25 |
|
| 26 |
-
DEVICE = torch.device("cpu")
|
| 27 |
|
| 28 |
|
| 29 |
def load_model(site: str) -> AnemiaModel:
|
| 30 |
-
"""Load and cache model for a given site ('conjunctiva' or 'nailbed')."""
|
| 31 |
if site in _MODEL_CACHE:
|
| 32 |
return _MODEL_CACHE[site]
|
| 33 |
|
| 34 |
repo_id = HF_REPOS.get(site)
|
| 35 |
if repo_id is None:
|
| 36 |
-
raise ValueError(f"Unknown site: {site!r}
|
| 37 |
|
| 38 |
-
log.info(f"Downloading model
|
| 39 |
-
ckpt_path = hf_hub_download(repo_id=repo_id, filename="model.safetensors"
|
| 40 |
model = AnemiaModel(pretrained=False)
|
| 41 |
state_dict = load_file(ckpt_path, device="cpu")
|
| 42 |
model.load_state_dict(state_dict)
|
| 43 |
model.to(DEVICE)
|
| 44 |
model.eval()
|
| 45 |
_MODEL_CACHE[site] = model
|
| 46 |
-
log.info(f"Model loaded
|
| 47 |
return model
|
| 48 |
|
| 49 |
|
| 50 |
-
def
|
| 51 |
-
"""
|
| 52 |
for site in HF_REPOS:
|
| 53 |
try:
|
| 54 |
load_model(site)
|
| 55 |
except Exception as e:
|
| 56 |
-
log.warning(f"Could not preload {site} model: {e}")
|
|
|
|
| 1 |
# inference/model_loader.py
|
| 2 |
+
"""Download and cache model weights from HuggingFace Hub."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import logging
|
| 4 |
import os
|
| 5 |
|
|
|
|
| 11 |
|
| 12 |
log = logging.getLogger(__name__)
|
| 13 |
|
| 14 |
+
_MODEL_CACHE: dict = {}
|
| 15 |
|
| 16 |
HF_REPOS = {
|
| 17 |
"conjunctiva": os.getenv("HF_CONJ_MODEL_REPO", "hssling/anemia-efficientnet-b4-conjunctiva"),
|
| 18 |
+
"nailbed": os.getenv("HF_NAIL_MODEL_REPO", "hssling/anemia-efficientnet-b4-nailbed"),
|
| 19 |
}
|
|
|
|
| 20 |
|
| 21 |
+
DEVICE = torch.device("cpu")
|
| 22 |
|
| 23 |
|
| 24 |
def load_model(site: str) -> AnemiaModel:
|
|
|
|
| 25 |
if site in _MODEL_CACHE:
|
| 26 |
return _MODEL_CACHE[site]
|
| 27 |
|
| 28 |
repo_id = HF_REPOS.get(site)
|
| 29 |
if repo_id is None:
|
| 30 |
+
raise ValueError(f"Unknown site: {site!r}")
|
| 31 |
|
| 32 |
+
log.info(f"Downloading {site} model from {repo_id} ...")
|
| 33 |
+
ckpt_path = hf_hub_download(repo_id=repo_id, filename="model.safetensors")
|
| 34 |
model = AnemiaModel(pretrained=False)
|
| 35 |
state_dict = load_file(ckpt_path, device="cpu")
|
| 36 |
model.load_state_dict(state_dict)
|
| 37 |
model.to(DEVICE)
|
| 38 |
model.eval()
|
| 39 |
_MODEL_CACHE[site] = model
|
| 40 |
+
log.info(f"Model loaded: {site}")
|
| 41 |
return model
|
| 42 |
|
| 43 |
|
| 44 |
+
def preload_available_models():
|
| 45 |
+
"""Load conjunctiva model; skip nailbed if not yet on Hub."""
|
| 46 |
for site in HF_REPOS:
|
| 47 |
try:
|
| 48 |
load_model(site)
|
| 49 |
except Exception as e:
|
| 50 |
+
log.warning(f"Could not preload {site} model (skipping): {e}")
|
inference/predict.py
CHANGED
|
@@ -1,70 +1,80 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
Image preprocessing and MC-Dropout inference pipeline.
|
| 4 |
-
"""
|
| 5 |
|
| 6 |
-
import logging
|
| 7 |
from typing import Any
|
| 8 |
|
| 9 |
import numpy as np
|
| 10 |
import torch
|
| 11 |
from PIL import Image
|
| 12 |
|
| 13 |
-
log = logging.getLogger(__name__)
|
| 14 |
-
|
| 15 |
CLASS_NAMES = ["normal", "mild", "moderate", "severe"]
|
| 16 |
-
_IMAGENET_MEAN = torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1)
|
| 17 |
-
_IMAGENET_STD = torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1)
|
| 18 |
|
| 19 |
|
| 20 |
def preprocess_image(img: Image.Image, image_size: int = 380) -> torch.Tensor:
|
| 21 |
-
"""Convert PIL
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
tensor = torch.from_numpy(
|
| 25 |
tensor = (tensor - _IMAGENET_MEAN) / _IMAGENET_STD
|
| 26 |
-
return tensor.unsqueeze(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
def mc_dropout_predict(
|
| 30 |
model: torch.nn.Module,
|
| 31 |
image_tensor: torch.Tensor,
|
| 32 |
-
n_samples: int =
|
| 33 |
) -> dict[str, Any]:
|
| 34 |
-
"""
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
model.eval() # always restore eval mode, even on exception
|
| 51 |
-
|
| 52 |
-
hb_arr = np.array(hb_samples)
|
| 53 |
-
cls_arr = np.array(cls_samples).mean(axis=0) # (4,)
|
| 54 |
-
|
| 55 |
-
hb_mean = float(np.mean(hb_arr))
|
| 56 |
-
hb_lo = float(np.percentile(hb_arr, 2.5))
|
| 57 |
-
hb_hi = float(np.percentile(hb_arr, 97.5))
|
| 58 |
-
pred_class_idx = int(np.argmax(cls_arr))
|
| 59 |
|
| 60 |
return {
|
| 61 |
-
"hb_estimate": round(
|
| 62 |
-
"hb_ci_95": [
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
| 64 |
"class_probabilities": {
|
| 65 |
-
|
|
|
|
| 66 |
},
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
}
|
| 69 |
|
| 70 |
|
|
@@ -76,58 +86,67 @@ def run_full_prediction(
|
|
| 76 |
w_conj: float = 0.5,
|
| 77 |
w_nail: float = 0.5,
|
| 78 |
image_size: int = 380,
|
| 79 |
-
n_mc_samples: int =
|
| 80 |
) -> dict[str, Any]:
|
| 81 |
-
"""
|
| 82 |
-
|
| 83 |
-
Fills 'per_model' field with individual model results.
|
| 84 |
-
"""
|
| 85 |
-
results = {}
|
| 86 |
|
| 87 |
if conj_img is not None and conj_model is not None:
|
| 88 |
-
|
| 89 |
-
|
| 90 |
|
| 91 |
if nail_img is not None and nail_model is not None:
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
if not
|
| 96 |
-
raise ValueError("No
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
"class_probabilities"
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
| 121 |
else:
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
return {
|
| 129 |
-
|
| 130 |
-
"
|
| 131 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
"disclaimer": "Research tool only. Not a certified diagnostic device. Clinical confirmation required.",
|
| 133 |
}
|
|
|
|
| 1 |
+
"""Image preprocessing and MC-dropout inference helpers."""
|
| 2 |
+
from __future__ import annotations
|
|
|
|
|
|
|
| 3 |
|
|
|
|
| 4 |
from typing import Any
|
| 5 |
|
| 6 |
import numpy as np
|
| 7 |
import torch
|
| 8 |
from PIL import Image
|
| 9 |
|
|
|
|
|
|
|
| 10 |
CLASS_NAMES = ["normal", "mild", "moderate", "severe"]
|
| 11 |
+
_IMAGENET_MEAN = torch.tensor([0.485, 0.456, 0.406], dtype=torch.float32).view(3, 1, 1)
|
| 12 |
+
_IMAGENET_STD = torch.tensor([0.229, 0.224, 0.225], dtype=torch.float32).view(3, 1, 1)
|
| 13 |
|
| 14 |
|
| 15 |
def preprocess_image(img: Image.Image, image_size: int = 380) -> torch.Tensor:
|
| 16 |
+
"""Convert a PIL image to a normalised BCHW tensor."""
|
| 17 |
+
resized = img.convert("RGB").resize((image_size, image_size), Image.BICUBIC)
|
| 18 |
+
array = np.asarray(resized, dtype=np.float32) / 255.0
|
| 19 |
+
tensor = torch.from_numpy(array).permute(2, 0, 1)
|
| 20 |
tensor = (tensor - _IMAGENET_MEAN) / _IMAGENET_STD
|
| 21 |
+
return tensor.unsqueeze(0)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _enable_dropout(module: torch.nn.Module) -> None:
|
| 25 |
+
for child in module.modules():
|
| 26 |
+
if isinstance(child, torch.nn.Dropout):
|
| 27 |
+
child.train()
|
| 28 |
|
| 29 |
|
| 30 |
def mc_dropout_predict(
|
| 31 |
model: torch.nn.Module,
|
| 32 |
image_tensor: torch.Tensor,
|
| 33 |
+
n_samples: int = 20,
|
| 34 |
) -> dict[str, Any]:
|
| 35 |
+
"""Run repeated stochastic forward passes and aggregate predictions."""
|
| 36 |
+
model.eval()
|
| 37 |
+
_enable_dropout(model)
|
| 38 |
+
|
| 39 |
+
hb_samples: list[float] = []
|
| 40 |
+
cls_samples: list[np.ndarray] = []
|
| 41 |
+
|
| 42 |
+
with torch.no_grad():
|
| 43 |
+
for _ in range(n_samples):
|
| 44 |
+
hb_pred, cls_logits = model(image_tensor)
|
| 45 |
+
hb_samples.append(float(hb_pred.squeeze().item()))
|
| 46 |
+
cls_samples.append(torch.softmax(cls_logits, dim=1).squeeze(0).cpu().numpy())
|
| 47 |
+
|
| 48 |
+
hb_array = np.asarray(hb_samples, dtype=np.float32)
|
| 49 |
+
cls_array = np.asarray(cls_samples, dtype=np.float32).mean(axis=0)
|
| 50 |
+
class_idx = int(np.argmax(cls_array))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
return {
|
| 53 |
+
"hb_estimate": round(float(hb_array.mean()), 2),
|
| 54 |
+
"hb_ci_95": [
|
| 55 |
+
round(float(np.percentile(hb_array, 2.5)), 2),
|
| 56 |
+
round(float(np.percentile(hb_array, 97.5)), 2),
|
| 57 |
+
],
|
| 58 |
+
"classification": CLASS_NAMES[class_idx],
|
| 59 |
"class_probabilities": {
|
| 60 |
+
class_name: round(float(cls_array[i]), 4)
|
| 61 |
+
for i, class_name in enumerate(CLASS_NAMES)
|
| 62 |
},
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _combine_weighted_probabilities(
|
| 67 |
+
first: dict[str, float],
|
| 68 |
+
second: dict[str, float],
|
| 69 |
+
first_weight: float,
|
| 70 |
+
second_weight: float,
|
| 71 |
+
) -> dict[str, float]:
|
| 72 |
+
return {
|
| 73 |
+
class_name: round(
|
| 74 |
+
first_weight * first[class_name] + second_weight * second[class_name],
|
| 75 |
+
4,
|
| 76 |
+
)
|
| 77 |
+
for class_name in CLASS_NAMES
|
| 78 |
}
|
| 79 |
|
| 80 |
|
|
|
|
| 86 |
w_conj: float = 0.5,
|
| 87 |
w_nail: float = 0.5,
|
| 88 |
image_size: int = 380,
|
| 89 |
+
n_mc_samples: int = 20,
|
| 90 |
) -> dict[str, Any]:
|
| 91 |
+
"""Predict from conjunctiva and/or nail-bed images with optional ensembling."""
|
| 92 |
+
per_model: dict[str, dict[str, Any]] = {}
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
if conj_img is not None and conj_model is not None:
|
| 95 |
+
tensor = preprocess_image(conj_img, image_size=image_size)
|
| 96 |
+
per_model["conjunctiva"] = mc_dropout_predict(conj_model, tensor, n_samples=n_mc_samples)
|
| 97 |
|
| 98 |
if nail_img is not None and nail_model is not None:
|
| 99 |
+
tensor = preprocess_image(nail_img, image_size=image_size)
|
| 100 |
+
per_model["nailbed"] = mc_dropout_predict(nail_model, tensor, n_samples=n_mc_samples)
|
| 101 |
+
|
| 102 |
+
if not per_model:
|
| 103 |
+
raise ValueError("No usable model/image pair was provided.")
|
| 104 |
+
|
| 105 |
+
if {"conjunctiva", "nailbed"} <= set(per_model):
|
| 106 |
+
hb_estimate = round(
|
| 107 |
+
w_conj * per_model["conjunctiva"]["hb_estimate"]
|
| 108 |
+
+ w_nail * per_model["nailbed"]["hb_estimate"],
|
| 109 |
+
2,
|
| 110 |
+
)
|
| 111 |
+
hb_ci_95 = [
|
| 112 |
+
round(
|
| 113 |
+
w_conj * per_model["conjunctiva"]["hb_ci_95"][0]
|
| 114 |
+
+ w_nail * per_model["nailbed"]["hb_ci_95"][0],
|
| 115 |
+
2,
|
| 116 |
+
),
|
| 117 |
+
round(
|
| 118 |
+
w_conj * per_model["conjunctiva"]["hb_ci_95"][1]
|
| 119 |
+
+ w_nail * per_model["nailbed"]["hb_ci_95"][1],
|
| 120 |
+
2,
|
| 121 |
+
),
|
| 122 |
+
]
|
| 123 |
+
class_probabilities = _combine_weighted_probabilities(
|
| 124 |
+
per_model["conjunctiva"]["class_probabilities"],
|
| 125 |
+
per_model["nailbed"]["class_probabilities"],
|
| 126 |
+
w_conj,
|
| 127 |
+
w_nail,
|
| 128 |
+
)
|
| 129 |
+
classification = max(class_probabilities, key=class_probabilities.get)
|
| 130 |
else:
|
| 131 |
+
primary_result = next(iter(per_model.values()))
|
| 132 |
+
hb_estimate = primary_result["hb_estimate"]
|
| 133 |
+
hb_ci_95 = primary_result["hb_ci_95"]
|
| 134 |
+
classification = primary_result["classification"]
|
| 135 |
+
class_probabilities = primary_result["class_probabilities"]
|
| 136 |
+
|
| 137 |
+
warnings: list[str] = []
|
| 138 |
+
if conj_img is not None and conj_model is None:
|
| 139 |
+
warnings.append("Conjunctiva image ignored because the conjunctiva model is unavailable.")
|
| 140 |
+
if nail_img is not None and nail_model is None:
|
| 141 |
+
warnings.append("Nail-bed image ignored because the nail-bed model is unavailable.")
|
| 142 |
|
| 143 |
return {
|
| 144 |
+
"hb_estimate": hb_estimate,
|
| 145 |
+
"hb_ci_95": hb_ci_95,
|
| 146 |
+
"classification": classification,
|
| 147 |
+
"class_probabilities": class_probabilities,
|
| 148 |
+
"per_model": per_model,
|
| 149 |
+
"warnings": warnings,
|
| 150 |
+
"model_version": "v0.4.0",
|
| 151 |
"disclaimer": "Research tool only. Not a certified diagnostic device. Clinical confirmation required.",
|
| 152 |
}
|
inference/requirements.txt
CHANGED
|
@@ -11,3 +11,5 @@ opencv-python-headless>=4.9.0
|
|
| 11 |
albumentations>=1.4.0
|
| 12 |
numpy>=1.26.0
|
| 13 |
python-multipart>=0.0.9
|
|
|
|
|
|
|
|
|
| 11 |
albumentations>=1.4.0
|
| 12 |
numpy>=1.26.0
|
| 13 |
python-multipart>=0.0.9
|
| 14 |
+
scipy>=1.12.0
|
| 15 |
+
scikit-learn>=1.4.0
|
training/config.yaml
CHANGED
|
@@ -1,9 +1,7 @@
|
|
| 1 |
# training/config.yaml
|
| 2 |
-
# All hyperparameters and paths. Override via CLI args in train.py.
|
| 3 |
-
|
| 4 |
data:
|
| 5 |
hf_dataset_repo: "hssling/anemia-conjunctiva-nailbed"
|
| 6 |
-
image_size: 380
|
| 7 |
batch_size: 32
|
| 8 |
num_workers: 4
|
| 9 |
|
|
@@ -19,7 +17,7 @@ model:
|
|
| 19 |
pretrained: true
|
| 20 |
unfreeze_last_n_blocks: 3
|
| 21 |
dropout_rate: 0.3
|
| 22 |
-
mc_dropout_samples: 30
|
| 23 |
|
| 24 |
training:
|
| 25 |
phase1_epochs: 10
|
|
@@ -46,4 +44,4 @@ output:
|
|
| 46 |
|
| 47 |
wandb:
|
| 48 |
project: "anemiascan"
|
| 49 |
-
entity: null
|
|
|
|
| 1 |
# training/config.yaml
|
|
|
|
|
|
|
| 2 |
data:
|
| 3 |
hf_dataset_repo: "hssling/anemia-conjunctiva-nailbed"
|
| 4 |
+
image_size: 380
|
| 5 |
batch_size: 32
|
| 6 |
num_workers: 4
|
| 7 |
|
|
|
|
| 17 |
pretrained: true
|
| 18 |
unfreeze_last_n_blocks: 3
|
| 19 |
dropout_rate: 0.3
|
| 20 |
+
mc_dropout_samples: 30
|
| 21 |
|
| 22 |
training:
|
| 23 |
phase1_epochs: 10
|
|
|
|
| 44 |
|
| 45 |
wandb:
|
| 46 |
project: "anemiascan"
|
| 47 |
+
entity: null
|
training/cross_validation.py
CHANGED
|
@@ -1,23 +1,11 @@
|
|
| 1 |
# training/cross_validation.py
|
| 2 |
-
"""
|
| 3 |
-
5-fold stratified cross-validation runner.
|
| 4 |
-
|
| 5 |
-
CV is used for metric estimation only.
|
| 6 |
-
Final model is retrained on full train+val after CV.
|
| 7 |
-
|
| 8 |
-
Usage:
|
| 9 |
-
python training/cross_validation.py \
|
| 10 |
-
--model efficientnet_b4 \
|
| 11 |
-
--config training/config.yaml \
|
| 12 |
-
--output-dir outputs/cv/
|
| 13 |
-
"""
|
| 14 |
-
|
| 15 |
-
import argparse
|
| 16 |
import json
|
| 17 |
import logging
|
| 18 |
import pathlib
|
| 19 |
|
| 20 |
import numpy as np
|
|
|
|
| 21 |
from sklearn.model_selection import StratifiedKFold
|
| 22 |
|
| 23 |
from training.train import load_config, train_model
|
|
@@ -26,33 +14,21 @@ log = logging.getLogger(__name__)
|
|
| 26 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 27 |
|
| 28 |
|
| 29 |
-
def run_cross_validation(
|
| 30 |
-
rows: list,
|
| 31 |
-
model_name: str,
|
| 32 |
-
config: dict,
|
| 33 |
-
output_dir: pathlib.Path,
|
| 34 |
-
) -> dict:
|
| 35 |
-
"""
|
| 36 |
-
Run 5-fold stratified CV. Returns dict with mean +/- std of each metric.
|
| 37 |
-
"""
|
| 38 |
n_folds = config["training"]["n_folds"]
|
| 39 |
-
fold_metrics = []
|
| 40 |
-
|
| 41 |
strat_labels = [r["anemia_class"] for r in rows]
|
| 42 |
skf = StratifiedKFold(
|
| 43 |
n_splits=n_folds, shuffle=True, random_state=config["training"]["random_seed"]
|
| 44 |
)
|
| 45 |
-
|
| 46 |
for fold, (train_idx, val_idx) in enumerate(skf.split(rows, strat_labels)):
|
| 47 |
log.info(f"=== Fold {fold + 1}/{n_folds} ===")
|
| 48 |
-
|
| 49 |
-
val_rows = [rows[i] for i in val_idx]
|
| 50 |
-
fold_out = output_dir / f"fold_{fold}"
|
| 51 |
fold_out.mkdir(parents=True, exist_ok=True)
|
| 52 |
metrics = train_model(
|
| 53 |
model_name=model_name,
|
| 54 |
-
train_rows=
|
| 55 |
-
val_rows=
|
| 56 |
config=config,
|
| 57 |
output_dir=fold_out,
|
| 58 |
fold=fold,
|
|
@@ -60,36 +36,15 @@ def run_cross_validation(
|
|
| 60 |
)
|
| 61 |
fold_metrics.append(metrics)
|
| 62 |
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
vals = [m[key] for m in fold_metrics if isinstance(m.get(key), int | float)]
|
| 67 |
if vals:
|
| 68 |
summary[f"{key}_mean"] = float(np.mean(vals))
|
| 69 |
summary[f"{key}_std"] = float(np.std(vals))
|
| 70 |
|
| 71 |
-
|
| 72 |
-
summary["model"] = model_name
|
| 73 |
-
out_path = output_dir / f"{model_name}_cv_summary.json"
|
| 74 |
with open(out_path, "w") as f:
|
| 75 |
json.dump(summary, f, indent=2)
|
| 76 |
-
log.info(
|
| 77 |
-
f"CV summary: MAE={summary.get('mae_mean', '?'):.3f} +/- {summary.get('mae_std', '?'):.3f}"
|
| 78 |
-
)
|
| 79 |
return summary
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
def main():
|
| 83 |
-
parser = argparse.ArgumentParser()
|
| 84 |
-
parser.add_argument("--model", required=True)
|
| 85 |
-
parser.add_argument("--config", default="training/config.yaml", type=pathlib.Path)
|
| 86 |
-
parser.add_argument("--output-dir", default="outputs/cv", type=pathlib.Path)
|
| 87 |
-
args = parser.parse_args()
|
| 88 |
-
|
| 89 |
-
load_config(args.config)
|
| 90 |
-
log.info(f"Cross-validation for {args.model}")
|
| 91 |
-
log.info("Load your dataset rows and call run_cross_validation(rows, ...)")
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
if __name__ == "__main__":
|
| 95 |
-
main()
|
|
|
|
| 1 |
# training/cross_validation.py
|
| 2 |
+
"""5-fold stratified cross-validation runner."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import json
|
| 4 |
import logging
|
| 5 |
import pathlib
|
| 6 |
|
| 7 |
import numpy as np
|
| 8 |
+
import yaml
|
| 9 |
from sklearn.model_selection import StratifiedKFold
|
| 10 |
|
| 11 |
from training.train import load_config, train_model
|
|
|
|
| 14 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 15 |
|
| 16 |
|
| 17 |
+
def run_cross_validation(rows, model_name, config, output_dir):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
n_folds = config["training"]["n_folds"]
|
|
|
|
|
|
|
| 19 |
strat_labels = [r["anemia_class"] for r in rows]
|
| 20 |
skf = StratifiedKFold(
|
| 21 |
n_splits=n_folds, shuffle=True, random_state=config["training"]["random_seed"]
|
| 22 |
)
|
| 23 |
+
fold_metrics = []
|
| 24 |
for fold, (train_idx, val_idx) in enumerate(skf.split(rows, strat_labels)):
|
| 25 |
log.info(f"=== Fold {fold + 1}/{n_folds} ===")
|
| 26 |
+
fold_out = pathlib.Path(output_dir) / f"fold_{fold}"
|
|
|
|
|
|
|
| 27 |
fold_out.mkdir(parents=True, exist_ok=True)
|
| 28 |
metrics = train_model(
|
| 29 |
model_name=model_name,
|
| 30 |
+
train_rows=[rows[i] for i in train_idx],
|
| 31 |
+
val_rows=[rows[i] for i in val_idx],
|
| 32 |
config=config,
|
| 33 |
output_dir=fold_out,
|
| 34 |
fold=fold,
|
|
|
|
| 36 |
)
|
| 37 |
fold_metrics.append(metrics)
|
| 38 |
|
| 39 |
+
summary = {"n_folds": n_folds, "model": model_name}
|
| 40 |
+
for key in fold_metrics[0].keys():
|
| 41 |
+
vals = [m[key] for m in fold_metrics if isinstance(m.get(key), (int, float))]
|
|
|
|
| 42 |
if vals:
|
| 43 |
summary[f"{key}_mean"] = float(np.mean(vals))
|
| 44 |
summary[f"{key}_std"] = float(np.std(vals))
|
| 45 |
|
| 46 |
+
out_path = pathlib.Path(output_dir) / f"{model_name}_cv_summary.json"
|
|
|
|
|
|
|
| 47 |
with open(out_path, "w") as f:
|
| 48 |
json.dump(summary, f, indent=2)
|
| 49 |
+
log.info(f"CV MAE={summary.get('mae_mean', '?'):.3f} ± {summary.get('mae_std', '?'):.3f}")
|
|
|
|
|
|
|
| 50 |
return summary
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
training/evaluation/metrics.py
CHANGED
|
@@ -1,13 +1,11 @@
|
|
| 1 |
# training/evaluation/metrics.py
|
| 2 |
"""Evaluation metrics for hemoglobin regression and anemia classification."""
|
| 3 |
-
|
| 4 |
import numpy as np
|
| 5 |
from scipy import stats
|
| 6 |
from sklearn.metrics import confusion_matrix, f1_score, roc_auc_score
|
| 7 |
|
| 8 |
|
| 9 |
def compute_regression_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict:
|
| 10 |
-
"""MAE, RMSE, Pearson r for Hb regression."""
|
| 11 |
mae = float(np.mean(np.abs(y_true - y_pred)))
|
| 12 |
rmse = float(np.sqrt(np.mean((y_true - y_pred) ** 2)))
|
| 13 |
r, p_val = stats.pearsonr(y_true, y_pred)
|
|
@@ -15,25 +13,21 @@ def compute_regression_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict:
|
|
| 15 |
|
| 16 |
|
| 17 |
def compute_classification_metrics(y_true: np.ndarray, y_pred_proba: np.ndarray) -> dict:
|
| 18 |
-
"""AUC, F1, sensitivity, specificity, confusion matrix for 4-class anemia."""
|
| 19 |
y_pred = np.argmax(y_pred_proba, axis=1)
|
| 20 |
cm = confusion_matrix(y_true, y_pred, labels=[0, 1, 2, 3])
|
| 21 |
-
|
| 22 |
per_class_sens = {}
|
| 23 |
per_class_spec = {}
|
| 24 |
-
for
|
| 25 |
-
tp = cm[
|
| 26 |
-
fn = cm[
|
| 27 |
-
fp = cm[:,
|
| 28 |
tn = cm.sum() - tp - fn - fp
|
| 29 |
-
per_class_sens[
|
| 30 |
-
per_class_spec[
|
| 31 |
-
|
| 32 |
try:
|
| 33 |
auc_macro = float(roc_auc_score(y_true, y_pred_proba, multi_class="ovr", average="macro"))
|
| 34 |
except ValueError:
|
| 35 |
auc_macro = float("nan")
|
| 36 |
-
|
| 37 |
return {
|
| 38 |
"auc_macro": auc_macro,
|
| 39 |
"f1_macro": float(f1_score(y_true, y_pred, average="macro", zero_division=0)),
|
|
@@ -44,7 +38,6 @@ def compute_classification_metrics(y_true: np.ndarray, y_pred_proba: np.ndarray)
|
|
| 44 |
|
| 45 |
|
| 46 |
def bland_altman_stats(y_true: np.ndarray, y_pred: np.ndarray) -> dict:
|
| 47 |
-
"""Bland-Altman agreement statistics."""
|
| 48 |
diff = y_true - y_pred
|
| 49 |
mean_diff = float(np.mean(diff))
|
| 50 |
std_diff = float(np.std(diff, ddof=1))
|
|
|
|
| 1 |
# training/evaluation/metrics.py
|
| 2 |
"""Evaluation metrics for hemoglobin regression and anemia classification."""
|
|
|
|
| 3 |
import numpy as np
|
| 4 |
from scipy import stats
|
| 5 |
from sklearn.metrics import confusion_matrix, f1_score, roc_auc_score
|
| 6 |
|
| 7 |
|
| 8 |
def compute_regression_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict:
|
|
|
|
| 9 |
mae = float(np.mean(np.abs(y_true - y_pred)))
|
| 10 |
rmse = float(np.sqrt(np.mean((y_true - y_pred) ** 2)))
|
| 11 |
r, p_val = stats.pearsonr(y_true, y_pred)
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
def compute_classification_metrics(y_true: np.ndarray, y_pred_proba: np.ndarray) -> dict:
|
|
|
|
| 16 |
y_pred = np.argmax(y_pred_proba, axis=1)
|
| 17 |
cm = confusion_matrix(y_true, y_pred, labels=[0, 1, 2, 3])
|
|
|
|
| 18 |
per_class_sens = {}
|
| 19 |
per_class_spec = {}
|
| 20 |
+
for c in range(4):
|
| 21 |
+
tp = cm[c, c]
|
| 22 |
+
fn = cm[c, :].sum() - tp
|
| 23 |
+
fp = cm[:, c].sum() - tp
|
| 24 |
tn = cm.sum() - tp - fn - fp
|
| 25 |
+
per_class_sens[c] = tp / (tp + fn) if (tp + fn) > 0 else 0.0
|
| 26 |
+
per_class_spec[c] = tn / (tn + fp) if (tn + fp) > 0 else 0.0
|
|
|
|
| 27 |
try:
|
| 28 |
auc_macro = float(roc_auc_score(y_true, y_pred_proba, multi_class="ovr", average="macro"))
|
| 29 |
except ValueError:
|
| 30 |
auc_macro = float("nan")
|
|
|
|
| 31 |
return {
|
| 32 |
"auc_macro": auc_macro,
|
| 33 |
"f1_macro": float(f1_score(y_true, y_pred, average="macro", zero_division=0)),
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
def bland_altman_stats(y_true: np.ndarray, y_pred: np.ndarray) -> dict:
|
|
|
|
| 41 |
diff = y_true - y_pred
|
| 42 |
mean_diff = float(np.mean(diff))
|
| 43 |
std_diff = float(np.std(diff, ddof=1))
|
training/models/convnext_tiny.py
CHANGED
|
@@ -1,8 +1,6 @@
|
|
| 1 |
# training/models/convnext_tiny.py
|
| 2 |
"""ConvNeXt-Tiny dual-head model."""
|
| 3 |
-
|
| 4 |
import timm
|
| 5 |
-
import torch
|
| 6 |
import torch.nn as nn
|
| 7 |
|
| 8 |
|
|
@@ -17,13 +15,10 @@ class AnemiaModel(nn.Module):
|
|
| 17 |
nn.Linear(feature_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate), nn.Linear(256, 1)
|
| 18 |
)
|
| 19 |
self.classification_head = nn.Sequential(
|
| 20 |
-
nn.Linear(feature_dim, 256),
|
| 21 |
-
nn.ReLU(),
|
| 22 |
-
nn.Dropout(dropout_rate),
|
| 23 |
-
nn.Linear(256, num_classes),
|
| 24 |
)
|
| 25 |
|
| 26 |
-
def forward(self, x
|
| 27 |
f = self.backbone(x)
|
| 28 |
return self.regression_head(f), self.classification_head(f)
|
| 29 |
|
|
@@ -32,7 +27,6 @@ class AnemiaModel(nn.Module):
|
|
| 32 |
p.requires_grad = False
|
| 33 |
|
| 34 |
def unfreeze_last_n_blocks(self, n: int = 3):
|
| 35 |
-
|
| 36 |
-
for stage in stages[-n:]:
|
| 37 |
for p in stage.parameters():
|
| 38 |
p.requires_grad = True
|
|
|
|
| 1 |
# training/models/convnext_tiny.py
|
| 2 |
"""ConvNeXt-Tiny dual-head model."""
|
|
|
|
| 3 |
import timm
|
|
|
|
| 4 |
import torch.nn as nn
|
| 5 |
|
| 6 |
|
|
|
|
| 15 |
nn.Linear(feature_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate), nn.Linear(256, 1)
|
| 16 |
)
|
| 17 |
self.classification_head = nn.Sequential(
|
| 18 |
+
nn.Linear(feature_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate), nn.Linear(256, num_classes)
|
|
|
|
|
|
|
|
|
|
| 19 |
)
|
| 20 |
|
| 21 |
+
def forward(self, x):
|
| 22 |
f = self.backbone(x)
|
| 23 |
return self.regression_head(f), self.classification_head(f)
|
| 24 |
|
|
|
|
| 27 |
p.requires_grad = False
|
| 28 |
|
| 29 |
def unfreeze_last_n_blocks(self, n: int = 3):
|
| 30 |
+
for stage in list(self.backbone.stages)[-n:]:
|
|
|
|
| 31 |
for p in stage.parameters():
|
| 32 |
p.requires_grad = True
|
training/models/efficientnet_b4.py
CHANGED
|
@@ -1,63 +1,36 @@
|
|
| 1 |
# training/models/efficientnet_b4.py
|
| 2 |
"""EfficientNet-B4 dual-head model for hemoglobin regression + anemia classification."""
|
| 3 |
-
|
| 4 |
import timm
|
| 5 |
import torch
|
| 6 |
import torch.nn as nn
|
| 7 |
|
| 8 |
|
| 9 |
class AnemiaModel(nn.Module):
|
| 10 |
-
|
| 11 |
-
EfficientNet-B4 backbone with dual prediction heads:
|
| 12 |
-
- Regression head: predicts Hb (g/dL)
|
| 13 |
-
- Classification head: predicts 4-class anemia severity
|
| 14 |
-
"""
|
| 15 |
-
|
| 16 |
-
def __init__(
|
| 17 |
-
self,
|
| 18 |
-
num_classes: int = 4,
|
| 19 |
-
dropout_rate: float = 0.3,
|
| 20 |
-
pretrained: bool = True,
|
| 21 |
-
):
|
| 22 |
super().__init__()
|
| 23 |
self.backbone = timm.create_model(
|
| 24 |
-
"efficientnet_b4",
|
| 25 |
-
pretrained=pretrained,
|
| 26 |
-
num_classes=0, # remove classifier head
|
| 27 |
-
global_pool="avg",
|
| 28 |
)
|
| 29 |
feature_dim = self.backbone.num_features
|
| 30 |
-
|
| 31 |
self.regression_head = nn.Sequential(
|
| 32 |
-
nn.Linear(feature_dim, 256),
|
| 33 |
-
nn.ReLU(),
|
| 34 |
-
nn.Dropout(dropout_rate),
|
| 35 |
-
nn.Linear(256, 1),
|
| 36 |
)
|
| 37 |
self.classification_head = nn.Sequential(
|
| 38 |
-
nn.Linear(feature_dim, 256),
|
| 39 |
-
nn.ReLU(),
|
| 40 |
-
nn.Dropout(dropout_rate),
|
| 41 |
-
nn.Linear(256, num_classes),
|
| 42 |
)
|
| 43 |
|
| 44 |
def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
| 45 |
features = self.backbone(x)
|
| 46 |
-
|
| 47 |
-
class_logits = self.classification_head(features)
|
| 48 |
-
return hb_pred, class_logits
|
| 49 |
|
| 50 |
def freeze_backbone(self):
|
| 51 |
for param in self.backbone.parameters():
|
| 52 |
param.requires_grad = False
|
| 53 |
|
| 54 |
def unfreeze_last_n_blocks(self, n: int = 3):
|
| 55 |
-
|
| 56 |
-
blocks = list(self.backbone.blocks)
|
| 57 |
-
for block in blocks[-n:]:
|
| 58 |
for param in block.parameters():
|
| 59 |
param.requires_grad = True
|
| 60 |
-
# Always unfreeze the final conv + bn
|
| 61 |
for param in self.backbone.conv_head.parameters():
|
| 62 |
param.requires_grad = True
|
| 63 |
for param in self.backbone.bn2.parameters():
|
|
|
|
| 1 |
# training/models/efficientnet_b4.py
|
| 2 |
"""EfficientNet-B4 dual-head model for hemoglobin regression + anemia classification."""
|
|
|
|
| 3 |
import timm
|
| 4 |
import torch
|
| 5 |
import torch.nn as nn
|
| 6 |
|
| 7 |
|
| 8 |
class AnemiaModel(nn.Module):
|
| 9 |
+
def __init__(self, num_classes: int = 4, dropout_rate: float = 0.3, pretrained: bool = True):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
super().__init__()
|
| 11 |
self.backbone = timm.create_model(
|
| 12 |
+
"efficientnet_b4", pretrained=pretrained, num_classes=0, global_pool="avg"
|
|
|
|
|
|
|
|
|
|
| 13 |
)
|
| 14 |
feature_dim = self.backbone.num_features
|
|
|
|
| 15 |
self.regression_head = nn.Sequential(
|
| 16 |
+
nn.Linear(feature_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate), nn.Linear(256, 1)
|
|
|
|
|
|
|
|
|
|
| 17 |
)
|
| 18 |
self.classification_head = nn.Sequential(
|
| 19 |
+
nn.Linear(feature_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate), nn.Linear(256, num_classes)
|
|
|
|
|
|
|
|
|
|
| 20 |
)
|
| 21 |
|
| 22 |
def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
| 23 |
features = self.backbone(x)
|
| 24 |
+
return self.regression_head(features), self.classification_head(features)
|
|
|
|
|
|
|
| 25 |
|
| 26 |
def freeze_backbone(self):
|
| 27 |
for param in self.backbone.parameters():
|
| 28 |
param.requires_grad = False
|
| 29 |
|
| 30 |
def unfreeze_last_n_blocks(self, n: int = 3):
|
| 31 |
+
for block in list(self.backbone.blocks)[-n:]:
|
|
|
|
|
|
|
| 32 |
for param in block.parameters():
|
| 33 |
param.requires_grad = True
|
|
|
|
| 34 |
for param in self.backbone.conv_head.parameters():
|
| 35 |
param.requires_grad = True
|
| 36 |
for param in self.backbone.bn2.parameters():
|
training/models/efficientnetv2_s.py
CHANGED
|
@@ -1,8 +1,6 @@
|
|
| 1 |
# training/models/efficientnetv2_s.py
|
| 2 |
"""EfficientNetV2-S dual-head model."""
|
| 3 |
-
|
| 4 |
import timm
|
| 5 |
-
import torch
|
| 6 |
import torch.nn as nn
|
| 7 |
|
| 8 |
|
|
@@ -17,13 +15,10 @@ class AnemiaModel(nn.Module):
|
|
| 17 |
nn.Linear(feature_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate), nn.Linear(256, 1)
|
| 18 |
)
|
| 19 |
self.classification_head = nn.Sequential(
|
| 20 |
-
nn.Linear(feature_dim, 256),
|
| 21 |
-
nn.ReLU(),
|
| 22 |
-
nn.Dropout(dropout_rate),
|
| 23 |
-
nn.Linear(256, num_classes),
|
| 24 |
)
|
| 25 |
|
| 26 |
-
def forward(self, x
|
| 27 |
f = self.backbone(x)
|
| 28 |
return self.regression_head(f), self.classification_head(f)
|
| 29 |
|
|
@@ -32,11 +27,9 @@ class AnemiaModel(nn.Module):
|
|
| 32 |
p.requires_grad = False
|
| 33 |
|
| 34 |
def unfreeze_last_n_blocks(self, n: int = 3):
|
| 35 |
-
|
| 36 |
-
for block in blocks[-n:]:
|
| 37 |
for p in block.parameters():
|
| 38 |
p.requires_grad = True
|
| 39 |
-
# Also unfreeze final conv + bn for consistent gradient flow with B4
|
| 40 |
if hasattr(self.backbone, "conv_head"):
|
| 41 |
for p in self.backbone.conv_head.parameters():
|
| 42 |
p.requires_grad = True
|
|
|
|
| 1 |
# training/models/efficientnetv2_s.py
|
| 2 |
"""EfficientNetV2-S dual-head model."""
|
|
|
|
| 3 |
import timm
|
|
|
|
| 4 |
import torch.nn as nn
|
| 5 |
|
| 6 |
|
|
|
|
| 15 |
nn.Linear(feature_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate), nn.Linear(256, 1)
|
| 16 |
)
|
| 17 |
self.classification_head = nn.Sequential(
|
| 18 |
+
nn.Linear(feature_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate), nn.Linear(256, num_classes)
|
|
|
|
|
|
|
|
|
|
| 19 |
)
|
| 20 |
|
| 21 |
+
def forward(self, x):
|
| 22 |
f = self.backbone(x)
|
| 23 |
return self.regression_head(f), self.classification_head(f)
|
| 24 |
|
|
|
|
| 27 |
p.requires_grad = False
|
| 28 |
|
| 29 |
def unfreeze_last_n_blocks(self, n: int = 3):
|
| 30 |
+
for block in list(self.backbone.blocks)[-n:]:
|
|
|
|
| 31 |
for p in block.parameters():
|
| 32 |
p.requires_grad = True
|
|
|
|
| 33 |
if hasattr(self.backbone, "conv_head"):
|
| 34 |
for p in self.backbone.conv_head.parameters():
|
| 35 |
p.requires_grad = True
|
training/models/ensemble.py
CHANGED
|
@@ -1,12 +1,6 @@
|
|
| 1 |
# training/models/ensemble.py
|
| 2 |
-
"""
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
Loads a conjunctiva model and a nail-bed model.
|
| 6 |
-
Combines predictions with learned weights (optimised on val set).
|
| 7 |
-
Falls back gracefully if only one site image is provided.
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
import torch
|
| 11 |
import torch.nn as nn
|
| 12 |
from safetensors.torch import load_file
|
|
@@ -15,13 +9,7 @@ from training.models.efficientnet_b4 import AnemiaModel
|
|
| 15 |
|
| 16 |
|
| 17 |
class AnemiaEnsemble(nn.Module):
|
| 18 |
-
def __init__(
|
| 19 |
-
self,
|
| 20 |
-
conj_ckpt: str,
|
| 21 |
-
nail_ckpt: str,
|
| 22 |
-
w_conj: float = 0.5,
|
| 23 |
-
w_nail: float = 0.5,
|
| 24 |
-
):
|
| 25 |
super().__init__()
|
| 26 |
self.conj_model = AnemiaModel(pretrained=False)
|
| 27 |
self.nail_model = AnemiaModel(pretrained=False)
|
|
@@ -30,46 +18,25 @@ class AnemiaEnsemble(nn.Module):
|
|
| 30 |
self.w_conj = w_conj
|
| 31 |
self.w_nail = w_nail
|
| 32 |
|
| 33 |
-
def forward(
|
| 34 |
-
self,
|
| 35 |
-
conj_img: torch.Tensor | None = None,
|
| 36 |
-
nail_img: torch.Tensor | None = None,
|
| 37 |
-
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 38 |
if conj_img is not None and nail_img is not None:
|
| 39 |
hb_c, cls_c = self.conj_model(conj_img)
|
| 40 |
hb_n, cls_n = self.nail_model(nail_img)
|
| 41 |
-
|
| 42 |
-
cls = self.w_conj * cls_c + self.w_nail * cls_n
|
| 43 |
elif conj_img is not None:
|
| 44 |
-
|
| 45 |
elif nail_img is not None:
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
raise ValueError("At least one image (conjunctiva or nail-bed) must be provided")
|
| 49 |
-
return hb, cls
|
| 50 |
|
| 51 |
@classmethod
|
| 52 |
-
def find_best_weights(
|
| 53 |
-
|
| 54 |
-
conj_ckpt: str,
|
| 55 |
-
nail_ckpt: str,
|
| 56 |
-
val_rows_conj: list,
|
| 57 |
-
val_rows_nail: list,
|
| 58 |
-
config: dict,
|
| 59 |
-
) -> tuple[float, float]:
|
| 60 |
-
"""Grid search over ensemble weights on validation set. Returns (w_conj, w_nail).
|
| 61 |
-
|
| 62 |
-
IMPORTANT: val_rows_conj and val_rows_nail must be from the same patients
|
| 63 |
-
in the same order. The ensemble MAE is evaluated against conjunctiva ground-truth
|
| 64 |
-
(trues_c). Only valid when both sets cover the same patient population.
|
| 65 |
-
"""
|
| 66 |
if len(val_rows_conj) != len(val_rows_nail):
|
| 67 |
raise ValueError(
|
| 68 |
-
f"val_rows_conj ({len(val_rows_conj)}) and val_rows_nail "
|
| 69 |
-
|
| 70 |
-
"weight grid search. Ensure both cover the same patients."
|
| 71 |
)
|
| 72 |
-
import numpy as np
|
| 73 |
from torch.utils.data import DataLoader
|
| 74 |
|
| 75 |
from training.evaluation.metrics import compute_regression_metrics
|
|
@@ -101,10 +68,7 @@ class AnemiaEnsemble(nn.Module):
|
|
| 101 |
|
| 102 |
best_mae, best_wc = float("inf"), 0.5
|
| 103 |
for wc in np.arange(0.0, 1.05, 0.05):
|
| 104 |
-
|
| 105 |
-
ensemble_preds = wc * preds_c + wn * preds_n
|
| 106 |
-
mae = compute_regression_metrics(trues_c, ensemble_preds)["mae"]
|
| 107 |
if mae < best_mae:
|
| 108 |
-
best_mae, best_wc = mae, wc
|
| 109 |
-
|
| 110 |
-
return float(best_wc), float(1.0 - best_wc)
|
|
|
|
| 1 |
# training/models/ensemble.py
|
| 2 |
+
"""Late-fusion dual-site ensemble."""
|
| 3 |
+
import numpy as np
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import torch
|
| 5 |
import torch.nn as nn
|
| 6 |
from safetensors.torch import load_file
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
class AnemiaEnsemble(nn.Module):
|
| 12 |
+
def __init__(self, conj_ckpt: str, nail_ckpt: str, w_conj: float = 0.5, w_nail: float = 0.5):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
super().__init__()
|
| 14 |
self.conj_model = AnemiaModel(pretrained=False)
|
| 15 |
self.nail_model = AnemiaModel(pretrained=False)
|
|
|
|
| 18 |
self.w_conj = w_conj
|
| 19 |
self.w_nail = w_nail
|
| 20 |
|
| 21 |
+
def forward(self, conj_img=None, nail_img=None):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
if conj_img is not None and nail_img is not None:
|
| 23 |
hb_c, cls_c = self.conj_model(conj_img)
|
| 24 |
hb_n, cls_n = self.nail_model(nail_img)
|
| 25 |
+
return self.w_conj * hb_c + self.w_nail * hb_n, self.w_conj * cls_c + self.w_nail * cls_n
|
|
|
|
| 26 |
elif conj_img is not None:
|
| 27 |
+
return self.conj_model(conj_img)
|
| 28 |
elif nail_img is not None:
|
| 29 |
+
return self.nail_model(nail_img)
|
| 30 |
+
raise ValueError("At least one image must be provided")
|
|
|
|
|
|
|
| 31 |
|
| 32 |
@classmethod
|
| 33 |
+
def find_best_weights(cls, conj_ckpt, nail_ckpt, val_rows_conj, val_rows_nail, config):
|
| 34 |
+
"""Grid search over ensemble weights. Returns (w_conj, w_nail)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
if len(val_rows_conj) != len(val_rows_nail):
|
| 36 |
raise ValueError(
|
| 37 |
+
f"val_rows_conj ({len(val_rows_conj)}) and val_rows_nail ({len(val_rows_nail)}) "
|
| 38 |
+
"must have the same length for ensemble weight optimisation."
|
|
|
|
| 39 |
)
|
|
|
|
| 40 |
from torch.utils.data import DataLoader
|
| 41 |
|
| 42 |
from training.evaluation.metrics import compute_regression_metrics
|
|
|
|
| 68 |
|
| 69 |
best_mae, best_wc = float("inf"), 0.5
|
| 70 |
for wc in np.arange(0.0, 1.05, 0.05):
|
| 71 |
+
mae = compute_regression_metrics(trues_c, wc * preds_c + (1.0 - wc) * preds_n)["mae"]
|
|
|
|
|
|
|
| 72 |
if mae < best_mae:
|
| 73 |
+
best_mae, best_wc = mae, float(wc)
|
| 74 |
+
return best_wc, 1.0 - best_wc
|
|
|
training/push_model_to_hf.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
# training/push_model_to_hf.py
|
| 2 |
"""Push trained model weights and metrics to HuggingFace Hub."""
|
| 3 |
-
|
| 4 |
import json
|
| 5 |
import logging
|
| 6 |
import pathlib
|
|
@@ -13,16 +12,7 @@ log = logging.getLogger(__name__)
|
|
| 13 |
api = HfApi()
|
| 14 |
|
| 15 |
|
| 16 |
-
def push_model(
|
| 17 |
-
ckpt_path: str,
|
| 18 |
-
repo_id: str,
|
| 19 |
-
metrics: dict,
|
| 20 |
-
model_name: str,
|
| 21 |
-
site: str,
|
| 22 |
-
config: dict,
|
| 23 |
-
version: str = "v1.0.0",
|
| 24 |
-
):
|
| 25 |
-
"""Push a single model checkpoint + metrics to HF Hub."""
|
| 26 |
with tempfile.TemporaryDirectory() as tmpdir:
|
| 27 |
tmp = pathlib.Path(tmpdir)
|
| 28 |
shutil.copy(ckpt_path, tmp / "model.safetensors")
|
|
@@ -34,33 +24,16 @@ tags:
|
|
| 34 |
- medical-imaging
|
| 35 |
- anemia
|
| 36 |
- hemoglobin-estimation
|
| 37 |
-
- image-classification
|
| 38 |
pipeline_tag: image-classification
|
| 39 |
---
|
| 40 |
|
| 41 |
-
# AnemiaScan
|
| 42 |
|
| 43 |
**Task:** Non-invasive hemoglobin estimation + anemia severity classification from {site} images.
|
| 44 |
|
| 45 |
-
**Architecture:** {model_name} (ImageNet pretrained, fine-tuned)
|
| 46 |
-
|
| 47 |
-
**Input:** 380x380 RGB image of the palpebral {site}
|
| 48 |
-
|
| 49 |
-
**Outputs:**
|
| 50 |
-
- `hb_estimate` (float, g/dL)
|
| 51 |
-
- `classification` (str: normal / mild / moderate / severe)
|
| 52 |
-
|
| 53 |
-
## Performance (5-fold CV on public datasets)
|
| 54 |
-
|
| 55 |
-
| Metric | Mean +/- Std |
|
| 56 |
-
|--------|-----------|
|
| 57 |
-
| MAE (g/dL) | {metrics.get("mae_mean", "TBD")} |
|
| 58 |
-
| Pearson r | {metrics.get("pearson_r_mean", "TBD")} |
|
| 59 |
-
| AUC (macro) | {metrics.get("auc_mean", "TBD")} |
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
**Research tool only. Not a certified diagnostic device. All results require clinical confirmation.**
|
| 64 |
"""
|
| 65 |
(tmp / "README.md").write_text(card)
|
| 66 |
api.upload_folder(
|
|
@@ -73,13 +46,7 @@ pipeline_tag: image-classification
|
|
| 73 |
|
| 74 |
|
| 75 |
def push_all_models(
|
| 76 |
-
conj_ckpt
|
| 77 |
-
nail_ckpt: str,
|
| 78 |
-
cv_summary_conj: dict,
|
| 79 |
-
cv_summary_nail: dict,
|
| 80 |
-
w_conj: float,
|
| 81 |
-
w_nail: float,
|
| 82 |
-
config: dict,
|
| 83 |
):
|
| 84 |
push_model(
|
| 85 |
conj_ckpt,
|
|
@@ -89,21 +56,20 @@ def push_all_models(
|
|
| 89 |
"conjunctiva",
|
| 90 |
config,
|
| 91 |
)
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
| 100 |
ensemble_meta = {
|
| 101 |
"conj_model": "hssling/anemia-efficientnet-b4-conjunctiva",
|
| 102 |
-
"nail_model": "hssling/anemia-efficientnet-b4-nailbed",
|
| 103 |
"w_conj": w_conj,
|
| 104 |
"w_nail": w_nail,
|
| 105 |
-
"mae_mean": w_conj * cv_summary_conj.get("mae_mean", 0)
|
| 106 |
-
+ w_nail * cv_summary_nail.get("mae_mean", 0),
|
| 107 |
}
|
| 108 |
api.upload_file(
|
| 109 |
path_or_fileobj=json.dumps(ensemble_meta, indent=2).encode(),
|
|
|
|
| 1 |
# training/push_model_to_hf.py
|
| 2 |
"""Push trained model weights and metrics to HuggingFace Hub."""
|
|
|
|
| 3 |
import json
|
| 4 |
import logging
|
| 5 |
import pathlib
|
|
|
|
| 12 |
api = HfApi()
|
| 13 |
|
| 14 |
|
| 15 |
+
def push_model(ckpt_path, repo_id, metrics, model_name, site, config, version="v1.0.0"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
with tempfile.TemporaryDirectory() as tmpdir:
|
| 17 |
tmp = pathlib.Path(tmpdir)
|
| 18 |
shutil.copy(ckpt_path, tmp / "model.safetensors")
|
|
|
|
| 24 |
- medical-imaging
|
| 25 |
- anemia
|
| 26 |
- hemoglobin-estimation
|
|
|
|
| 27 |
pipeline_tag: image-classification
|
| 28 |
---
|
| 29 |
|
| 30 |
+
# AnemiaScan — {model_name} ({site})
|
| 31 |
|
| 32 |
**Task:** Non-invasive hemoglobin estimation + anemia severity classification from {site} images.
|
| 33 |
|
| 34 |
+
**Architecture:** {model_name} (ImageNet pretrained, fine-tuned on 380×380 RGB images)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
+
**Disclaimer:** Research tool only. Not a certified diagnostic device. Clinical confirmation required.
|
|
|
|
|
|
|
| 37 |
"""
|
| 38 |
(tmp / "README.md").write_text(card)
|
| 39 |
api.upload_folder(
|
|
|
|
| 46 |
|
| 47 |
|
| 48 |
def push_all_models(
|
| 49 |
+
conj_ckpt, nail_ckpt, cv_summary_conj, cv_summary_nail, w_conj, w_nail, config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
):
|
| 51 |
push_model(
|
| 52 |
conj_ckpt,
|
|
|
|
| 56 |
"conjunctiva",
|
| 57 |
config,
|
| 58 |
)
|
| 59 |
+
if nail_ckpt is not None:
|
| 60 |
+
push_model(
|
| 61 |
+
nail_ckpt,
|
| 62 |
+
"hssling/anemia-efficientnet-b4-nailbed",
|
| 63 |
+
cv_summary_nail,
|
| 64 |
+
"efficientnet_b4",
|
| 65 |
+
"nailbed",
|
| 66 |
+
config,
|
| 67 |
+
)
|
| 68 |
ensemble_meta = {
|
| 69 |
"conj_model": "hssling/anemia-efficientnet-b4-conjunctiva",
|
| 70 |
+
"nail_model": "hssling/anemia-efficientnet-b4-nailbed" if nail_ckpt else None,
|
| 71 |
"w_conj": w_conj,
|
| 72 |
"w_nail": w_nail,
|
|
|
|
|
|
|
| 73 |
}
|
| 74 |
api.upload_file(
|
| 75 |
path_or_fileobj=json.dumps(ensemble_meta, indent=2).encode(),
|
training/train.py
CHANGED
|
@@ -1,15 +1,5 @@
|
|
| 1 |
# training/train.py
|
| 2 |
-
"""
|
| 3 |
-
Core training loop: two-phase training (head warmup -> backbone fine-tune).
|
| 4 |
-
|
| 5 |
-
Usage:
|
| 6 |
-
python training/train.py \
|
| 7 |
-
--model efficientnet_b4 \
|
| 8 |
-
--site conjunctiva \
|
| 9 |
-
--config training/config.yaml \
|
| 10 |
-
--output-dir outputs/
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
import argparse
|
| 14 |
import importlib
|
| 15 |
import json
|
|
@@ -23,10 +13,7 @@ import wandb
|
|
| 23 |
import yaml
|
| 24 |
from torch.utils.data import DataLoader
|
| 25 |
|
| 26 |
-
from training.evaluation.metrics import
|
| 27 |
-
compute_classification_metrics,
|
| 28 |
-
compute_regression_metrics,
|
| 29 |
-
)
|
| 30 |
from training.utils.dataset import AnemiaDataset
|
| 31 |
|
| 32 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
@@ -40,21 +27,11 @@ def load_config(path: pathlib.Path) -> dict:
|
|
| 40 |
|
| 41 |
def get_model(model_name: str, config: dict) -> nn.Module:
|
| 42 |
mod = importlib.import_module(f"training.models.{model_name}")
|
| 43 |
-
return mod.AnemiaModel(
|
| 44 |
-
dropout_rate=config["model"]["dropout_rate"],
|
| 45 |
-
pretrained=True,
|
| 46 |
-
)
|
| 47 |
|
| 48 |
|
| 49 |
-
def multitask_loss(
|
| 50 |
-
|
| 51 |
-
hb_true: torch.Tensor,
|
| 52 |
-
class_logits: torch.Tensor,
|
| 53 |
-
class_true: torch.Tensor,
|
| 54 |
-
w_reg: float = 0.7,
|
| 55 |
-
w_cls: float = 0.3,
|
| 56 |
-
) -> torch.Tensor:
|
| 57 |
-
mse = nn.functional.mse_loss(hb_pred.squeeze(), hb_true.float())
|
| 58 |
ce = nn.functional.cross_entropy(class_logits, class_true.long())
|
| 59 |
return w_reg * mse + w_cls * ce
|
| 60 |
|
|
@@ -77,10 +54,10 @@ def run_epoch(model, loader, optimizer, device, training: bool, config: dict):
|
|
| 77 |
loss.backward()
|
| 78 |
optimizer.step()
|
| 79 |
total_loss += loss.item()
|
| 80 |
-
hb_preds.extend(hb_pred.squeeze(1).cpu().numpy().tolist())
|
| 81 |
-
hb_trues.extend(hb.cpu().numpy().tolist())
|
| 82 |
-
cls_preds.extend(torch.softmax(cls_logits, dim=1).cpu().numpy().tolist())
|
| 83 |
-
cls_trues.extend(cls.cpu().numpy().tolist())
|
| 84 |
|
| 85 |
reg_metrics = compute_regression_metrics(np.array(hb_trues), np.array(hb_preds))
|
| 86 |
cls_metrics = compute_classification_metrics(np.array(cls_trues), np.array(cls_preds))
|
|
@@ -92,31 +69,27 @@ def run_epoch(model, loader, optimizer, device, training: bool, config: dict):
|
|
| 92 |
}
|
| 93 |
|
| 94 |
|
| 95 |
-
def
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
) -> dict:
|
| 104 |
-
"""Full two-phase training. Returns best val metrics dict."""
|
| 105 |
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 106 |
log.info(f"Training {model_name} fold={fold} on {device}")
|
| 107 |
|
| 108 |
img_size = config["data"]["image_size"]
|
| 109 |
-
train_ds = AnemiaDataset(train_rows, image_size=img_size, augment=True)
|
| 110 |
-
val_ds = AnemiaDataset(val_rows, image_size=img_size, augment=False)
|
| 111 |
train_loader = DataLoader(
|
| 112 |
-
|
| 113 |
batch_size=config["data"]["batch_size"],
|
| 114 |
shuffle=True,
|
| 115 |
num_workers=config["data"]["num_workers"],
|
| 116 |
pin_memory=True,
|
| 117 |
)
|
| 118 |
val_loader = DataLoader(
|
| 119 |
-
|
| 120 |
batch_size=config["data"]["batch_size"],
|
| 121 |
shuffle=False,
|
| 122 |
num_workers=config["data"]["num_workers"],
|
|
@@ -124,7 +97,6 @@ def train_model(
|
|
| 124 |
)
|
| 125 |
|
| 126 |
model = get_model(model_name, config).to(device)
|
| 127 |
-
|
| 128 |
wandb_run = wandb.init(
|
| 129 |
project=config["wandb"]["project"],
|
| 130 |
name=run_name or f"{model_name}_fold{fold}",
|
|
@@ -132,14 +104,13 @@ def train_model(
|
|
| 132 |
reinit=True,
|
| 133 |
)
|
| 134 |
|
| 135 |
-
# Phase 1:
|
| 136 |
model.freeze_backbone()
|
| 137 |
optimizer = torch.optim.AdamW(
|
| 138 |
filter(lambda p: p.requires_grad, model.parameters()),
|
| 139 |
lr=config["training"]["phase1_lr"],
|
| 140 |
weight_decay=config["training"]["weight_decay"],
|
| 141 |
)
|
| 142 |
-
log.info("Phase 1: training heads only")
|
| 143 |
for epoch in range(config["training"]["phase1_epochs"]):
|
| 144 |
train_m = run_epoch(model, train_loader, optimizer, device, training=True, config=config)
|
| 145 |
val_m = run_epoch(model, val_loader, optimizer, device, training=False, config=config)
|
|
@@ -150,11 +121,9 @@ def train_model(
|
|
| 150 |
**{f"val/{k}": v for k, v in val_m.items()},
|
| 151 |
}
|
| 152 |
)
|
| 153 |
-
log.info(
|
| 154 |
-
f" Phase1 Ep{epoch + 1}: train_mae={train_m['mae']:.3f} val_mae={val_m['mae']:.3f}"
|
| 155 |
-
)
|
| 156 |
|
| 157 |
-
# Phase 2: unfreeze last
|
| 158 |
arch_cfg = next(
|
| 159 |
(a for a in config["model"]["architectures"] if a["name"] == model_name),
|
| 160 |
config["model"]["architectures"][0],
|
|
@@ -172,9 +141,8 @@ def train_model(
|
|
| 172 |
best_val_mae = float("inf")
|
| 173 |
patience_count = 0
|
| 174 |
best_metrics = {}
|
| 175 |
-
best_ckpt_path = output_dir / f"{model_name}_fold{fold}_best.safetensors"
|
| 176 |
|
| 177 |
-
log.info("Phase 2: fine-tuning last 3 blocks")
|
| 178 |
for epoch in range(config["training"]["phase2_epochs"]):
|
| 179 |
train_m = run_epoch(model, train_loader, optimizer, device, training=True, config=config)
|
| 180 |
val_m = run_epoch(model, val_loader, optimizer, device, training=False, config=config)
|
|
@@ -186,7 +154,7 @@ def train_model(
|
|
| 186 |
**{f"val/{k}": v for k, v in val_m.items()},
|
| 187 |
}
|
| 188 |
)
|
| 189 |
-
log.info(f"
|
| 190 |
|
| 191 |
if val_m["mae"] < best_val_mae:
|
| 192 |
best_val_mae = val_m["mae"]
|
|
@@ -196,34 +164,11 @@ def train_model(
|
|
| 196 |
else:
|
| 197 |
patience_count += 1
|
| 198 |
if patience_count >= config["training"]["early_stopping_patience"]:
|
| 199 |
-
log.info(f" Early stopping at epoch {epoch
|
| 200 |
break
|
| 201 |
|
| 202 |
wandb_run.finish()
|
| 203 |
-
metrics_path = output_dir / f"{model_name}_fold{fold}_metrics.json"
|
| 204 |
with open(metrics_path, "w") as f:
|
| 205 |
json.dump(best_metrics, f, indent=2)
|
| 206 |
-
log.info(f"Best val MAE: {best_val_mae:.3f} -- saved to {best_ckpt_path}")
|
| 207 |
return best_metrics
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
def _save_safetensors(model: nn.Module, path: pathlib.Path):
|
| 211 |
-
from safetensors.torch import save_file
|
| 212 |
-
|
| 213 |
-
path.parent.mkdir(parents=True, exist_ok=True)
|
| 214 |
-
save_file({k: v.contiguous() for k, v in model.state_dict().items()}, str(path))
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
def main():
|
| 218 |
-
parser = argparse.ArgumentParser()
|
| 219 |
-
parser.add_argument("--model", default="efficientnet_b4")
|
| 220 |
-
parser.add_argument("--config", default="training/config.yaml", type=pathlib.Path)
|
| 221 |
-
parser.add_argument("--output-dir", default="outputs/", type=pathlib.Path)
|
| 222 |
-
args = parser.parse_args()
|
| 223 |
-
load_config(args.config)
|
| 224 |
-
log.info(f"Config loaded: {args.config}")
|
| 225 |
-
log.info("Pass train_rows and val_rows to train_model() to start training.")
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
if __name__ == "__main__":
|
| 229 |
-
main()
|
|
|
|
| 1 |
# training/train.py
|
| 2 |
+
"""Two-phase training loop with W&B logging and safetensors checkpointing."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import argparse
|
| 4 |
import importlib
|
| 5 |
import json
|
|
|
|
| 13 |
import yaml
|
| 14 |
from torch.utils.data import DataLoader
|
| 15 |
|
| 16 |
+
from training.evaluation.metrics import compute_classification_metrics, compute_regression_metrics
|
|
|
|
|
|
|
|
|
|
| 17 |
from training.utils.dataset import AnemiaDataset
|
| 18 |
|
| 19 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
|
|
| 27 |
|
| 28 |
def get_model(model_name: str, config: dict) -> nn.Module:
|
| 29 |
mod = importlib.import_module(f"training.models.{model_name}")
|
| 30 |
+
return mod.AnemiaModel(dropout_rate=config["model"]["dropout_rate"], pretrained=True)
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
+
def multitask_loss(hb_pred, hb_true, class_logits, class_true, w_reg=0.7, w_cls=0.3):
|
| 34 |
+
mse = nn.functional.mse_loss(hb_pred.squeeze(1), hb_true.float())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
ce = nn.functional.cross_entropy(class_logits, class_true.long())
|
| 36 |
return w_reg * mse + w_cls * ce
|
| 37 |
|
|
|
|
| 54 |
loss.backward()
|
| 55 |
optimizer.step()
|
| 56 |
total_loss += loss.item()
|
| 57 |
+
hb_preds.extend(hb_pred.squeeze(1).detach().cpu().numpy().tolist())
|
| 58 |
+
hb_trues.extend(hb.detach().cpu().numpy().tolist())
|
| 59 |
+
cls_preds.extend(torch.softmax(cls_logits, dim=1).detach().cpu().numpy().tolist())
|
| 60 |
+
cls_trues.extend(cls.detach().cpu().numpy().tolist())
|
| 61 |
|
| 62 |
reg_metrics = compute_regression_metrics(np.array(hb_trues), np.array(hb_preds))
|
| 63 |
cls_metrics = compute_classification_metrics(np.array(cls_trues), np.array(cls_preds))
|
|
|
|
| 69 |
}
|
| 70 |
|
| 71 |
|
| 72 |
+
def _save_safetensors(model: nn.Module, path: pathlib.Path):
|
| 73 |
+
from safetensors.torch import save_file
|
| 74 |
+
|
| 75 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 76 |
+
save_file({k: v.contiguous() for k, v in model.state_dict().items()}, str(path))
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def train_model(model_name, train_rows, val_rows, config, output_dir, fold=0, run_name=""):
|
|
|
|
|
|
|
| 80 |
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 81 |
log.info(f"Training {model_name} fold={fold} on {device}")
|
| 82 |
|
| 83 |
img_size = config["data"]["image_size"]
|
|
|
|
|
|
|
| 84 |
train_loader = DataLoader(
|
| 85 |
+
AnemiaDataset(train_rows, image_size=img_size, augment=True),
|
| 86 |
batch_size=config["data"]["batch_size"],
|
| 87 |
shuffle=True,
|
| 88 |
num_workers=config["data"]["num_workers"],
|
| 89 |
pin_memory=True,
|
| 90 |
)
|
| 91 |
val_loader = DataLoader(
|
| 92 |
+
AnemiaDataset(val_rows, image_size=img_size, augment=False),
|
| 93 |
batch_size=config["data"]["batch_size"],
|
| 94 |
shuffle=False,
|
| 95 |
num_workers=config["data"]["num_workers"],
|
|
|
|
| 97 |
)
|
| 98 |
|
| 99 |
model = get_model(model_name, config).to(device)
|
|
|
|
| 100 |
wandb_run = wandb.init(
|
| 101 |
project=config["wandb"]["project"],
|
| 102 |
name=run_name or f"{model_name}_fold{fold}",
|
|
|
|
| 104 |
reinit=True,
|
| 105 |
)
|
| 106 |
|
| 107 |
+
# Phase 1: heads only
|
| 108 |
model.freeze_backbone()
|
| 109 |
optimizer = torch.optim.AdamW(
|
| 110 |
filter(lambda p: p.requires_grad, model.parameters()),
|
| 111 |
lr=config["training"]["phase1_lr"],
|
| 112 |
weight_decay=config["training"]["weight_decay"],
|
| 113 |
)
|
|
|
|
| 114 |
for epoch in range(config["training"]["phase1_epochs"]):
|
| 115 |
train_m = run_epoch(model, train_loader, optimizer, device, training=True, config=config)
|
| 116 |
val_m = run_epoch(model, val_loader, optimizer, device, training=False, config=config)
|
|
|
|
| 121 |
**{f"val/{k}": v for k, v in val_m.items()},
|
| 122 |
}
|
| 123 |
)
|
| 124 |
+
log.info(f" P1 Ep{epoch+1}: train_mae={train_m['mae']:.3f} val_mae={val_m['mae']:.3f}")
|
|
|
|
|
|
|
| 125 |
|
| 126 |
+
# Phase 2: unfreeze last n blocks (look up by model_name)
|
| 127 |
arch_cfg = next(
|
| 128 |
(a for a in config["model"]["architectures"] if a["name"] == model_name),
|
| 129 |
config["model"]["architectures"][0],
|
|
|
|
| 141 |
best_val_mae = float("inf")
|
| 142 |
patience_count = 0
|
| 143 |
best_metrics = {}
|
| 144 |
+
best_ckpt_path = pathlib.Path(output_dir) / f"{model_name}_fold{fold}_best.safetensors"
|
| 145 |
|
|
|
|
| 146 |
for epoch in range(config["training"]["phase2_epochs"]):
|
| 147 |
train_m = run_epoch(model, train_loader, optimizer, device, training=True, config=config)
|
| 148 |
val_m = run_epoch(model, val_loader, optimizer, device, training=False, config=config)
|
|
|
|
| 154 |
**{f"val/{k}": v for k, v in val_m.items()},
|
| 155 |
}
|
| 156 |
)
|
| 157 |
+
log.info(f" P2 Ep{epoch+1}: val_mae={val_m['mae']:.3f} val_auc={val_m['auc']:.3f}")
|
| 158 |
|
| 159 |
if val_m["mae"] < best_val_mae:
|
| 160 |
best_val_mae = val_m["mae"]
|
|
|
|
| 164 |
else:
|
| 165 |
patience_count += 1
|
| 166 |
if patience_count >= config["training"]["early_stopping_patience"]:
|
| 167 |
+
log.info(f" Early stopping at epoch {epoch+1}")
|
| 168 |
break
|
| 169 |
|
| 170 |
wandb_run.finish()
|
| 171 |
+
metrics_path = pathlib.Path(output_dir) / f"{model_name}_fold{fold}_metrics.json"
|
| 172 |
with open(metrics_path, "w") as f:
|
| 173 |
json.dump(best_metrics, f, indent=2)
|
|
|
|
| 174 |
return best_metrics
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
training/utils/augmentation.py
CHANGED
|
@@ -1,26 +1,21 @@
|
|
| 1 |
# training/utils/augmentation.py
|
| 2 |
"""Albumentations pipelines for training and validation."""
|
| 3 |
-
|
| 4 |
import albumentations as A
|
| 5 |
|
| 6 |
|
| 7 |
def get_augmentation_pipeline(image_size: int = 380) -> A.Compose:
|
| 8 |
-
return A.Compose(
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
]
|
| 18 |
-
)
|
| 19 |
|
| 20 |
|
| 21 |
def get_val_transforms(image_size: int = 380) -> A.Compose:
|
| 22 |
-
return A.Compose(
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
]
|
| 26 |
-
)
|
|
|
|
| 1 |
# training/utils/augmentation.py
|
| 2 |
"""Albumentations pipelines for training and validation."""
|
|
|
|
| 3 |
import albumentations as A
|
| 4 |
|
| 5 |
|
| 6 |
def get_augmentation_pipeline(image_size: int = 380) -> A.Compose:
|
| 7 |
+
return A.Compose([
|
| 8 |
+
A.Resize(image_size, image_size),
|
| 9 |
+
A.HorizontalFlip(p=0.5),
|
| 10 |
+
A.Rotate(limit=15, p=0.7),
|
| 11 |
+
A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.6),
|
| 12 |
+
A.HueSaturationValue(hue_shift_limit=10, sat_shift_limit=15, val_shift_limit=10, p=0.4),
|
| 13 |
+
A.GaussNoise(std_range=(0.01, 0.05), p=0.2),
|
| 14 |
+
A.CoarseDropout(num_holes_range=(1, 4), hole_height_range=(1, 32), hole_width_range=(1, 32), p=0.3),
|
| 15 |
+
])
|
|
|
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
def get_val_transforms(image_size: int = 380) -> A.Compose:
|
| 19 |
+
return A.Compose([
|
| 20 |
+
A.Resize(image_size, image_size),
|
| 21 |
+
])
|
|
|
|
|
|
training/utils/dataset.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
# training/utils/dataset.py
|
| 2 |
"""PyTorch Dataset for anemia screening images."""
|
| 3 |
-
|
| 4 |
from typing import Any
|
| 5 |
|
| 6 |
import numpy as np
|
|
@@ -28,7 +27,8 @@ class AnemiaDataset(Dataset):
|
|
| 28 |
self.rows = rows
|
| 29 |
self.image_size = image_size
|
| 30 |
self.transform = (
|
| 31 |
-
get_augmentation_pipeline(image_size) if augment
|
|
|
|
| 32 |
)
|
| 33 |
|
| 34 |
def __len__(self) -> int:
|
|
@@ -45,7 +45,6 @@ class AnemiaDataset(Dataset):
|
|
| 45 |
transformed = self.transform(image=img_arr)
|
| 46 |
img_tensor = torch.from_numpy(transformed["image"]).permute(2, 0, 1).float() / 255.0
|
| 47 |
|
| 48 |
-
# Normalize with ImageNet stats
|
| 49 |
mean = torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1)
|
| 50 |
std = torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1)
|
| 51 |
img_tensor = (img_tensor - mean) / std
|
|
|
|
| 1 |
# training/utils/dataset.py
|
| 2 |
"""PyTorch Dataset for anemia screening images."""
|
|
|
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
import numpy as np
|
|
|
|
| 27 |
self.rows = rows
|
| 28 |
self.image_size = image_size
|
| 29 |
self.transform = (
|
| 30 |
+
get_augmentation_pipeline(image_size) if augment
|
| 31 |
+
else get_val_transforms(image_size)
|
| 32 |
)
|
| 33 |
|
| 34 |
def __len__(self) -> int:
|
|
|
|
| 45 |
transformed = self.transform(image=img_arr)
|
| 46 |
img_tensor = torch.from_numpy(transformed["image"]).permute(2, 0, 1).float() / 255.0
|
| 47 |
|
|
|
|
| 48 |
mean = torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1)
|
| 49 |
std = torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1)
|
| 50 |
img_tensor = (img_tensor - mean) / std
|
training/utils/preprocessing.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# training/utils/preprocessing.py
|
| 2 |
+
"""Image preprocessing utilities."""
|
| 3 |
+
from training.utils.augmentation import get_val_transforms
|
| 4 |
+
|
| 5 |
+
__all__ = ["get_val_transforms"]
|