aelleeyy commited on
Commit
48bb70a
Β·
1 Parent(s): d1f37c3

Add E2E-VarNet reconstruction API

Browse files
Files changed (2) hide show
  1. app.py +203 -0
  2. requirements.txt +7 -0
app.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ E2E-VarNet MRI Reconstruction β€” HuggingFace Space
3
+
4
+ Two interfaces in one:
5
+ β€’ Gradio UI at / (visual demo on HF)
6
+ β€’ REST API at /reconstruct (called by React frontend)
7
+ β€’ Health at /health
8
+ """
9
+
10
+ import base64
11
+ import io
12
+ import time
13
+ from pathlib import Path
14
+
15
+ import gradio as gr
16
+ import h5py
17
+ import numpy as np
18
+ import torch
19
+ from fastapi import File, HTTPException, UploadFile
20
+ from fastapi.middleware.cors import CORSMiddleware
21
+ from PIL import Image
22
+ from huggingface_hub import hf_hub_download
23
+
24
+ import fastmri
25
+ from fastmri.data import transforms as T
26
+ from fastmri.data.subsample import EquispacedMaskFractionFunc
27
+ from fastmri.evaluate import ssim as fastmri_ssim, psnr as fastmri_psnr
28
+ from fastmri.models import VarNet
29
+ from fastmri.data.transforms import center_crop
30
+
31
+ # ── Config ────────────────────────────────────────────────────────────────────
32
+ HF_REPO = "alyrraza/e2e-varnet-mri-reconstruction"
33
+ CKPT_DIR = Path("checkpoints")
34
+ CENTER_FRACTIONS = [0.08]
35
+ ACCELERATIONS = [4]
36
+ MASK_SEED = 42 # explicit seed β€” avoids run-to-run non-determinism
37
+ CROP_SIZE = (320, 320)
38
+ FLAG_THRESHOLD = 0.018 # calibrated from research (1.2Γ— in-domain mean)
39
+ DEVICE = torch.device("cpu")
40
+
41
+ # ── Download & load models ────────────────────────────────────────────────────
42
+ CKPT_DIR.mkdir(exist_ok=True)
43
+
44
+ def _download(filename: str) -> None:
45
+ if not (CKPT_DIR / filename).exists():
46
+ print(f"Downloading {filename} from HF Hub …")
47
+ hf_hub_download(repo_id=HF_REPO, filename=filename, local_dir=str(CKPT_DIR))
48
+
49
+ def _load_varnet(path: Path) -> VarNet:
50
+ model = VarNet(num_cascades=4, chans=18, sens_chans=8)
51
+ state = torch.load(path, map_location=DEVICE, weights_only=False)
52
+ sd = state.get("model_state_dict", state)
53
+ model.load_state_dict(sd)
54
+ model.eval()
55
+ return model
56
+
57
+ print("Downloading checkpoints …")
58
+ _download("best_model.pt") # T4 epoch-21 (best val SSIM)
59
+ _download("checkpoint_epoch_50.pt") # T4 epoch-50 (final)
60
+ MODEL_BEST = _load_varnet(CKPT_DIR / "best_model.pt")
61
+ MODEL_FINAL = _load_varnet(CKPT_DIR / "checkpoint_epoch_50.pt")
62
+ print("Models ready.")
63
+
64
+ # ── Inference helpers ─────────────────────────────────────────────────────────
65
+
66
+ def _tensor_to_b64(t: torch.Tensor) -> str:
67
+ arr = t.numpy().astype(np.float32)
68
+ arr = (arr - arr.min()) / (arr.max() - arr.min() + 1e-8)
69
+ img = Image.fromarray((arr * 255).astype(np.uint8))
70
+ buf = io.BytesIO()
71
+ img.save(buf, format="PNG")
72
+ return base64.b64encode(buf.getvalue()).decode()
73
+
74
+
75
+ def _load_slice(data: bytes):
76
+ """Parse one slice from a fastMRI .h5 file. Returns (masked_kspace, mask, max_value)."""
77
+ with h5py.File(io.BytesIO(data), "r") as f:
78
+ kspace = f["kspace"][0] # first slice: [H, W] complex or [coils, H, W]
79
+ max_value = float(f.attrs.get("max", np.abs(kspace).max()))
80
+
81
+ mask_func = EquispacedMaskFractionFunc(
82
+ center_fractions=CENTER_FRACTIONS,
83
+ accelerations=ACCELERATIONS,
84
+ seed=MASK_SEED,
85
+ )
86
+ transform = T.VarNetDataTransform(mask_func=mask_func)
87
+
88
+ if kspace.ndim == 2:
89
+ kspace_np = kspace[np.newaxis, ..., np.newaxis] # [1, H, W, 1] complex
90
+ kspace_np = np.concatenate([kspace_np.real, kspace_np.imag], axis=-1) # [1, H, W, 2]
91
+ else:
92
+ kspace_np = kspace
93
+
94
+ sample = transform(kspace_np, None, None, None, max_value)
95
+ return sample.masked_kspace, sample.mask, max_value
96
+
97
+
98
+ def _run_model(model: VarNet, mk: torch.Tensor, mask: torch.Tensor, mv: float) -> torch.Tensor:
99
+ """One forward pass β†’ [H, W] cropped reconstruction."""
100
+ mk_ = mk if mk.dim() == 5 else mk.unsqueeze(0).unsqueeze(0)
101
+ mask_ = mask.bool() if mask.dim() == 5 else mask.bool().unsqueeze(0).unsqueeze(0)
102
+ with torch.no_grad():
103
+ out = model(mk_, mask_, torch.tensor(mv, device=DEVICE))
104
+ return center_crop(out.squeeze(0), CROP_SIZE)
105
+
106
+
107
+ def _infer(raw: bytes) -> dict:
108
+ """Full inference: two checkpoints β†’ mean recon + uncertainty + metrics."""
109
+ t0 = time.perf_counter()
110
+
111
+ mk, mask, max_value = _load_slice(raw)
112
+
113
+ img_best = _run_model(MODEL_BEST, mk, mask, max_value)
114
+ img_final = _run_model(MODEL_FINAL, mk, mask, max_value)
115
+
116
+ # Ensemble stats
117
+ stack = torch.stack([img_best, img_final]) # [2, H, W]
118
+ mean_img = stack.mean(0) # [H, W]
119
+ std_map = stack.std(0, unbiased=False) # [H, W]
120
+ scalar = float(std_map.mean()) / max_value if max_value > 0 else float(std_map.mean())
121
+
122
+ # SSIM / PSNR vs zero-filled (no ground truth at inference time)
123
+ zf_img = center_crop(
124
+ fastmri.complex_abs(fastmri.ifft2c(mk)).squeeze(0), CROP_SIZE
125
+ )
126
+ def _w(t): return t.unsqueeze(0).unsqueeze(0).numpy()
127
+ ssim_val = float(fastmri_ssim(_w(mean_img), _w(zf_img), maxval=max_value))
128
+ psnr_val = float(fastmri_psnr(_w(mean_img), _w(zf_img), maxval=max_value))
129
+
130
+ return {
131
+ "ssim": round(ssim_val, 4),
132
+ "psnr": round(psnr_val, 2),
133
+ "uncertainty_scalar": round(scalar, 6),
134
+ "flagged_for_review": scalar > FLAG_THRESHOLD,
135
+ "reconstruction_b64": _tensor_to_b64(mean_img),
136
+ "uncertainty_map_b64": _tensor_to_b64(std_map),
137
+ "inference_time_ms": round((time.perf_counter() - t0) * 1000, 1),
138
+ "model_variant": "T4-ensemble-K2",
139
+ }
140
+
141
+ # ── Gradio UI ─────────────────────────────────────────────────────────────────
142
+
143
+ def gradio_predict(file):
144
+ if file is None:
145
+ return "Upload a file first.", None, None
146
+ with open(file, "rb") as f:
147
+ raw = f.read()
148
+ res = _infer(raw)
149
+ label = (
150
+ f"SSIM: {res['ssim']} | PSNR: {res['psnr']} dB | "
151
+ f"Uncertainty: {res['uncertainty_scalar']} | "
152
+ f"{'⚠ OOD flagged' if res['flagged_for_review'] else 'βœ“ In-distribution'} | "
153
+ f"{res['inference_time_ms']:.0f} ms"
154
+ )
155
+ recon = Image.open(io.BytesIO(base64.b64decode(res["reconstruction_b64"])))
156
+ umap = Image.open(io.BytesIO(base64.b64decode(res["uncertainty_map_b64"])))
157
+ return label, recon, umap
158
+
159
+ demo = gr.Interface(
160
+ fn=gradio_predict,
161
+ inputs=gr.File(label="Upload fastMRI .h5 file", file_types=[".h5"]),
162
+ outputs=[
163
+ gr.Textbox(label="Metrics"),
164
+ gr.Image(label="Reconstruction"),
165
+ gr.Image(label="Uncertainty Map"),
166
+ ],
167
+ title="E2E-VarNet MRI Reconstruction",
168
+ description=(
169
+ "Upload a fastMRI single-coil knee `.h5` file. "
170
+ "The model reconstructs the image from 25 % of k-space (4Γ— acceleration) "
171
+ "and returns a checkpoint-ensemble uncertainty score. "
172
+ "OOD shift: **1.54Γ—** higher uncertainty on brain vs knee, "
173
+ "bootstrap 95 % CI [1.35Γ—, 1.74Γ—]."
174
+ ),
175
+ )
176
+
177
+ # ── Mount REST API on the Gradio FastAPI app ──────────────────────────────────
178
+ # These endpoints are called by the React frontend β€” same format as the local FastAPI server.
179
+
180
+ demo.app.add_middleware(
181
+ CORSMiddleware,
182
+ allow_origins=["*"],
183
+ allow_methods=["*"],
184
+ allow_headers=["*"],
185
+ )
186
+
187
+ @demo.app.get("/health")
188
+ def health():
189
+ return {"status": "ok", "model_loaded": True, "device": "cpu"}
190
+
191
+ @demo.app.post("/reconstruct")
192
+ async def reconstruct(file: UploadFile = File(...)):
193
+ if not (file.filename or "").endswith(".h5"):
194
+ raise HTTPException(400, "File must be a .h5 fastMRI file")
195
+ raw = await file.read()
196
+ try:
197
+ return _infer(raw)
198
+ except Exception as exc:
199
+ raise HTTPException(422, f"Inference failed: {exc}")
200
+
201
+ # ── Entry point ───────────────────────────────────────────────────────────────
202
+ if __name__ == "__main__":
203
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ gradio>=4.40.0
2
+ torch
3
+ h5py
4
+ numpy
5
+ Pillow
6
+ huggingface_hub
7
+ git+https://github.com/facebookresearch/fastMRI.git@91f2df4711adbb6d643df1810f234e4abcf5881b