Publish the FLIP tutorial models
Browse files- .gitattributes +1 -0
- README.md +83 -0
- code/config.json +43 -0
- code/models.py +68 -0
- code/plot_utils.py +605 -0
- code/trainer.py +429 -0
- code/transforms.py +58 -0
- diffusion_model.safetensors +3 -0
- evaluation/sample_xray.py +44 -0
- figures/samples_4x4.png +3 -0
- provenance.json +36 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
figures/samples_4x4.png filter=lfs diff=lfs merge=lfs -text
|
README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
library_name: monai
|
| 4 |
+
tags:
|
| 5 |
+
- medical-imaging
|
| 6 |
+
- chest-x-ray
|
| 7 |
+
- diffusion
|
| 8 |
+
- ddpm
|
| 9 |
+
- federated-learning
|
| 10 |
+
- nvflare
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# Chest X-ray diffusion model: FLIP tutorial model
|
| 14 |
+
|
| 15 |
+
A 2-D denoising diffusion model (DDPM) that generates 128×128 chest X-rays, trained with the
|
| 16 |
+
chest X-ray diffusion tutorial of [FLIP](https://github.com/londonaicentre/FLIP), the Federated
|
| 17 |
+
Learning Interoperability Platform (`fl-tutorials/nvflare/image_generation/xray_diffusion/`).
|
| 18 |
+
|
| 19 |
+
It was trained federated, with NVIDIA FLARE simulating two sites on one GPU, entirely on **synthetic**
|
| 20 |
+
X-rays. It is a tutorial model, not for clinical use.
|
| 21 |
+
|
| 22 |
+

|
| 23 |
+
|
| 24 |
+
## Contents
|
| 25 |
+
|
| 26 |
+
```text
|
| 27 |
+
diffusion_model.safetensors the weights, float32 as trained
|
| 28 |
+
provenance.json commit, install line, launch command, versions, hardware, wall time, final losses
|
| 29 |
+
code/ the app files exactly as the run executed them
|
| 30 |
+
evaluation/sample_xray.py the script that drew the samples above
|
| 31 |
+
figures/
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
## Model and training
|
| 35 |
+
|
| 36 |
+
MONAI `DiffusionModelUNet` in 2-D, unconditional (its only input besides the image is the noise
|
| 37 |
+
step), 22.1 M parameters, trained from scratch: four levels of 64, 128, 192 and 256 channels, with
|
| 38 |
+
self-attention at the two coarsest. It predicts the noise added at a random step of a 1000-step DDPM
|
| 39 |
+
schedule, with an MSE loss.
|
| 40 |
+
|
| 41 |
+
| | |
|
| 42 |
+
| --- | --- |
|
| 43 |
+
| Data | Ark+ tutorial splits, about 1.9k images per site, two sites |
|
| 44 |
+
| Preprocessing | grayscale, resized to 128×128, scaled to [0, 1]; light random affine in training |
|
| 45 |
+
| Schedule | 2 rounds × 300 epochs, batch 6 |
|
| 46 |
+
| Learning rate | 1e-3 decaying to 5e-5 over the first 200 epochs |
|
| 47 |
+
| Final validation loss | 0.0039 (site 1), 0.0036 (site 2), on each site's held-out 20 % |
|
| 48 |
+
| Wall time | 5 h 05 min on one NVIDIA GeForce RTX 5090 |
|
| 49 |
+
| Software | PyTorch 2.13.0, MONAI 1.6.0, NVIDIA FLARE 2.9.0 |
|
| 50 |
+
|
| 51 |
+
Samples saved during training looked like chest X-rays from about epoch 150.
|
| 52 |
+
|
| 53 |
+
## Use
|
| 54 |
+
|
| 55 |
+
```python
|
| 56 |
+
import json
|
| 57 |
+
from monai.networks.nets import DiffusionModelUNet
|
| 58 |
+
from safetensors.torch import load_file
|
| 59 |
+
|
| 60 |
+
net_config = json.load(open("code/config.json"))["net_config"]
|
| 61 |
+
model = DiffusionModelUNet(spatial_dims=2, **net_config["diffusion_model"])
|
| 62 |
+
weights = load_file("diffusion_model.safetensors")
|
| 63 |
+
model.load_state_dict({k.removeprefix("diffusion_model."): v for k, v in weights.items()})
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
Sample with MONAI's `DDPMScheduler` and `DiffusionInferer` (1000 steps), as in
|
| 67 |
+
`evaluation/sample_xray.py`.
|
| 68 |
+
|
| 69 |
+
## Reproducing it
|
| 70 |
+
|
| 71 |
+
Install flip-utils from the commit in `provenance.json` with its install line, check out FLIP at
|
| 72 |
+
the same commit, copy `code/` over the tutorial's `app_files/`, download the data with
|
| 73 |
+
`make -C fl-tutorials download-arkplus-finetuning-data`, and run the launch command. The run predates
|
| 74 |
+
the tutorial's move to `image_generation/`, so `provenance.json` gives the directory it ran from.
|
| 75 |
+
Training on a GPU is not bit-for-bit repeatable, so expect samples of the same quality, not the same
|
| 76 |
+
samples.
|
| 77 |
+
|
| 78 |
+
## Data and licence
|
| 79 |
+
|
| 80 |
+
The Ark+ tutorial splits
|
| 81 |
+
([`aicentreflip/tutorials-arkplus-cxr-classification`](https://huggingface.co/datasets/aicentreflip/tutorials-arkplus-cxr-classification))
|
| 82 |
+
are fully synthetic chest X-rays (DeCaf `balanced_synthetic_split`), with no patient data, licensed
|
| 83 |
+
Apache-2.0. This model is licensed Apache-2.0 too.
|
code/config.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"job_type": "standard",
|
| 3 |
+
"GLOBAL_ROUNDS": 2,
|
| 4 |
+
"LOCAL_ROUNDS": 300,
|
| 5 |
+
"VALIDATE_EVERY": 25,
|
| 6 |
+
"LR_START": 0.001,
|
| 7 |
+
"LR_END": 5e-05,
|
| 8 |
+
"LR_DECAY_EPOCHS": 200,
|
| 9 |
+
"GRAD_CLIP_NORM": 1.0,
|
| 10 |
+
"VAL_SPLIT": 0.2,
|
| 11 |
+
"BATCH_SIZE": 6,
|
| 12 |
+
"SAVE_DEBUG_SAMPLES": true,
|
| 13 |
+
"spatial_shape": [
|
| 14 |
+
128,
|
| 15 |
+
128
|
| 16 |
+
],
|
| 17 |
+
"net_config": {
|
| 18 |
+
"spatial_dims": 2,
|
| 19 |
+
"diffusion_model": {
|
| 20 |
+
"in_channels": 1,
|
| 21 |
+
"out_channels": 1,
|
| 22 |
+
"channels": [
|
| 23 |
+
64,
|
| 24 |
+
128,
|
| 25 |
+
192,
|
| 26 |
+
256
|
| 27 |
+
],
|
| 28 |
+
"num_res_blocks": [
|
| 29 |
+
2,
|
| 30 |
+
2,
|
| 31 |
+
2,
|
| 32 |
+
2
|
| 33 |
+
],
|
| 34 |
+
"attention_levels": [
|
| 35 |
+
false,
|
| 36 |
+
false,
|
| 37 |
+
true,
|
| 38 |
+
true
|
| 39 |
+
]
|
| 40 |
+
}
|
| 41 |
+
},
|
| 42 |
+
"DATALOADER_WORKERS": 8
|
| 43 |
+
}
|
code/models.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London
|
| 2 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 3 |
+
# you may not use this file except in compliance with the License.
|
| 4 |
+
# You may obtain a copy of the License at
|
| 5 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
| 6 |
+
# Unless required by applicable law or agreed to in writing, software
|
| 7 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 8 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 9 |
+
# See the License for the specific language governing permissions and
|
| 10 |
+
# limitations under the License.
|
| 11 |
+
|
| 12 |
+
"""Diffusion model network for the FLIP pixel-space diffusion tutorial.
|
| 13 |
+
|
| 14 |
+
A single ``DiffusionModelUNet`` that denoises **images directly**. There is no autoencoder and no
|
| 15 |
+
discriminator here: the diffusion process runs at full image resolution, so nothing needs to be
|
| 16 |
+
compressed first and nothing is adversarially trained.
|
| 17 |
+
|
| 18 |
+
Because the UNet sees images rather than latents, its ``in_channels``/``out_channels`` are the
|
| 19 |
+
**image** channel count (1 for the single-channel chest X-rays this tutorial uses), not an
|
| 20 |
+
autoencoder's ``latent_channels``. Contrast the latent diffusion tutorial, where the same network
|
| 21 |
+
operates on a 3-channel latent.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import json
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
from monai.networks.nets import DiffusionModelUNet
|
| 28 |
+
from torch import nn
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def load_net_config():
|
| 32 |
+
"""Read the ``net_config`` block from the ``config.json`` next to this file."""
|
| 33 |
+
config_path = Path(__file__).parent / "config.json"
|
| 34 |
+
with open(config_path, "r") as f:
|
| 35 |
+
config = json.load(f)
|
| 36 |
+
net_config = config.get("net_config", {})
|
| 37 |
+
print(f"Loaded network config: {net_config}")
|
| 38 |
+
return net_config
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class DiffusionModelNetwork(nn.Module):
|
| 42 |
+
"""Creates a pixel-space diffusion model: a single UNet denoising images directly."""
|
| 43 |
+
|
| 44 |
+
def __init__(self):
|
| 45 |
+
super().__init__()
|
| 46 |
+
net_config = load_net_config()
|
| 47 |
+
self.diffusion_model = DiffusionModelUNet(
|
| 48 |
+
spatial_dims=net_config["spatial_dims"],
|
| 49 |
+
in_channels=net_config["diffusion_model"]["in_channels"],
|
| 50 |
+
out_channels=net_config["diffusion_model"]["out_channels"],
|
| 51 |
+
channels=net_config["diffusion_model"]["channels"],
|
| 52 |
+
num_res_blocks=net_config["diffusion_model"]["num_res_blocks"],
|
| 53 |
+
attention_levels=net_config["diffusion_model"]["attention_levels"],
|
| 54 |
+
# Fused scaled-dot-product attention: the same result, without materialising each head's map.
|
| 55 |
+
use_flash_attention=True,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
_net = DiffusionModelNetwork()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def get_model() -> nn.Module:
|
| 63 |
+
"""
|
| 64 |
+
Returns the model defined in this file.
|
| 65 |
+
NOTE: This function needs to exist and cannot take any input arguments. If you would like to parameterize the
|
| 66 |
+
configuration of your model, for example loaded from a config file, do it when instantiating the model above.
|
| 67 |
+
"""
|
| 68 |
+
return _net
|
code/plot_utils.py
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London
|
| 2 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 3 |
+
# you may not use this file except in compliance with the License.
|
| 4 |
+
# You may obtain a copy of the License at
|
| 5 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
| 6 |
+
# Unless required by applicable law or agreed to in writing, software
|
| 7 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 8 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 9 |
+
# See the License for the specific language governing permissions and
|
| 10 |
+
# limitations under the License.
|
| 11 |
+
|
| 12 |
+
"""Figures for the image-synthesis tutorials, for local development only.
|
| 13 |
+
|
| 14 |
+
``save_grid`` and ``save_triplanar`` write debug images during a simulator run; run as a script, this
|
| 15 |
+
module plots each site's losses from a simulator workspace. It sits in ``image_synthesis/dev/``,
|
| 16 |
+
which is never uploaded: each tutorial's ``job.py`` stages it into simulator runs, and on a trust the
|
| 17 |
+
trainers' import of it fails and debug output is simply off.
|
| 18 |
+
|
| 19 |
+
Images are written only when ``config.json`` sets ``SAVE_DEBUG_SAMPLES`` **and** this is a
|
| 20 |
+
Makefile-launched simulator run (see :func:`samples_enabled`), into ``DEBUG_SAMPLES_DIR``. Nothing
|
| 21 |
+
here sends an image anywhere.
|
| 22 |
+
|
| 23 |
+
Usage, from a tutorial's directory after ``make run``::
|
| 24 |
+
|
| 25 |
+
python ../dev/plot_utils.py # reads /tmp/nvflare/<this tutorial>/flip_fedavg
|
| 26 |
+
python ../dev/plot_utils.py --workspace <dir> --out-dir <dir>
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import argparse
|
| 32 |
+
import logging
|
| 33 |
+
import os
|
| 34 |
+
import re
|
| 35 |
+
from pathlib import Path
|
| 36 |
+
|
| 37 |
+
import numpy as np
|
| 38 |
+
import torch
|
| 39 |
+
from flip.constants import FlipConstants
|
| 40 |
+
from torchvision.utils import make_grid, save_image
|
| 41 |
+
|
| 42 |
+
logger = logging.getLogger(__name__)
|
| 43 |
+
|
| 44 |
+
#: Set by the tutorial Makefiles' ``sim`` recipe only: where the images go, and half of the simulator
|
| 45 |
+
#: check in :func:`in_simulation`. Never a ``config.json`` key.
|
| 46 |
+
DEBUG_DIR_ENV = "DEBUG_SAMPLES_DIR"
|
| 47 |
+
|
| 48 |
+
#: How many images to write per grid when ``DEBUG_SAMPLES_MAX`` is absent from the config.
|
| 49 |
+
DEFAULT_MAX_IMAGES = 8
|
| 50 |
+
|
| 51 |
+
#: Training iterations between tri-planar figures when ``DEBUG_PLOT_EVERY`` is absent.
|
| 52 |
+
DEFAULT_PLOT_EVERY = 100
|
| 53 |
+
|
| 54 |
+
#: The three orthogonal views: display name, the array axis each one holds fixed, and whether the
|
| 55 |
+
#: slice needs a left-right flip after the shared rotation.
|
| 56 |
+
#:
|
| 57 |
+
#: Volumes reach here as ``(C, X, Y, Z)`` with the chain's ``Orientationd(axcodes="RAS")``
|
| 58 |
+
#: guaranteeing what the axes mean: +X is right, +Y anterior, +Z superior. So a sagittal view fixes
|
| 59 |
+
#: X, a coronal view fixes Y, and an axial view fixes Z. Without that guarantee these labels — and
|
| 60 |
+
#: the flip below — would be guesses.
|
| 61 |
+
#:
|
| 62 |
+
#: One ``np.rot90`` puts the vertical axis the right way up in all three (superior up for sagittal
|
| 63 |
+
#: and coronal, anterior up for axial), but it does not settle the horizontal one, and the sagittal
|
| 64 |
+
#: view is the odd one out. Coronal and axial both have **X** across the page, so rot90 leaves them
|
| 65 |
+
#: with the patient's left on the viewer's left — neurological convention. Sagittal has **Y** across
|
| 66 |
+
#: the page instead, which leaves anterior on the *right*: the head faces backwards compared with how
|
| 67 |
+
#: every other view here, and every standard neuroimaging viewer, shows it. Hence the flip.
|
| 68 |
+
_PLANES = (("sagittal", 0, True), ("coronal", 1, False), ("axial", 2, False))
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def in_simulation() -> bool:
|
| 72 |
+
"""Whether this process is a local simulator run launched by a tutorial Makefile.
|
| 73 |
+
|
| 74 |
+
Needs **both** signals, so that neither alone can open the gate on a trust:
|
| 75 |
+
|
| 76 |
+
- ``FlipConstants.LOCAL_DEV`` is true. Every trust deployment (compose and Helm) sets it to
|
| 77 |
+
``false``, and the ``flip`` library already reads its data from the trust APIs on that value,
|
| 78 |
+
so a client with it true cannot be running a real cohort. It is not enough on its own because
|
| 79 |
+
it *defaults* to true when the variable is unset.
|
| 80 |
+
- ``DEBUG_SAMPLES_DIR`` is set and non-blank. Only the tutorial Makefiles' ``sim`` recipe sets
|
| 81 |
+
it; it is not a ``config.json`` key, so nothing uploaded with the app can supply it.
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
bool: True only inside a Makefile-launched simulator run.
|
| 85 |
+
"""
|
| 86 |
+
if not os.environ.get(DEBUG_DIR_ENV, "").strip():
|
| 87 |
+
return False
|
| 88 |
+
try:
|
| 89 |
+
return FlipConstants.LOCAL_DEV is True
|
| 90 |
+
except Exception: # unreadable settings: fail closed
|
| 91 |
+
return False
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def samples_enabled(config: dict) -> bool:
|
| 95 |
+
"""Whether this job writes client-local debug images.
|
| 96 |
+
|
| 97 |
+
True only when ``config.json`` sets ``SAVE_DEBUG_SAMPLES`` **and** :func:`in_simulation` holds.
|
| 98 |
+
The config is the user's switch — false means no images, simulator included. It is never what
|
| 99 |
+
separates simulation from production, because ``config.json`` travels with the app to the trust:
|
| 100 |
+
in production (``LOCAL_DEV=false``, no ``DEBUG_SAMPLES_DIR``) this returns False whatever the
|
| 101 |
+
config says, so patient-derived pixels are never written at a trust.
|
| 102 |
+
|
| 103 |
+
This is the single gate for both writers. ``save_grid`` and ``save_triplanar`` consult it and
|
| 104 |
+
no-op when it is false, so a caller must never add a second condition of its own: a sampling
|
| 105 |
+
block that runs on a looser test than this one pays for its output and then discards it.
|
| 106 |
+
|
| 107 |
+
Args:
|
| 108 |
+
config (dict): The parsed ``config.json``.
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
bool: True when ``SAVE_DEBUG_SAMPLES`` is truthy and this is a simulator run.
|
| 112 |
+
"""
|
| 113 |
+
return bool(config.get("SAVE_DEBUG_SAMPLES", False)) and in_simulation()
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def plot_every(config: dict) -> int:
|
| 117 |
+
"""Iterations between tri-planar figures, from ``DEBUG_PLOT_EVERY`` (default 100).
|
| 118 |
+
|
| 119 |
+
Args:
|
| 120 |
+
config (dict): The parsed ``config.json``.
|
| 121 |
+
|
| 122 |
+
Returns:
|
| 123 |
+
int: The interval. Zero or negative disables the figures while leaving the grids on.
|
| 124 |
+
"""
|
| 125 |
+
return int(config.get("DEBUG_PLOT_EVERY", DEFAULT_PLOT_EVERY))
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def due_for_plot(iteration: int, config: dict) -> bool:
|
| 129 |
+
"""Whether ``iteration`` (0-based) is one of the every-N steps that gets a tri-planar figure.
|
| 130 |
+
|
| 131 |
+
True on iteration 0 as well as every Nth after it, so a run that crashes early still leaves one
|
| 132 |
+
figure behind — the first is also the most diagnostic, being the only look at an untrained model.
|
| 133 |
+
|
| 134 |
+
Args:
|
| 135 |
+
iteration (int): Zero-based training step, counted across rounds and epochs (not within one
|
| 136 |
+
epoch — an epoch shorter than the interval would otherwise fire once per epoch).
|
| 137 |
+
config (dict): The parsed ``config.json``.
|
| 138 |
+
|
| 139 |
+
Returns:
|
| 140 |
+
bool: True when a figure is due and debug output is enabled at all.
|
| 141 |
+
"""
|
| 142 |
+
if not samples_enabled(config):
|
| 143 |
+
return False
|
| 144 |
+
interval = plot_every(config)
|
| 145 |
+
return interval > 0 and iteration % interval == 0
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def debug_dir() -> Path:
|
| 149 |
+
"""``DEBUG_SAMPLES_DIR``, created if needed; the tutorial Makefiles point it under fl-tutorials/data/."""
|
| 150 |
+
target = Path(os.environ[DEBUG_DIR_ENV].strip()).expanduser().resolve()
|
| 151 |
+
target.mkdir(parents=True, exist_ok=True)
|
| 152 |
+
return target
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _to_2d(batch: torch.Tensor) -> torch.Tensor:
|
| 156 |
+
"""Reduce a batch to something ``make_grid`` can tile, taking a mid-slice of any volume.
|
| 157 |
+
|
| 158 |
+
Args:
|
| 159 |
+
batch (torch.Tensor): ``(B, C, H, W)`` or ``(B, C, H, W, D)``.
|
| 160 |
+
|
| 161 |
+
Returns:
|
| 162 |
+
torch.Tensor: ``(B, C, H, W)`` — volumes sliced at the centre of the last axis, which for the
|
| 163 |
+
RAS-oriented volumetric chain is the axial mid-slice.
|
| 164 |
+
"""
|
| 165 |
+
if batch.ndim == 5:
|
| 166 |
+
return batch[..., batch.shape[-1] // 2]
|
| 167 |
+
return batch
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _name_parts(name: str, site_name: str, step: int | None) -> list[str]:
|
| 171 |
+
"""Filename parts shared by both writers: what, where, when — in that order."""
|
| 172 |
+
parts = [name]
|
| 173 |
+
if site_name:
|
| 174 |
+
parts.append(site_name)
|
| 175 |
+
if step is not None:
|
| 176 |
+
parts.append(f"step{step:04d}")
|
| 177 |
+
return parts
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _prepare(batch: torch.Tensor, max_images: int) -> torch.Tensor:
|
| 181 |
+
"""Detach, truncate and flatten a batch to greyscale 2-D images ready for tiling."""
|
| 182 |
+
prepared = _to_2d(batch.detach().float().cpu()[:max_images])
|
| 183 |
+
if prepared.shape[1] > 1:
|
| 184 |
+
# Multi-channel here means a latent, not a colour image; show the first channel rather than
|
| 185 |
+
# letting save_image interpret 3 channels as RGB.
|
| 186 |
+
prepared = prepared[:, :1]
|
| 187 |
+
return prepared
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def save_grid(
|
| 191 |
+
tensors: dict[str, torch.Tensor],
|
| 192 |
+
name: str,
|
| 193 |
+
config: dict,
|
| 194 |
+
*,
|
| 195 |
+
site_name: str = "",
|
| 196 |
+
step: int | None = None,
|
| 197 |
+
) -> Path | None:
|
| 198 |
+
"""Write one PNG tiling the given batches, one row per entry. No-op unless enabled.
|
| 199 |
+
|
| 200 |
+
Rows are normalised **jointly**, not per image: independent scaling would rescale a washed-out
|
| 201 |
+
reconstruction back onto its input's range and hide precisely the intensity drift the comparison
|
| 202 |
+
is meant to expose.
|
| 203 |
+
|
| 204 |
+
Never raises. Debug output must not be able to fail a training round, so any error (a read-only
|
| 205 |
+
workspace, a tensor of an unexpected rank) is logged and swallowed.
|
| 206 |
+
|
| 207 |
+
Args:
|
| 208 |
+
tensors (dict[str, torch.Tensor]): Batches to tile, one row each, in insertion order — e.g.
|
| 209 |
+
``{"input": images, "reconstruction": recon}``. Keys appear in the filename.
|
| 210 |
+
name (str): Short label for what this grid shows, e.g. ``"reconstruction"``.
|
| 211 |
+
config (dict): The parsed ``config.json``; read for ``SAVE_DEBUG_SAMPLES``/``DEBUG_SAMPLES_MAX``.
|
| 212 |
+
site_name (str): Client name, included in the filename so a shared workspace cannot collide.
|
| 213 |
+
step (int | None): Round or epoch counter, included in the filename to order the images.
|
| 214 |
+
|
| 215 |
+
Returns:
|
| 216 |
+
Path | None: The file written, or None when disabled or on any failure.
|
| 217 |
+
"""
|
| 218 |
+
if not samples_enabled(config) or not tensors:
|
| 219 |
+
return None
|
| 220 |
+
|
| 221 |
+
try:
|
| 222 |
+
max_images = int(config.get("DEBUG_SAMPLES_MAX", DEFAULT_MAX_IMAGES))
|
| 223 |
+
rows = [_prepare(batch, max_images) for batch in tensors.values()]
|
| 224 |
+
columns = min(row.shape[0] for row in rows)
|
| 225 |
+
grid = make_grid(
|
| 226 |
+
torch.cat([row[:columns] for row in rows]),
|
| 227 |
+
nrow=columns,
|
| 228 |
+
normalize=True,
|
| 229 |
+
scale_each=False,
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
label = "_".join([name, *tensors.keys()]) if len(tensors) > 1 else name
|
| 233 |
+
path = debug_dir() / ("_".join(_name_parts(label, site_name, step)) + ".png")
|
| 234 |
+
|
| 235 |
+
save_image(grid, str(path))
|
| 236 |
+
logger.info(f"[DEBUG] Wrote {columns} sample(s) to {path}")
|
| 237 |
+
return path
|
| 238 |
+
except Exception as err:
|
| 239 |
+
logger.warning(f"[DEBUG] Could not write debug samples {name!r}: {err}")
|
| 240 |
+
return None
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def _central_slice(volume: torch.Tensor, axis: int, flip_lr: bool = False) -> np.ndarray:
|
| 244 |
+
"""The central slice of one 3-D volume along ``axis``, oriented for display.
|
| 245 |
+
|
| 246 |
+
Args:
|
| 247 |
+
volume (torch.Tensor): A single volume, ``(X, Y, Z)``, channel already removed.
|
| 248 |
+
axis (int): Spatial axis to hold fixed — 0 sagittal, 1 coronal, 2 axial.
|
| 249 |
+
flip_lr (bool): Mirror the slice horizontally after the rotation. True for the sagittal
|
| 250 |
+
view only; see :data:`_PLANES` for why it alone needs it.
|
| 251 |
+
|
| 252 |
+
Returns:
|
| 253 |
+
np.ndarray: The 2-D slice, rotated so the axis that runs superior (or anterior, for the
|
| 254 |
+
axial view) points up the page, and mirrored if this view needs it.
|
| 255 |
+
"""
|
| 256 |
+
plane = volume.index_select(axis, torch.tensor([volume.shape[axis] // 2], device=volume.device)).squeeze(axis)
|
| 257 |
+
rotated = np.rot90(plane.numpy())
|
| 258 |
+
return np.fliplr(rotated) if flip_lr else rotated
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def save_triplanar(
|
| 262 |
+
volumes: dict[str, torch.Tensor],
|
| 263 |
+
name: str,
|
| 264 |
+
config: dict,
|
| 265 |
+
*,
|
| 266 |
+
site_name: str = "",
|
| 267 |
+
step: int | None = None,
|
| 268 |
+
sample: int = 0,
|
| 269 |
+
) -> Path | None:
|
| 270 |
+
"""Write a 3x2 tri-planar figure: one row per view, one column per batch. No-op unless enabled.
|
| 271 |
+
|
| 272 |
+
Rows are sagittal / coronal / axial central cuts; columns are the entries of ``volumes`` in
|
| 273 |
+
insertion order — so ``{"input": images, "reconstruction": recon}`` puts the input beside what
|
| 274 |
+
the model made of it, in the same three planes.
|
| 275 |
+
|
| 276 |
+
This is the figure that answers questions a mid-slice grid cannot. An autoencoder can look
|
| 277 |
+
plausible on one axial slice while having learnt nothing about through-plane structure, and that
|
| 278 |
+
shows up immediately in the sagittal and coronal views.
|
| 279 |
+
|
| 280 |
+
**Each panel is scaled to its own range**, unlike :func:`save_grid`, which normalises jointly.
|
| 281 |
+
The trade-off is deliberate and goes the other way here: a joint scale is the honest one for
|
| 282 |
+
spotting intensity drift, but an early reconstruction spans a far wider range than the input
|
| 283 |
+
(noise, including negatives), so a shared scale compresses the input to flat grey and neither
|
| 284 |
+
column can be read. These figures exist to show *structure* — is there a brain, are the
|
| 285 |
+
ventricles in the right place, does the tumour survive — and structure needs per-panel contrast.
|
| 286 |
+
Read absolute intensity off the grids, not off these.
|
| 287 |
+
|
| 288 |
+
Never raises: debug output must not be able to fail a training round.
|
| 289 |
+
|
| 290 |
+
Args:
|
| 291 |
+
volumes (dict[str, torch.Tensor]): Batches to compare, ``(B, C, X, Y, Z)``, one column each.
|
| 292 |
+
name (str): Short label for what this figure shows, used in the filename.
|
| 293 |
+
config (dict): The parsed ``config.json``.
|
| 294 |
+
site_name (str): Client name, included in the filename so a shared workspace cannot collide.
|
| 295 |
+
step (int | None): Iteration or epoch counter, included in the filename to order the figures.
|
| 296 |
+
sample (int): Which item of the batch to show.
|
| 297 |
+
|
| 298 |
+
Returns:
|
| 299 |
+
Path | None: The file written, or None when disabled, when the tensors are not volumetric,
|
| 300 |
+
or on any failure.
|
| 301 |
+
"""
|
| 302 |
+
if not samples_enabled(config) or not volumes:
|
| 303 |
+
return None
|
| 304 |
+
|
| 305 |
+
try:
|
| 306 |
+
import matplotlib
|
| 307 |
+
|
| 308 |
+
# Agg before pyplot: an FL client has no display, and the default backend would try to pick
|
| 309 |
+
# an interactive one and fail at import inside a training round.
|
| 310 |
+
matplotlib.use("Agg")
|
| 311 |
+
import matplotlib.pyplot as plt
|
| 312 |
+
|
| 313 |
+
picked = {}
|
| 314 |
+
for label, batch in volumes.items():
|
| 315 |
+
if batch.ndim != 5:
|
| 316 |
+
logger.info(f"[DEBUG] {name!r}: {label} is not volumetric ({tuple(batch.shape)}); skipping figure.")
|
| 317 |
+
return None
|
| 318 |
+
picked[label] = batch.detach().float().cpu()[min(sample, batch.shape[0] - 1), 0]
|
| 319 |
+
|
| 320 |
+
figure, axes = plt.subplots(len(_PLANES), len(picked), figsize=(3.2 * len(picked), 3.2 * len(_PLANES)))
|
| 321 |
+
axes = np.atleast_2d(axes).reshape(len(_PLANES), len(picked))
|
| 322 |
+
figure.patch.set_facecolor("black")
|
| 323 |
+
|
| 324 |
+
for row, (view, axis, flip_lr) in enumerate(_PLANES):
|
| 325 |
+
for column, (label, volume) in enumerate(picked.items()):
|
| 326 |
+
axis_handle = axes[row][column]
|
| 327 |
+
# No vmin/vmax: matplotlib scales each panel to its own min/max, which is the point
|
| 328 |
+
# (see the note on contrast above). A flat panel is left to matplotlib, which
|
| 329 |
+
# renders a constant array as mid-grey rather than dividing by a zero range.
|
| 330 |
+
axis_handle.imshow(_central_slice(volume, axis, flip_lr), cmap="gray")
|
| 331 |
+
axis_handle.set_facecolor("black")
|
| 332 |
+
axis_handle.axis("off")
|
| 333 |
+
if row == 0:
|
| 334 |
+
axis_handle.set_title(label, color="0.85", fontsize=11)
|
| 335 |
+
if column == 0:
|
| 336 |
+
# A y-label survives axis("off"), which is why the view name rides here rather
|
| 337 |
+
# than in a title that would collide with the column headings.
|
| 338 |
+
axis_handle.set_ylabel(view, color="0.85", fontsize=10)
|
| 339 |
+
axis_handle.axis("on")
|
| 340 |
+
axis_handle.set_xticks([])
|
| 341 |
+
axis_handle.set_yticks([])
|
| 342 |
+
for spine in axis_handle.spines.values():
|
| 343 |
+
spine.set_visible(False)
|
| 344 |
+
|
| 345 |
+
figure.tight_layout()
|
| 346 |
+
path = debug_dir() / ("_".join(_name_parts(name, site_name, step)) + ".png")
|
| 347 |
+
figure.savefig(path, facecolor=figure.get_facecolor(), dpi=110)
|
| 348 |
+
plt.close(figure)
|
| 349 |
+
logger.info(f"[DEBUG] Wrote tri-planar figure to {path}")
|
| 350 |
+
return path
|
| 351 |
+
except Exception as err:
|
| 352 |
+
logger.warning(f"[DEBUG] Could not write tri-planar figure {name!r}: {err}")
|
| 353 |
+
return None
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
# --------------------------------------------------------------------------------------------
|
| 357 |
+
# Analysis half: loss curves from a simulator log. Host-side only — see the module docstring.
|
| 358 |
+
# --------------------------------------------------------------------------------------------
|
| 359 |
+
|
| 360 |
+
#: Log lines arrive as ``<timestamp> - <logger> - INFO - <message>``. Splitting the message off is
|
| 361 |
+
#: what stops the timestamp's own colons ("20:10:04") being read as data. A line that does *not*
|
| 362 |
+
#: match is a continuation of the message above it — the per-epoch summary is logged as one
|
| 363 |
+
#: multi-line string, so its numbers live on unprefixed lines — and is parsed whole.
|
| 364 |
+
_MESSAGE_RE = re.compile(r" - (?:DEBUG|INFO|WARNING|ERROR|CRITICAL) - (?P<message>.*)$")
|
| 365 |
+
|
| 366 |
+
#: One ``name: number`` pair inside a message. Deliberately generic: every tutorial here logs its
|
| 367 |
+
#: losses in that shape but with different names, so hard-coding one tutorial's names would leave
|
| 368 |
+
#: the other two with empty plots. The name charset excludes quotes,
|
| 369 |
+
#: commas and hyphens, which is what keeps a name from bridging across ``a: 1, b: 2`` or reaching
|
| 370 |
+
#: back into the logger prefix; ``nan``/``inf`` are accepted as values so a run that broke still
|
| 371 |
+
#: plots up to the point it broke.
|
| 372 |
+
_PAIR_RE = re.compile(r"(?P<name>[A-Za-z][\w ()]*?)\s*:\s*(?P<value>[-+]?(?:\d+\.?\d*(?:[eE][-+]?\d+)?|nan|inf))\b")
|
| 373 |
+
|
| 374 |
+
#: Fixed colours for the names this tree logs today, so two sites' figures can be read side by side
|
| 375 |
+
#: and successive runs stay comparable. Anything unrecognised is assigned from _PALETTE in sorted
|
| 376 |
+
#: order, which is deterministic for a given set of names.
|
| 377 |
+
COLOURS = {
|
| 378 |
+
"l1": "#4ea1ff",
|
| 379 |
+
"perceptual": "#ffd166",
|
| 380 |
+
"gan": "#ff6b6b",
|
| 381 |
+
"KL": "#9b7bff",
|
| 382 |
+
"Total loss D": "#7bffb0",
|
| 383 |
+
"Total loss DM": "#4ea1ff",
|
| 384 |
+
"Validation loss (L1)": "#4ea1ff",
|
| 385 |
+
"Validation SSIM (foreground)": "#ff9f4e",
|
| 386 |
+
"Validation DM": "#ff9f4e",
|
| 387 |
+
# The per-epoch aggregates, coloured to match the per-iteration term each one totals.
|
| 388 |
+
"L1": "#4ea1ff",
|
| 389 |
+
"Perceptual": "#ffd166",
|
| 390 |
+
"GAN": "#ff6b6b",
|
| 391 |
+
"KLD": "#9b7bff",
|
| 392 |
+
"Total loss G": "#eeeeee",
|
| 393 |
+
}
|
| 394 |
+
_PALETTE = ("#4ea1ff", "#ffd166", "#ff6b6b", "#9b7bff", "#7bffb0", "#ff9f4e", "#59d4d4", "#d98cc4")
|
| 395 |
+
|
| 396 |
+
#: Smoothing for the per-iteration panel, which is unreadable raw at BATCH_SIZE 2.
|
| 397 |
+
EMA_ALPHA = 0.02
|
| 398 |
+
|
| 399 |
+
#: A series needs at least this many points to be worth an axis; it also filters out the stray
|
| 400 |
+
#: ``name: number`` pairs that appear once in a startup banner.
|
| 401 |
+
MIN_POINTS = 2
|
| 402 |
+
|
| 403 |
+
#: Series logged at the same cadence share a length. Anything within this fraction of the longest
|
| 404 |
+
#: series is treated as per-iteration; the rest is per-epoch. A tutorial that logs only per-epoch
|
| 405 |
+
#: (the two diffusion ones) therefore yields a single panel rather than an empty one.
|
| 406 |
+
#:
|
| 407 |
+
#: The same ratio is then applied *within* the per-epoch band, which is what keeps incidental
|
| 408 |
+
#: ``name: number`` pairs out of the figure. A log is full of them — ``localhost: 34953``,
|
| 409 |
+
#: ``PID: 1005143``, ``train size: 30`` — and they are not distinguishable from a metric by name or
|
| 410 |
+
#: by value. They are distinguishable by cadence: a metric is logged once per iteration or once per
|
| 411 |
+
#: epoch, so it appears as many times as there were iterations or epochs, while a startup line
|
| 412 |
+
#: appears once or twice however long the run goes on.
|
| 413 |
+
DENSE_RATIO = 0.9
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def _ema(values: list[float], alpha: float = EMA_ALPHA) -> list[float]:
|
| 417 |
+
"""Exponential moving average, skipping non-finite points rather than propagating them."""
|
| 418 |
+
out: list[float] = []
|
| 419 |
+
acc = next((v for v in values if v == v and abs(v) != float("inf")), 0.0)
|
| 420 |
+
for value in values:
|
| 421 |
+
if value == value and abs(value) != float("inf"):
|
| 422 |
+
acc = alpha * value + (1 - alpha) * acc
|
| 423 |
+
out.append(acc)
|
| 424 |
+
return out
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def parse_log(path: Path) -> dict[str, list[float]]:
|
| 428 |
+
"""Return every ``name: number`` series found in one site's ``log.txt``, in order of appearance.
|
| 429 |
+
|
| 430 |
+
Args:
|
| 431 |
+
path (Path): The site's ``log.txt``.
|
| 432 |
+
|
| 433 |
+
Returns:
|
| 434 |
+
dict[str, list[float]]: Series name to its values, longest series first.
|
| 435 |
+
"""
|
| 436 |
+
series: dict[str, list[float]] = {}
|
| 437 |
+
with path.open(errors="replace") as handle:
|
| 438 |
+
for line in handle:
|
| 439 |
+
prefixed = _MESSAGE_RE.search(line)
|
| 440 |
+
message = prefixed.group("message") if prefixed else line
|
| 441 |
+
for match in _PAIR_RE.finditer(message):
|
| 442 |
+
series.setdefault(match.group("name"), []).append(float(match.group("value")))
|
| 443 |
+
return dict(sorted(series.items(), key=lambda item: -len(item[1])))
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def split_by_cadence(series: dict[str, list[float]]) -> tuple[dict[str, list[float]], dict[str, list[float]]]:
|
| 447 |
+
"""Split series into per-iteration and per-epoch groups by how often each was logged.
|
| 448 |
+
|
| 449 |
+
Args:
|
| 450 |
+
series (dict[str, list[float]]): Output of :func:`parse_log`.
|
| 451 |
+
|
| 452 |
+
Returns:
|
| 453 |
+
tuple: ``(dense, sparse)``; ``sparse`` is empty when everything shares one cadence.
|
| 454 |
+
"""
|
| 455 |
+
usable = {name: values for name, values in series.items() if len(values) >= MIN_POINTS}
|
| 456 |
+
if not usable:
|
| 457 |
+
return {}, {}
|
| 458 |
+
longest = max(len(values) for values in usable.values())
|
| 459 |
+
dense = {n: v for n, v in usable.items() if len(v) >= longest * DENSE_RATIO}
|
| 460 |
+
|
| 461 |
+
remainder = {n: v for n, v in usable.items() if len(v) < longest * DENSE_RATIO}
|
| 462 |
+
if not remainder:
|
| 463 |
+
return dense, {}
|
| 464 |
+
epoch_cadence = max(len(values) for values in remainder.values())
|
| 465 |
+
sparse = {n: v for n, v in remainder.items() if len(v) >= epoch_cadence * DENSE_RATIO}
|
| 466 |
+
return dense, sparse
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def _colour_for(name: str, index: int) -> str:
|
| 470 |
+
return COLOURS.get(name, _PALETTE[index % len(_PALETTE)])
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def _style(axis, title: str) -> None:
|
| 474 |
+
"""Apply the shared dark styling to one axis."""
|
| 475 |
+
axis.set_facecolor("#111111")
|
| 476 |
+
axis.set_title(title, color="#eeeeee")
|
| 477 |
+
axis.tick_params(colors="#bbbbbb")
|
| 478 |
+
axis.grid(True, color="#333333", linewidth=0.5)
|
| 479 |
+
for spine in axis.spines.values():
|
| 480 |
+
spine.set_color("#444444")
|
| 481 |
+
axis.xaxis.label.set_color("#bbbbbb")
|
| 482 |
+
axis.yaxis.label.set_color("#bbbbbb")
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
def _legend(axis, handles=None, labels=None) -> None:
|
| 486 |
+
"""Legend outside the axes on the right, so it cannot sit on top of the curves.
|
| 487 |
+
|
| 488 |
+
The generic parser finds however many series a tutorial logs — eight for the autoencoder's
|
| 489 |
+
per-epoch panel — which is more than fits inside the plot area.
|
| 490 |
+
"""
|
| 491 |
+
axis.legend(
|
| 492 |
+
*([handles, labels] if handles is not None else []),
|
| 493 |
+
loc="upper left",
|
| 494 |
+
bbox_to_anchor=(1.12, 1.0),
|
| 495 |
+
fontsize=8,
|
| 496 |
+
facecolor="#1c1c1c",
|
| 497 |
+
edgecolor="#444444",
|
| 498 |
+
labelcolor="#eeeeee",
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
def plot_site(site: str, series: dict[str, list[float]], out_dir: Path) -> Path:
|
| 503 |
+
"""Write the loss figure for one site and return its path.
|
| 504 |
+
|
| 505 |
+
The per-iteration panel is log-scaled when every value is positive: the terms routinely span two
|
| 506 |
+
decades, and on a linear axis the smallest of them — usually the one that is misweighted, which
|
| 507 |
+
is exactly what you are looking for — is a flat line on the floor.
|
| 508 |
+
|
| 509 |
+
Args:
|
| 510 |
+
site (str): Site name, used in the title and filename.
|
| 511 |
+
series (dict[str, list[float]]): Output of :func:`parse_log`.
|
| 512 |
+
out_dir (Path): Directory to write into; created if absent.
|
| 513 |
+
|
| 514 |
+
Returns:
|
| 515 |
+
Path: The figure written.
|
| 516 |
+
"""
|
| 517 |
+
import matplotlib
|
| 518 |
+
|
| 519 |
+
matplotlib.use("Agg")
|
| 520 |
+
import matplotlib.pyplot as plt
|
| 521 |
+
|
| 522 |
+
dense, sparse = split_by_cadence(series)
|
| 523 |
+
panels = [p for p in (dense, sparse) if p]
|
| 524 |
+
figure, axes = plt.subplots(len(panels), 1, figsize=(13, 4 * len(panels)), facecolor="#111111", squeeze=False)
|
| 525 |
+
|
| 526 |
+
for axis, panel in zip(axes.flatten(), panels, strict=True):
|
| 527 |
+
is_dense = panel is dense
|
| 528 |
+
# SSIM is bounded [0, 1] where the losses are not, so it gets its own axis or it flattens
|
| 529 |
+
# every other series against the top of the plot.
|
| 530 |
+
twin_names = [n for n in panel if "SSIM" in n] if not is_dense else []
|
| 531 |
+
twin = axis.twinx() if twin_names else None
|
| 532 |
+
|
| 533 |
+
for index, (name, values) in enumerate(panel.items()):
|
| 534 |
+
colour = _colour_for(name, index)
|
| 535 |
+
target = twin if name in twin_names else axis
|
| 536 |
+
if is_dense:
|
| 537 |
+
target.plot(values, color=colour, alpha=0.15, linewidth=0.6)
|
| 538 |
+
target.plot(_ema(values), color=colour, linewidth=1.8, label=name)
|
| 539 |
+
else:
|
| 540 |
+
steps = range(1, len(values) + 1)
|
| 541 |
+
target.plot(steps, values, color=colour, marker="o", markersize=3, label=name)
|
| 542 |
+
|
| 543 |
+
# Log y whenever everything is positive. The terms routinely span two or three decades, and
|
| 544 |
+
# on a linear axis the smallest of them — usually the misweighted one, which is exactly what
|
| 545 |
+
# you are looking for — is a flat line on the floor. On the per-epoch panel the first epoch
|
| 546 |
+
# is several times any later one, so a linear axis spends most of its height on it.
|
| 547 |
+
plotted = [values for name, values in panel.items() if name not in twin_names]
|
| 548 |
+
finite = [v for values in plotted for v in values if v == v and abs(v) != float("inf")]
|
| 549 |
+
if finite and min(finite) > 0:
|
| 550 |
+
axis.set_yscale("log")
|
| 551 |
+
axis.set_xlabel("training iteration" if is_dense else "local epoch")
|
| 552 |
+
axis.set_ylabel("weighted contribution to $\\mathcal{L}_G$" if is_dense else "loss")
|
| 553 |
+
_style(axis, f"{site} — {'per-iteration loss terms' if is_dense else 'per-epoch summary'}")
|
| 554 |
+
|
| 555 |
+
if twin is not None:
|
| 556 |
+
twin.set_ylabel(" / ".join(twin_names), color=COLOURS.get(twin_names[0], _PALETTE[5]))
|
| 557 |
+
twin.tick_params(axis="y", colors=COLOURS.get(twin_names[0], _PALETTE[5]))
|
| 558 |
+
for spine in twin.spines.values():
|
| 559 |
+
spine.set_color("#444444")
|
| 560 |
+
handles, labels = axis.get_legend_handles_labels()
|
| 561 |
+
extra_handles, extra_labels = twin.get_legend_handles_labels()
|
| 562 |
+
_legend(axis, handles + extra_handles, labels + extra_labels)
|
| 563 |
+
else:
|
| 564 |
+
_legend(axis)
|
| 565 |
+
|
| 566 |
+
figure.tight_layout()
|
| 567 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 568 |
+
destination = out_dir / f"losses_{site}.png"
|
| 569 |
+
figure.savefig(destination, dpi=110, facecolor=figure.get_facecolor())
|
| 570 |
+
plt.close(figure)
|
| 571 |
+
return destination
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
def main() -> None:
|
| 575 |
+
"""Plot every site's losses from a simulator workspace. Host-side entry point."""
|
| 576 |
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 577 |
+
parser.add_argument(
|
| 578 |
+
"--workspace",
|
| 579 |
+
type=Path,
|
| 580 |
+
default=Path("/tmp/nvflare") / Path.cwd().name / "flip_fedavg",
|
| 581 |
+
help="simulator workspace root (default: this tutorial's, from the current directory)",
|
| 582 |
+
)
|
| 583 |
+
parser.add_argument("--out-dir", type=Path, default=None, help="defaults to $DEBUG_SAMPLES_DIR, else ./")
|
| 584 |
+
args = parser.parse_args()
|
| 585 |
+
|
| 586 |
+
out_dir = args.out_dir or Path(os.environ.get(DEBUG_DIR_ENV, "."))
|
| 587 |
+
|
| 588 |
+
logs = sorted(args.workspace.glob("site-*/log.txt"))
|
| 589 |
+
if not logs:
|
| 590 |
+
raise SystemExit(f"no site logs under {args.workspace} — pass --workspace")
|
| 591 |
+
|
| 592 |
+
for log in logs:
|
| 593 |
+
site = log.parent.name
|
| 594 |
+
series = parse_log(log)
|
| 595 |
+
dense, sparse = split_by_cadence(series)
|
| 596 |
+
if not dense and not sparse:
|
| 597 |
+
print(f"{site}: no scalar lines yet, skipping")
|
| 598 |
+
continue
|
| 599 |
+
destination = plot_site(site, series, out_dir)
|
| 600 |
+
counts = ", ".join(f"{name} x{len(values)}" for name, values in list(dense.items())[:4])
|
| 601 |
+
print(f"{site}: {counts} -> {destination}")
|
| 602 |
+
|
| 603 |
+
|
| 604 |
+
if __name__ == "__main__":
|
| 605 |
+
main()
|
code/trainer.py
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London
|
| 2 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 3 |
+
# you may not use this file except in compliance with the License.
|
| 4 |
+
# You may obtain a copy of the License at
|
| 5 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
| 6 |
+
# Unless required by applicable law or agreed to in writing, software
|
| 7 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 8 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 9 |
+
# See the License for the specific language governing permissions and
|
| 10 |
+
# limitations under the License.
|
| 11 |
+
|
| 12 |
+
"""FLARE Client API script for the FLIP pixel-space diffusion tutorial.
|
| 13 |
+
|
| 14 |
+
Trains a denoising diffusion model (MONAI's ``DiffusionModelUNet``) to generate 2-D chest X-rays, as
|
| 15 |
+
an ordinary FedAvg job. On a ``train`` task the client trains the received model for ``LOCAL_ROUNDS``
|
| 16 |
+
epochs and sends back the weight difference; on an ``evaluate`` task it scores the aggregated model
|
| 17 |
+
on its held-out split.
|
| 18 |
+
|
| 19 |
+
Training noises each image at a random timestep and teaches the UNet to predict that noise (MSE).
|
| 20 |
+
Every ``VALIDATE_EVERY`` epochs the same loss is scored on the held-out split and, in local runs,
|
| 21 |
+
sample images are drawn. Training runs in full precision.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
import json
|
| 26 |
+
import logging
|
| 27 |
+
import math
|
| 28 |
+
import os
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
import nvflare.client as flare
|
| 33 |
+
import pandas as pd
|
| 34 |
+
import pydicom
|
| 35 |
+
import torch
|
| 36 |
+
from flip import FLIP
|
| 37 |
+
from flip.constants import ResourceType
|
| 38 |
+
from models import get_model
|
| 39 |
+
from monai.data import DataLoader, Dataset
|
| 40 |
+
from monai.inferers import DiffusionInferer
|
| 41 |
+
from monai.networks.schedulers import DDPMScheduler
|
| 42 |
+
from nvflare.client.tracking import SummaryWriter
|
| 43 |
+
from transforms import get_xray_transforms
|
| 44 |
+
|
| 45 |
+
# plot_utils.py is a local-development helper (../dev/): job.py stages it into simulator runs, and it is
|
| 46 |
+
# never uploaded, so on a trust this import fails and debug output is simply off.
|
| 47 |
+
try:
|
| 48 |
+
from plot_utils import samples_enabled, save_grid
|
| 49 |
+
except ModuleNotFoundError as err:
|
| 50 |
+
if err.name != "plot_utils":
|
| 51 |
+
raise
|
| 52 |
+
|
| 53 |
+
def samples_enabled(config: dict) -> bool:
|
| 54 |
+
return False
|
| 55 |
+
|
| 56 |
+
def save_grid(*args: object, **kwargs: object) -> None:
|
| 57 |
+
return None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
logger = logging.getLogger(__name__)
|
| 61 |
+
|
| 62 |
+
#: Epochs between in-loop validation and sampling when ``VALIDATE_EVERY`` is absent from the config.
|
| 63 |
+
DEFAULT_VALIDATE_EVERY = 10
|
| 64 |
+
|
| 65 |
+
#: Images drawn per sample grid.
|
| 66 |
+
NUM_DEBUG_SAMPLES = 4
|
| 67 |
+
|
| 68 |
+
#: Max gradient norm when ``GRAD_CLIP_NORM`` is absent from the config.
|
| 69 |
+
DEFAULT_GRAD_CLIP_NORM = 1.0
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def parse_args() -> argparse.Namespace:
|
| 73 |
+
parser = argparse.ArgumentParser()
|
| 74 |
+
parser.add_argument("--project_id", type=str, default="")
|
| 75 |
+
return parser.parse_args()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def load_query() -> str:
|
| 79 |
+
"""Read the cohort query from the client app config.
|
| 80 |
+
|
| 81 |
+
FLARE's TaskScriptRunner does a naive whitespace split on task_script_args, so the SQL query
|
| 82 |
+
(which can contain spaces) is plumbed via the top-level ``query`` key in
|
| 83 |
+
``config/config_fed_client.json`` rather than as a CLI flag. In dev/simulator mode this is
|
| 84 |
+
ignored by ``flip.get_dataframe``.
|
| 85 |
+
"""
|
| 86 |
+
client_cfg = Path(__file__).parent.parent / "config" / "config_fed_client.json"
|
| 87 |
+
if client_cfg.exists():
|
| 88 |
+
try:
|
| 89 |
+
return json.loads(client_cfg.read_text()).get("query", "")
|
| 90 |
+
except Exception:
|
| 91 |
+
return ""
|
| 92 |
+
return ""
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def load_config() -> dict:
|
| 96 |
+
"""Load the user-supplied config.json that sits next to this script."""
|
| 97 |
+
config_path = Path(__file__).parent.resolve() / "config.json"
|
| 98 |
+
with open(config_path) as f:
|
| 99 |
+
return json.load(f)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def validate_every(config: dict) -> int:
|
| 103 |
+
"""Epochs between in-loop validation and sampling, from ``VALIDATE_EVERY``; zero or less turns it off.
|
| 104 |
+
|
| 105 |
+
The default is above 1 because the pass also samples, which runs the full reverse diffusion.
|
| 106 |
+
"""
|
| 107 |
+
return int(config.get("VALIDATE_EVERY", DEFAULT_VALIDATE_EVERY))
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def due_for_validation(epoch: int, epochs: int, config: dict) -> bool:
|
| 111 |
+
"""Whether this 0-based epoch validates: every ``VALIDATE_EVERY`` epochs, and always the last."""
|
| 112 |
+
interval = validate_every(config)
|
| 113 |
+
if interval <= 0:
|
| 114 |
+
return False
|
| 115 |
+
return (epoch + 1) % interval == 0 or epoch == epochs - 1
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def grad_clip_norm(config: dict) -> float:
|
| 119 |
+
"""Max global gradient norm, from ``GRAD_CLIP_NORM``; zero or less turns clipping off."""
|
| 120 |
+
return float(config.get("GRAD_CLIP_NORM", DEFAULT_GRAD_CLIP_NORM))
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def lr_at(epoch: int, config: dict) -> float:
|
| 124 |
+
"""The learning rate at an epoch of the job: half a cosine from ``LR_START`` to ``LR_END``, then held.
|
| 125 |
+
|
| 126 |
+
The decay spans the job's first ``LR_DECAY_EPOCHS`` epochs, so ``epoch`` counts across rounds
|
| 127 |
+
(``global_round * LOCAL_ROUNDS + epoch``). Without ``LR_END`` or a positive ``LR_DECAY_EPOCHS`` the
|
| 128 |
+
rate stays at ``LR_START``.
|
| 129 |
+
"""
|
| 130 |
+
start = float(config["LR_START"])
|
| 131 |
+
end = float(config.get("LR_END", start))
|
| 132 |
+
decay_epochs = int(config.get("LR_DECAY_EPOCHS", 0) or 0)
|
| 133 |
+
if decay_epochs <= 0:
|
| 134 |
+
return start
|
| 135 |
+
progress = min(epoch, decay_epochs) / decay_epochs
|
| 136 |
+
return end + (start - end) * 0.5 * (1.0 + math.cos(math.pi * progress))
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def set_learning_rate(optimizer: torch.optim.Optimizer, lr: float) -> None:
|
| 140 |
+
for group in optimizer.param_groups:
|
| 141 |
+
group["lr"] = lr
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def site_data_paths(site_name: str) -> tuple[Path, Path] | None:
|
| 145 |
+
"""This simulated site's own ``(images_dir, dataframe)``, from ``SITE{N}_IMAGES_DIR`` / ``SITE{N}_DATAFRAME``.
|
| 146 |
+
|
| 147 |
+
Set only by the tutorial Makefile for a local run on per-site data ("site-1" reads ``SITE1_*``).
|
| 148 |
+
``None`` when either is unset or blank: the site then reads the shared ``DEV_*`` dataset through
|
| 149 |
+
``FLIP``, as it does on a trust.
|
| 150 |
+
"""
|
| 151 |
+
prefix = site_name.replace("-", "").upper()
|
| 152 |
+
images_dir = os.environ.get(f"{prefix}_IMAGES_DIR", "").strip()
|
| 153 |
+
dataframe = os.environ.get(f"{prefix}_DATAFRAME", "").strip()
|
| 154 |
+
if not images_dir or not dataframe:
|
| 155 |
+
return None
|
| 156 |
+
return Path(images_dir), Path(dataframe)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def split_for_site(items: list, site_name: str) -> list:
|
| 160 |
+
"""In the simulator, give "site-1" and "site-2" half the shared dataset each; on a trust, all of it."""
|
| 161 |
+
half = len(items) // 2
|
| 162 |
+
site = site_name.replace("-", "")
|
| 163 |
+
if site == "site1":
|
| 164 |
+
return items[:half]
|
| 165 |
+
if site == "site2":
|
| 166 |
+
return items[half:]
|
| 167 |
+
return items
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def noise_prediction_loss(
|
| 171 |
+
model: torch.nn.Module, inferer: DiffusionInferer, scheduler: DDPMScheduler, images: torch.Tensor
|
| 172 |
+
) -> torch.Tensor:
|
| 173 |
+
"""The diffusion objective: noise each image at a random timestep and score the predicted noise."""
|
| 174 |
+
noise = torch.randn_like(images)
|
| 175 |
+
timesteps = torch.randint(0, scheduler.num_train_timesteps, (images.shape[0],), device=images.device)
|
| 176 |
+
noise_pred = inferer(inputs=images, diffusion_model=model.diffusion_model, noise=noise, timesteps=timesteps)
|
| 177 |
+
return torch.nn.functional.mse_loss(noise_pred.float(), noise.float())
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def score_split(
|
| 181 |
+
model: torch.nn.Module,
|
| 182 |
+
loader: DataLoader,
|
| 183 |
+
inferer: DiffusionInferer,
|
| 184 |
+
scheduler: DDPMScheduler,
|
| 185 |
+
device: torch.device,
|
| 186 |
+
) -> float:
|
| 187 |
+
"""Mean noise-prediction loss over a split, in eval mode; restores the caller's mode afterwards."""
|
| 188 |
+
was_training = model.diffusion_model.training
|
| 189 |
+
model.diffusion_model.eval()
|
| 190 |
+
losses = []
|
| 191 |
+
try:
|
| 192 |
+
with torch.no_grad():
|
| 193 |
+
for batch in loader:
|
| 194 |
+
losses.append(noise_prediction_loss(model, inferer, scheduler, batch["image"].to(device)).item())
|
| 195 |
+
finally:
|
| 196 |
+
model.diffusion_model.train(was_training)
|
| 197 |
+
return float(np.mean(losses)) if losses else float("nan")
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def sample_and_save(
|
| 201 |
+
model: torch.nn.Module,
|
| 202 |
+
inferer: DiffusionInferer,
|
| 203 |
+
scheduler: DDPMScheduler,
|
| 204 |
+
config: dict,
|
| 205 |
+
device: torch.device,
|
| 206 |
+
*,
|
| 207 |
+
step: int | None = None,
|
| 208 |
+
) -> None:
|
| 209 |
+
"""Generate ``NUM_DEBUG_SAMPLES`` images and save them as one grid, in local runs only.
|
| 210 |
+
|
| 211 |
+
Samples are the honest read on a diffusion model: the loss barely moves between a model that
|
| 212 |
+
generates radiographs and one that generates texture.
|
| 213 |
+
"""
|
| 214 |
+
if not samples_enabled(config):
|
| 215 |
+
return
|
| 216 |
+
shape = [NUM_DEBUG_SAMPLES, config["net_config"]["diffusion_model"]["in_channels"], *config["spatial_shape"]]
|
| 217 |
+
was_training = model.diffusion_model.training
|
| 218 |
+
model.diffusion_model.eval()
|
| 219 |
+
try:
|
| 220 |
+
with torch.no_grad():
|
| 221 |
+
samples = inferer.sample(
|
| 222 |
+
input_noise=torch.randn(shape, device=device),
|
| 223 |
+
diffusion_model=model.diffusion_model,
|
| 224 |
+
scheduler=scheduler,
|
| 225 |
+
save_intermediates=False,
|
| 226 |
+
# No progress bar: simulated sites share one terminal and would overwrite each other's.
|
| 227 |
+
verbose=False,
|
| 228 |
+
)
|
| 229 |
+
finally:
|
| 230 |
+
model.diffusion_model.train(was_training)
|
| 231 |
+
save_grid({"sample": samples}, "samples", config, site_name=flare.get_site_name(), step=step)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
class DiffusionTrainer:
|
| 235 |
+
"""The model, optimiser, schedules and data for pixel-space diffusion training.
|
| 236 |
+
|
| 237 |
+
One instance lives for the whole ``flare.is_running()`` loop, so the optimiser state carries over
|
| 238 |
+
from round to round.
|
| 239 |
+
"""
|
| 240 |
+
|
| 241 |
+
def __init__(self, config: dict, project_id: str, query: str):
|
| 242 |
+
self.config = config
|
| 243 |
+
self.project_id = project_id
|
| 244 |
+
self.epochs = config["LOCAL_ROUNDS"]
|
| 245 |
+
|
| 246 |
+
self.model = get_model()
|
| 247 |
+
self.device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
|
| 248 |
+
self.optimizer = torch.optim.Adam(
|
| 249 |
+
self.model.diffusion_model.parameters(), lr=config["LR_START"], weight_decay=1e-6, amsgrad=True
|
| 250 |
+
)
|
| 251 |
+
self.noise_scheduler = DDPMScheduler(
|
| 252 |
+
num_train_timesteps=1000,
|
| 253 |
+
schedule="scaled_linear_beta",
|
| 254 |
+
clip_sample=False,
|
| 255 |
+
prediction_type="epsilon",
|
| 256 |
+
beta_start=0.0015,
|
| 257 |
+
beta_end=0.0195,
|
| 258 |
+
)
|
| 259 |
+
self.inferer = DiffusionInferer(scheduler=self.noise_scheduler)
|
| 260 |
+
|
| 261 |
+
# Data
|
| 262 |
+
self.flip = FLIP()
|
| 263 |
+
site_name = flare.get_site_name()
|
| 264 |
+
site_paths = site_data_paths(site_name)
|
| 265 |
+
if site_paths is not None:
|
| 266 |
+
# Local run on per-site data: this site's own folder and CSV, used whole.
|
| 267 |
+
self.images_dir, dataframe_path = site_paths
|
| 268 |
+
dataframe = pd.read_csv(dataframe_path, sep=None, engine="python")
|
| 269 |
+
logger.info(f"Reading {dataframe_path} and {self.images_dir}")
|
| 270 |
+
else:
|
| 271 |
+
self.images_dir = None
|
| 272 |
+
dataframe = self.flip.get_dataframe(project_id=project_id, query=query)
|
| 273 |
+
train_items, val_items = self.build_datalist(dataframe)
|
| 274 |
+
if site_paths is None:
|
| 275 |
+
train_items = split_for_site(train_items, site_name)
|
| 276 |
+
|
| 277 |
+
workers = int(config.get("DATALOADER_WORKERS", 4)) # as in Ark+ (FLIP#1434)
|
| 278 |
+
self.train_loader = DataLoader(
|
| 279 |
+
Dataset(train_items, transform=get_xray_transforms()),
|
| 280 |
+
batch_size=config["BATCH_SIZE"],
|
| 281 |
+
shuffle=True,
|
| 282 |
+
num_workers=workers,
|
| 283 |
+
)
|
| 284 |
+
self.val_loader = DataLoader(
|
| 285 |
+
Dataset(val_items, transform=get_xray_transforms(is_validation=True)),
|
| 286 |
+
batch_size=config["BATCH_SIZE"],
|
| 287 |
+
shuffle=False,
|
| 288 |
+
num_workers=workers,
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
def build_datalist(self, dataframe: pd.DataFrame) -> tuple[list, list]:
|
| 292 |
+
"""List each accession's readable DICOM images and split them into train and validation.
|
| 293 |
+
|
| 294 |
+
As ``xray_classification``'s ``build_datalist``, without the labels. Each header is parsed up
|
| 295 |
+
front, so an unreadable file is dropped here rather than failing a training step.
|
| 296 |
+
"""
|
| 297 |
+
datalist: list[dict[str, str]] = []
|
| 298 |
+
for accession_id in dataframe["accession_id"]:
|
| 299 |
+
if self.images_dir is not None:
|
| 300 |
+
accession_folder_path = self.images_dir / str(accession_id)
|
| 301 |
+
else:
|
| 302 |
+
try:
|
| 303 |
+
accession_folder_path = self.flip.get_by_accession_number(
|
| 304 |
+
self.project_id, accession_id, resource_type=[ResourceType.DICOM]
|
| 305 |
+
)
|
| 306 |
+
except Exception as err:
|
| 307 |
+
logger.info(f"Could not get image data folder path for {accession_id}: {err}")
|
| 308 |
+
continue
|
| 309 |
+
|
| 310 |
+
for image in sorted(accession_folder_path.rglob("*.dcm")):
|
| 311 |
+
try:
|
| 312 |
+
pydicom.dcmread(str(image), stop_before_pixels=True)
|
| 313 |
+
except Exception as err:
|
| 314 |
+
logger.warning(f"Skipping invalid DICOM {image.name}: {err}")
|
| 315 |
+
continue
|
| 316 |
+
datalist.append({"image": str(image)})
|
| 317 |
+
|
| 318 |
+
logger.info(f"Found {len(datalist)} files in total.")
|
| 319 |
+
val_size = int(self.config["VAL_SPLIT"] * len(datalist))
|
| 320 |
+
return datalist[val_size:], datalist[:val_size]
|
| 321 |
+
|
| 322 |
+
def load_weights(self, weights: dict[str, torch.Tensor]) -> None:
|
| 323 |
+
self.model.load_state_dict(state_dict=weights)
|
| 324 |
+
self.model.diffusion_model.to(self.device)
|
| 325 |
+
|
| 326 |
+
def train_one_epoch(self) -> float:
|
| 327 |
+
"""One pass over the training split; returns the mean loss."""
|
| 328 |
+
self.model.diffusion_model.train()
|
| 329 |
+
max_norm = grad_clip_norm(self.config)
|
| 330 |
+
total = 0.0
|
| 331 |
+
for batch in self.train_loader:
|
| 332 |
+
self.optimizer.zero_grad(set_to_none=True)
|
| 333 |
+
loss = noise_prediction_loss(self.model, self.inferer, self.noise_scheduler, batch["image"].to(self.device))
|
| 334 |
+
if not torch.isfinite(loss):
|
| 335 |
+
raise RuntimeError("Non-finite loss during training; stopping this site.")
|
| 336 |
+
loss.backward()
|
| 337 |
+
if max_norm > 0:
|
| 338 |
+
torch.nn.utils.clip_grad_norm_(self.model.diffusion_model.parameters(), max_norm)
|
| 339 |
+
self.optimizer.step()
|
| 340 |
+
total += loss.item()
|
| 341 |
+
return total / max(1, len(self.train_loader))
|
| 342 |
+
|
| 343 |
+
def train(self, writer: SummaryWriter, global_round: int) -> int:
|
| 344 |
+
"""One ``train`` task: ``LOCAL_ROUNDS`` epochs. Returns the number of optimiser steps."""
|
| 345 |
+
for epoch in range(self.epochs):
|
| 346 |
+
step = global_round * self.epochs + epoch + 1
|
| 347 |
+
lr = lr_at(step - 1, self.config)
|
| 348 |
+
set_learning_rate(self.optimizer, lr)
|
| 349 |
+
epoch_loss = self.train_one_epoch()
|
| 350 |
+
logger.info(f"Epoch {epoch + 1} / {self.epochs}: loss {epoch_loss}")
|
| 351 |
+
writer.add_scalar("Total loss DM@epoch", epoch_loss, global_step=step)
|
| 352 |
+
writer.add_scalar("LR DM@epoch", lr, global_step=step)
|
| 353 |
+
|
| 354 |
+
if due_for_validation(epoch, self.epochs, self.config):
|
| 355 |
+
val_loss = score_split(self.model, self.val_loader, self.inferer, self.noise_scheduler, self.device)
|
| 356 |
+
logger.info(f"Validation loss: {val_loss}")
|
| 357 |
+
writer.add_scalar("Validation loss DM@epoch", val_loss, global_step=step)
|
| 358 |
+
sample_and_save(self.model, self.inferer, self.noise_scheduler, self.config, self.device, step=step)
|
| 359 |
+
|
| 360 |
+
return self.epochs * len(self.train_loader)
|
| 361 |
+
|
| 362 |
+
def validate(self, writer: SummaryWriter) -> float:
|
| 363 |
+
"""One ``evaluate`` task: score the aggregated model on this site's held-out split, and sample."""
|
| 364 |
+
val_loss = score_split(self.model, self.val_loader, self.inferer, self.noise_scheduler, self.device)
|
| 365 |
+
sample_and_save(self.model, self.inferer, self.noise_scheduler, self.config, self.device)
|
| 366 |
+
writer.add_scalar("Total loss DM (val)", val_loss, global_step=0)
|
| 367 |
+
return val_loss
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def to_torch_weights(input_model: flare.FLModel) -> dict[str, torch.Tensor]:
|
| 371 |
+
return {k: torch.as_tensor(v) for k, v in input_model.params.items()}
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def send_weight_diff(original_params: dict, model: torch.nn.Module, n_iterations: int) -> None:
|
| 375 |
+
"""Send the full-model weight diff for a completed training round.
|
| 376 |
+
|
| 377 |
+
Built one tensor at a time to avoid holding a second full copy of the model in RAM (mirrors
|
| 378 |
+
``flip.utils.get_model_weights_diff``).
|
| 379 |
+
"""
|
| 380 |
+
diff = {}
|
| 381 |
+
for k, v in model.state_dict().items():
|
| 382 |
+
new_arr = v.detach().cpu().numpy()
|
| 383 |
+
diff[k] = new_arr - np.asarray(original_params[k])
|
| 384 |
+
del new_arr
|
| 385 |
+
|
| 386 |
+
flare.send(
|
| 387 |
+
flare.FLModel(
|
| 388 |
+
params=diff,
|
| 389 |
+
params_type="DIFF",
|
| 390 |
+
meta={"NUM_STEPS_CURRENT_ROUND": n_iterations},
|
| 391 |
+
)
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def main() -> None:
|
| 396 |
+
logging.basicConfig(level=logging.INFO)
|
| 397 |
+
args = parse_args()
|
| 398 |
+
config = load_config()
|
| 399 |
+
|
| 400 |
+
flare.init()
|
| 401 |
+
writer = SummaryWriter()
|
| 402 |
+
|
| 403 |
+
trainer = DiffusionTrainer(config, project_id=args.project_id, query=load_query())
|
| 404 |
+
|
| 405 |
+
while flare.is_running():
|
| 406 |
+
input_model = flare.receive()
|
| 407 |
+
if input_model is None:
|
| 408 |
+
break
|
| 409 |
+
|
| 410 |
+
global_round = input_model.current_round or 0
|
| 411 |
+
|
| 412 |
+
if flare.is_train():
|
| 413 |
+
logger.info(f"[diffusion trainer] received train task (round {global_round})")
|
| 414 |
+
trainer.load_weights(to_torch_weights(input_model))
|
| 415 |
+
n_iterations = trainer.train(writer, global_round)
|
| 416 |
+
send_weight_diff(input_model.params, trainer.model, n_iterations)
|
| 417 |
+
|
| 418 |
+
elif flare.is_evaluate():
|
| 419 |
+
trainer.load_weights(to_torch_weights(input_model))
|
| 420 |
+
val_loss = trainer.validate(writer)
|
| 421 |
+
logger.info(f"Validating the aggregated diffusion model on {flare.get_site_name()}'s data: {val_loss}")
|
| 422 |
+
flare.send(flare.FLModel(metrics={"val_loss": val_loss}))
|
| 423 |
+
|
| 424 |
+
else:
|
| 425 |
+
logger.warning("Received unknown task; ignoring.")
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
if __name__ == "__main__":
|
| 429 |
+
main()
|
code/transforms.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London
|
| 2 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 3 |
+
# you may not use this file except in compliance with the License.
|
| 4 |
+
# You may obtain a copy of the License at
|
| 5 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
| 6 |
+
# Unless required by applicable law or agreed to in writing, software
|
| 7 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 8 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 9 |
+
# See the License for the specific language governing permissions and
|
| 10 |
+
# limitations under the License.
|
| 11 |
+
|
| 12 |
+
"""MONAI transforms for chest-X-ray inputs."""
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import monai.transforms as mt
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def load_spatial_shape() -> list[int]:
|
| 21 |
+
"""Read ``spatial_shape`` from the ``config.json`` next to this file."""
|
| 22 |
+
with open(Path(__file__).parent / "config.json", "r") as f:
|
| 23 |
+
return list(json.load(f)["spatial_shape"])
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
SPATIAL_SHAPE = load_spatial_shape()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def to_grayscale(image):
|
| 30 |
+
"""Average an ``(H, W, 3)`` RGB-stored radiograph to ``(H, W)``; grayscale passes through."""
|
| 31 |
+
if image.ndim == 3 and image.shape[-1] == 3:
|
| 32 |
+
return image.float().mean(dim=-1)
|
| 33 |
+
return image
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def get_xray_transforms(is_validation: bool = False) -> mt.Compose:
|
| 37 |
+
"""Return the MONAI transforms used for chest-X-ray training/validation.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
is_validation (bool): When True, skip random augmentation.
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
mt.Compose: Composed transform pipeline keyed on "image".
|
| 44 |
+
"""
|
| 45 |
+
transforms = [
|
| 46 |
+
# A pinned reader: auto-detection picks ITKReader whenever itk is installed, which changes the
|
| 47 |
+
# axis order, and MONAI's default swap_ij=True transposes the radiograph, which no rotation or
|
| 48 |
+
# flip undoes (a "fix" mirrors it, silently swapping left and right). swap_ij=False keeps
|
| 49 |
+
# DICOM's (row, column) order; fl-tutorials/tests/ pins this for every app on this path.
|
| 50 |
+
mt.LoadImaged(keys=["image"], reader="PydicomReader", swap_ij=False),
|
| 51 |
+
mt.Lambdad(keys=["image"], func=to_grayscale),
|
| 52 |
+
mt.EnsureChannelFirstd(keys=["image"], channel_dim="no_channel"),
|
| 53 |
+
mt.Resized(keys=["image"], spatial_size=SPATIAL_SHAPE),
|
| 54 |
+
mt.ScaleIntensityd(keys=["image"]),
|
| 55 |
+
]
|
| 56 |
+
if not is_validation:
|
| 57 |
+
transforms.append(mt.RandAffined(keys=["image"], rotate_range=[-0.05, 0.05], scale_range=[0.01, 0.05]))
|
| 58 |
+
return mt.Compose(transforms)
|
diffusion_model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5118f8e8a33cd94fb76a4966bd8f8cc9f5bca539aafcb18d5273b8a072d4c081
|
| 3 |
+
size 88359404
|
evaluation/sample_xray.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""4x4 grid of chest X-rays from the final global model of the X-ray diffusion tutorial.
|
| 2 |
+
|
| 3 |
+
The first n samples drawn from one seed, not picked, with the tutorial's own scheduler and sampler (the full
|
| 4 |
+
1000-step DDPM), shown as the tutorial's debug grids are: one min-max over the whole grid. Runs on the CPU so it
|
| 5 |
+
leaves the GPU to the queue. Also saves the samples as a plain tensor.
|
| 6 |
+
|
| 7 |
+
python sample_xray.py <app custom dir> <FL_global_model.pt> <out.png> [n=16] [seed=0] [threads=8]
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import sys
|
| 12 |
+
import time
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
from torchvision.utils import make_grid, save_image
|
| 17 |
+
|
| 18 |
+
app, ckpt, out = Path(sys.argv[1]), Path(sys.argv[2]), Path(sys.argv[3])
|
| 19 |
+
n, seed, threads = (int(sys.argv[i]) if len(sys.argv) > i else d for i, d in ((4, 16), (5, 0), (6, 8)))
|
| 20 |
+
sys.path.insert(0, str(app))
|
| 21 |
+
from models import get_model # noqa: E402
|
| 22 |
+
from monai.inferers import DiffusionInferer # noqa: E402
|
| 23 |
+
from monai.networks.schedulers import DDPMScheduler # noqa: E402
|
| 24 |
+
|
| 25 |
+
torch.set_num_threads(threads)
|
| 26 |
+
config = json.loads((app / "config.json").read_text())
|
| 27 |
+
model = get_model()
|
| 28 |
+
blob = torch.load(ckpt, map_location="cpu", weights_only=False) # our own run's output
|
| 29 |
+
model.load_state_dict(blob["model"] if isinstance(blob.get("model"), dict) else blob, strict=True)
|
| 30 |
+
model.eval()
|
| 31 |
+
|
| 32 |
+
scheduler = DDPMScheduler(num_train_timesteps=1000, schedule="scaled_linear_beta", clip_sample=False,
|
| 33 |
+
prediction_type="epsilon", beta_start=0.0015, beta_end=0.0195) # as trainer.py
|
| 34 |
+
inferer = DiffusionInferer(scheduler=scheduler)
|
| 35 |
+
shape = [n, config["net_config"]["diffusion_model"]["in_channels"], *config["spatial_shape"]]
|
| 36 |
+
generator = torch.Generator().manual_seed(seed)
|
| 37 |
+
start = time.time()
|
| 38 |
+
with torch.no_grad():
|
| 39 |
+
samples = inferer.sample(input_noise=torch.randn(shape, generator=generator), diffusion_model=model.diffusion_model,
|
| 40 |
+
scheduler=scheduler, save_intermediates=False, verbose=False)
|
| 41 |
+
print(f"sampled {n} in {time.time() - start:.0f} s; range {samples.min():.3f}..{samples.max():.3f}")
|
| 42 |
+
torch.save(samples.as_subclass(torch.Tensor).clone(), out.with_suffix(".pt"))
|
| 43 |
+
save_image(make_grid(samples, nrow=int(n**0.5), normalize=True, scale_each=False, padding=2), str(out))
|
| 44 |
+
print("wrote", out)
|
figures/samples_4x4.png
ADDED
|
Git LFS Details
|
provenance.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"flip_commit": "b3a42840ab5a2d73e45b6d596bdea25f1b383502",
|
| 3 |
+
"install": "pip install \"flip-utils @ git+https://github.com/londonaicentre/FLIP@b3a42840ab5a2d73e45b6d596bdea25f1b383502#subdirectory=flip-utils\"",
|
| 4 |
+
"launch": "cd fl-tutorials/nvflare/image_synthesis/diffusion_model && python job.py --n_clients 2 --num_rounds 2",
|
| 5 |
+
"note": "Runs predate the tutorials' move to fl-tutorials/nvflare/image_generation/ (a2c053ec4).",
|
| 6 |
+
"sites": 2,
|
| 7 |
+
"hardware": "1 x NVIDIA GeForce RTX 5090 (both simulated sites)",
|
| 8 |
+
"versions": {
|
| 9 |
+
"torch": "2.13.0+cu130",
|
| 10 |
+
"monai": "1.6.0",
|
| 11 |
+
"nvflare": "2.9.0"
|
| 12 |
+
},
|
| 13 |
+
"wall_time": {
|
| 14 |
+
"start": "2026-10-22 21:33:39",
|
| 15 |
+
"end": "2026-10-23 02:38:35",
|
| 16 |
+
"hours_minutes": "5 h 05 min"
|
| 17 |
+
},
|
| 18 |
+
"model": "E6",
|
| 19 |
+
"local_epochs": 300,
|
| 20 |
+
"code_files": [
|
| 21 |
+
"config.json",
|
| 22 |
+
"models.py",
|
| 23 |
+
"plot_utils.py",
|
| 24 |
+
"trainer.py",
|
| 25 |
+
"transforms.py"
|
| 26 |
+
],
|
| 27 |
+
"weights": {
|
| 28 |
+
"file": "diffusion_model.safetensors",
|
| 29 |
+
"sha256": "5118f8e8a33cd94fb76a4966bd8f8cc9f5bca539aafcb18d5273b8a072d4c081",
|
| 30 |
+
"dtype": "float32"
|
| 31 |
+
},
|
| 32 |
+
"final_validation_loss": {
|
| 33 |
+
"site-2": 0.0036,
|
| 34 |
+
"site-1": 0.0039
|
| 35 |
+
}
|
| 36 |
+
}
|