from __future__ import annotations
from pathlib import Path
import os
import json
from argparse import ArgumentParser
import numpy as np
import logging
from typing import Literal, NamedTuple
from . import atlas, template
logger = logging.getLogger(__name__)
def add_parser_arguments(parser: ArgumentParser) -> None:
parser.add_argument(
"folder",
type=Path,
help="Directory to save the generated surfaces.",
)
parser.add_argument(
"-a",
"--all",
action="store_true",
help=(
"Use the PCA atlas derived from all 4,329 subjects from the UK "
"Biobank Study. Currently NOT supported (raises an error). By default we "
"use the PCA atlas derived from 630 healthy reference subjects from the "
"UK Biobank Study"
),
)
parser.add_argument(
"-m",
"--mode",
type=int,
default=-1,
help=(
"Mode to generate points from. If -1, generate points from the mean "
"shape. If between 0 and the number of modes, generate points from "
"the specified mode. By default -1"
),
)
parser.add_argument(
"-s",
"--std",
type=float,
default=1.5,
help="Standard deviation to scale the mode by, by default 1.5",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="Print verbose output.",
)
parser.add_argument(
"--cache-dir",
type=Path,
default=os.environ.get("UKB_CACHE_DIR", Path.home() / ".ukb"),
help=(
"Directory to save the downloaded atlas. "
"Can also be set with the UKB_CACHE_DIR environment variable. "
"By default ~/.ukb"
),
)
parser.add_argument(
"-c",
"--case",
choices=["ED", "ES", "both"],
default="ED",
help="Case to generate surfaces for.",
)
parser.add_argument(
"-g",
"--geometry",
choices=list(RECIPES),
default="biv",
help=(
"Geometry to generate surfaces for: biventricular (biv) or LV only (lv), with "
"open valves, or with the valves closed by caps so that the cavities are "
"closed surfaces (biv-closed, lv-closed)."
),
)
parser.add_argument(
"--burns-path",
type=Path,
default=None,
help=(
"Path to the burns atlas file. "
"This will be a .mat file which will be loaded using scipy.io.loadmat. "
),
)
def tolist(obj: np.ndarray | list) -> list:
if isinstance(obj, np.ndarray):
return obj.tolist()
elif isinstance(obj, list):
return obj
else:
raise ValueError(f"Object of type {type(obj)} cannot be converted to list.")
[docs]
def main(
folder: Path,
all: bool = False,
mode: int = -1,
std: float = 1.5,
verbose: bool = False,
cache_dir: Path = Path.home() / ".ukb",
case: Literal["ED", "ES", "both"] = "ED",
burns_path: Path | None = None,
custom_points: atlas.Points | None = None,
score: np.ndarray | None = None,
geometry: str = "biv",
) -> None:
"""Main function to generate surfas from the UK Biobank atlas.
Parameters
----------
folder : Path
Directory to save the generated points.
all : bool
Use the PCA atlas derived from all 4,329 subjects from the UK Biobank
Study. This is not supported and raises a ``NotImplementedError``. If
false (default), use the PCA atlas derived from 630 healthy reference
subjects from the UK Biobank Study (see [1]_)
mode : int
Mode to generate points from. If -1, generate points from the mean
shape. If between 0 and the number of modes, generate points from
the specified mode. By default -1
std : float
Standard deviation to scale the mode by, by default 1.5
verbose : bool
If true, print verbose output.
cache_dir : Path
Directory to save the downloaded atlas.
case : str
Case to generate surfaces for.
burns_path : Path | None
Path to the burns atlas file. This will be a .mat file which will be loaded
using scipy.io.loadmat.
custom_points : atlas.Points | None
If not None, use these points instead of generating points from the atlas.
score : np.ndarray | None
If not None, use these scores to generate points from the atlas instead of
using the `mode` and `std` parameters. This will override the `mode` and
`std` parameters if provided.
geometry : str
Name of the geometry (see ``RECIPES``) to generate surfaces for, by
default "biv"
"""
if all:
raise NotImplementedError(atlas.ALL_NOT_SUPPORTED)
folder.mkdir(exist_ok=True, parents=True)
args_json = json.dumps(
{
"folder": str(folder),
"all": all,
"mode": mode,
"std": std,
"verbose": verbose,
"cache_dir": str(cache_dir),
"case": case,
"geometry": geometry,
"burns_path": str(burns_path) if burns_path else None,
"custom_points": str(custom_points) if custom_points else None,
"score": tolist(score) if score is not None else None,
},
indent=4,
sort_keys=True,
default=lambda o: str(o),
)
(folder / "parameters.json").write_text(args_json)
if custom_points is not None:
points = custom_points
else:
if burns_path is not None:
if not burns_path.exists():
raise ValueError(f"Burns path {burns_path} does not exist.")
points = atlas.generate_points_burns(
filename=burns_path,
mode=mode,
std=std,
score=score,
)
else:
cache_dir.mkdir(exist_ok=True, parents=True)
filename = atlas.download_atlas(cache_dir, all=all)
points = atlas.generate_points(
filename=filename,
mode=mode,
std=std,
)
if case == "both":
cases = ["ED", "ES"]
else:
cases = [case]
for c in cases:
template.check_points(getattr(points, c), name=f"{c} points")
recipe = RECIPES[geometry]
files: dict[str, dict[str, list[str]]] = {}
for c in cases:
files[c] = {}
for name, regions in recipe.surfaces.items():
# One file per region: gmsh fails to mesh the volume when e.g. the RV
# septum and free wall are merged into a single surface
files[c][name] = []
for region in regions:
path = folder / f"{region}_{c}.stl"
template.extract(getattr(points, c), [region]).write(str(path))
logger.info(f"Saved {path}")
files[c][name].append(path.name)
write_manifest(folder, geometry, recipe.volumes, files)
[docs]
class Recipe(NamedTuple):
"""A geometry made of template regions.
Parameters
----------
surfaces : dict[str, tuple[str, ...]]
Physical surface name -> template regions it is made of
volumes : dict[str, tuple[str, ...]]
Physical volume name -> names of the surfaces enclosing it
"""
surfaces: dict[str, tuple[str, ...]]
volumes: dict[str, tuple[str, ...]]
VALVE_CAPS = {
"MV_CAP": ("MV_CAP",),
"AV_CAP": ("AV_CAP",),
"TV_CAP": ("TV_CAP",),
"PV_CAP": ("PV_CAP",),
}
BIV = Recipe(
surfaces={
"LV_ENDO": ("LV_ENDO",),
"RV_ENDO": ("RV_SEPTUM", "RV_FREEWALL"),
"EPI": ("LV_EPI", "RV_EPI"),
"MV": ("MV",),
"AV": ("AV",),
"TV": ("TV",),
"PV": ("PV",),
},
volumes={"Wall": ("LV_ENDO", "RV_ENDO", "EPI", "MV", "AV", "TV", "PV")},
)
BIV_CLOSED = Recipe(
surfaces=BIV.surfaces | VALVE_CAPS,
volumes=BIV.volumes
| {
"LV_CAVITY": ("LV_ENDO", "MV_CAP", "AV_CAP"),
"RV_CAVITY": ("RV_ENDO", "TV_CAP", "PV_CAP"),
},
)
# The LV wall is closed on the RV side by the RV septal surface and the septal
# bridge cutting through the wall along the RV insertion
LV = Recipe(
surfaces={
"LV_ENDO": ("LV_ENDO",),
"EPI": ("LV_EPI",),
"RV_SEPTUM": ("RV_SEPTUM",),
"SEPTAL_BRIDGE": ("SEPTAL_BRIDGE",),
"MV": ("MV",),
"AV": ("AV",),
},
volumes={"Wall": ("LV_ENDO", "EPI", "RV_SEPTUM", "SEPTAL_BRIDGE", "MV", "AV")},
)
LV_CLOSED = Recipe(
surfaces=LV.surfaces | {"MV_CAP": ("MV_CAP",), "AV_CAP": ("AV_CAP",)},
volumes=LV.volumes | {"LV_CAVITY": ("LV_ENDO", "MV_CAP", "AV_CAP")},
)
RECIPES: dict[str, Recipe] = {
"biv": BIV,
"biv-closed": BIV_CLOSED,
"lv": LV,
"lv-closed": LV_CLOSED,
}
MANIFEST = "surfaces.json"
[docs]
def write_manifest(
folder: Path,
geometry: str,
volumes: dict[str, tuple[str, ...]],
files: dict[str, dict[str, list[str]]],
) -> None:
"""Record the volumes of the geometry and which files make up each surface,
for each case.
Cases from an earlier run with the same geometry are kept, so that ED and ES
can be generated by separate calls.
"""
path = folder / MANIFEST
cases: dict[str, dict[str, list[str]]] = {}
if path.exists():
old = json.loads(path.read_text())
if old.get("geometry") == geometry:
cases = old["cases"]
cases.update(files)
manifest = {"geometry": geometry, "volumes": volumes, "cases": cases}
path.write_text(json.dumps(manifest, indent=4))
[docs]
def read_manifest(folder: Path, case: str) -> tuple[dict[str, list[Path]], dict[str, list[str]]]:
"""Return the surface files and volumes for the given case.
Returns
-------
files : dict[str, list[Path]]
Surface name -> files the surface is made of
volumes : dict[str, list[str]]
Volume name -> names of the surfaces enclosing it
Raises
------
FileNotFoundError
If no surfaces were generated for this case in ``folder``
"""
path = Path(folder) / MANIFEST
manifest = json.loads(path.read_text()) if path.exists() else {"cases": {}}
cases = manifest["cases"]
if case not in cases:
raise FileNotFoundError(
f"No surfaces for case {case} in {folder}. Run `ukb-atlas surf` first."
)
files = {name: [Path(folder) / file for file in files] for name, files in cases[case].items()}
# Manifests written by v1.5.0 do not record the volumes
volumes = manifest.get("volumes") or RECIPES[manifest["geometry"]].volumes
return files, {name: list(surfaces) for name, surfaces in volumes.items()}