aicentreflip's picture
Publish the FLIP tutorial models
d4700c7 verified
Raw History Blame Contribute Delete
6.26 kB
# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Latent diffusion network for the FLIP latent diffusion tutorial: a frozen autoencoder and the UNet that is trained.
The UNet denoises in the autoencoder's latent space, so its in and out channels are the autoencoder's
``latent_channels``. The autoencoder is built from ``autoencoder_config.yaml``, which
``process_tools/extract_autoencoder.py`` writes beside its checkpoint, not from ``config.json``. Its
submodule is named ``autoencoder`` so the checkpoint's ``autoencoder.*`` keys land on it. There is no
discriminator: that only served the autoencoder's own training. ``AGGREGATE_ONLY_REGEX`` selects
``diffusion_model`` for aggregation.
"""
import json
from collections.abc import Mapping
from pathlib import Path
from typing import Any
import yaml
from monai.networks.nets import AutoencoderKL, DiffusionModelUNet
from torch import nn
# Written beside the checkpoint by process_tools/extract_autoencoder.py. Unlike the checkpoint, which
# stays on the FL server, it reaches every client: they build the bare autoencoder from it.
AUTOENCODER_CONFIG = Path(__file__).parent / "autoencoder_config.yaml"
def load_net_config():
"""Read the ``net_config`` block from the ``config.json`` next to this file."""
config_path = Path(__file__).parent / "config.json"
with open(config_path, "r") as f:
config = json.load(f)
net_config = config.get("net_config", {})
print(f"Loaded network config: {net_config}")
return net_config
def read_autoencoder_document(path: Path = AUTOENCODER_CONFIG) -> dict[str, Any]:
"""Read ``autoencoder_config.yaml``: the ``autoencoder`` architecture and its ``autoencoder_sha256``.
Raises:
FileNotFoundError: if it is absent. Deliberately fatal: a guessed architecture would load the
checkpoint as noise without raising.
"""
if not path.is_file():
raise FileNotFoundError(
f"No {path.name} at {path}. This job needs the frozen autoencoder's architecture, which "
"comes with its checkpoint:\n make prepare-checkpoint RAW_CHECKPOINT=<an `autoencoder` run's .pt>"
)
with open(path) as handle:
document = yaml.safe_load(handle)
print(f"Loaded frozen autoencoder architecture from {path.name}: {document['autoencoder']}")
return document
def check_latent_geometry(net_config: Mapping[str, Any], architecture: Mapping[str, Any]) -> None:
"""Fail loudly where ``config.json`` and the autoencoder's config have to agree.
Two couplings are only true by convention, and both are cheap to state:
* the UNet denoises *in the latent space*, so its channel count is the autoencoder's
``latent_channels``. Mismatching raises deep inside a training step instead of here.
* both networks see the same data, so their ``spatial_dims`` cannot differ.
"""
latent_channels = architecture["latent_channels"]
diffusion = net_config["diffusion_model"]
for end in ("in_channels", "out_channels"):
if diffusion[end] != latent_channels:
raise ValueError(
f"net_config.diffusion_model.{end} is {diffusion[end]}, but the frozen autoencoder "
f"produces {latent_channels} latent channels. The UNet denoises latents, not images."
)
if net_config["spatial_dims"] != architecture["spatial_dims"]:
raise ValueError(
f"net_config.spatial_dims is {net_config['spatial_dims']} but the frozen autoencoder was "
f"built for {architecture['spatial_dims']}-D data."
)
class LatentDiffusionModelNetwork(nn.Module):
"""Creates a latent diffusion model containing a:
- Variational Autoencoder (to compress inputs into latent space) — FROZEN, supplied via
``SERVER_CHECKPOINT``
- Diffusion Model (to generate samples in the latent space) — the module actually trained
"""
def __init__(self):
super().__init__()
net_config = load_net_config()
document = read_autoencoder_document()
architecture = document["autoencoder"]
check_latent_geometry(net_config, architecture)
# Built from the checkpoint's own config, so every argument matches the weights that arrive
# in the round-0 broadcast — including the ones no later check could recover.
self.autoencoder = AutoencoderKL(**architecture)
# The fingerprint of those weights; the trainer checks what actually arrived against it.
self.autoencoder_sha256 = document["autoencoder_sha256"]
self.diffusion_model = DiffusionModelUNet(
spatial_dims=net_config["spatial_dims"],
in_channels=net_config["diffusion_model"]["in_channels"],
out_channels=net_config["diffusion_model"]["out_channels"],
with_conditioning=net_config["diffusion_model"]["with_conditioning"],
cross_attention_dim=None
if net_config["diffusion_model"]["cross_attention_dim"] == 0
else net_config["diffusion_model"]["cross_attention_dim"],
channels=net_config["diffusion_model"]["channels"],
num_res_blocks=net_config["diffusion_model"]["num_res_blocks"],
attention_levels=net_config["diffusion_model"]["attention_levels"],
)
_net = LatentDiffusionModelNetwork()
def get_model() -> nn.Module:
"""
Returns the model defined in this file.
NOTE: This function needs to exist and cannot take any input arguments. If you would like to parameterize the
configuration of your model, for example loaded from a config file, do it when instantiating the model above.
"""
return _net