This commit is contained in:
Xiao Furen 2025-02-02 12:29:19 +08:00
parent f3945d71a2
commit 58ecd66545
18 changed files with 2934 additions and 0 deletions

2
.gitignore vendored
View file

@ -162,3 +162,5 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
*.h5
*.gz

View file

@ -0,0 +1,22 @@
project(mri_synthmorph)
file(GLOB WEIGHTS "synthmorph.*.h5")
if (FSPYTHON_INSTALL_TREE)
install_pyscript_fspython_tree(mri_synthmorph)
install_symlinks_fspython_tree(TYPE files DESTINATION models ${WEIGHTS})
install_directories_fspython_tree(synthmorph DESTINATION python/packages)
else()
install_pyscript(mri_synthmorph)
install_symlinks(TYPE files DESTINATION models ${WEIGHTS})
install_directories(synthmorph DESTINATION python/packages)
endif()
install_configured(fs-synthmorph-reg DESTINATION bin)
## 08/2024 - currently failing on Intel Mac
if(NOT APPLE)
add_test_script(NAME mri_synthmorph_test_register SCRIPT test_register.sh)
add_test_script(NAME mri_synthmorph_test_apply SCRIPT test_apply.sh)
endif()

46
mri_synthmorph/Dockerfile Normal file
View file

@ -0,0 +1,46 @@
# Define base image. Set HOME to avoid Matplotlib warning about non-writable
# MPLCONFIGDIR on Neurite import when running as non-root user.
FROM tensorflow/tensorflow:2.17.0-gpu AS base
ENV FREESURFER_HOME=/freesurfer
ENV PYTHONUSERBASE="$FREESURFER_HOME/env"
ENV PATH="$FREESURFER_HOME:$PATH"
ENV HOME=/tmp
# Intermediate build stage. Install Python packages to user base for easy COPY.
FROM base AS copy
COPY --chmod=0775 mri_synthmorph $FREESURFER_HOME/
COPY --chmod=0664 synthmorph/*.py $FREESURFER_HOME/synthmorph/
COPY --chmod=0664 synthmorph.*.h5 $FREESURFER_HOME/models/
RUN apt-get update && apt-get install -y --no-install-recommends git
RUN python3 -m pip install -U pip
RUN python3 -m pip install --user \
'numpy<2.0' \
git+https://github.com/adalca/pystrum.git@ba35d4b357f54e5ed577cbd413076a07ef810a21 \
git+https://github.com/adalca/neurite.git@9ae2f5cec2201eedbcc6929cecf852193cef7646 \
git+https://github.com/freesurfer/surfa.git@041905fca717447780e0cc211197669e3218de2f \
git+https://github.com/voxelmorph/voxelmorph.git@53d1b95fa734648c92fd8af4f3807b09cb56c342
WORKDIR /artifacts
RUN python3 -V >python.txt
RUN python3 -m pip freeze >requirements.txt
RUN mri_synthmorph -h >help.general.txt
RUN mri_synthmorph register -h >help.register.txt
RUN mri_synthmorph apply -h >help.apply.txt
# Export Python requirements for reference. Build artifacts will only exist in
# in the target stage `export`.
FROM scratch AS export
COPY --from=copy /artifacts/*.txt /
# Exclude Git and caches from final image to save space. Copy only once to
# avoid unnecessary container layers. Set working directory to /mnt for data
# exchange with the host without having to specify the full path.
FROM base
COPY --from=copy $FREESURFER_HOME $FREESURFER_HOME
WORKDIR /mnt
ENTRYPOINT ["mri_synthmorph"]

106
mri_synthmorph/README.md Normal file
View file

@ -0,0 +1,106 @@
# SynthMorph
This guide explains how to build SynthMorph container images.
It assumes you execute commands in the `mri_synthmorph` directory.
For general information about SynthMorph, visit [synthmorph.io](https://synthmorph.io).
## Managing weight files with git-annex
Weight files are large and therefore managed with `git-annex`.
Instructions with examples are available elsewhere:
* https://surfer.nmr.mgh.harvard.edu/fswiki/GitAnnex
* https://git-annex.branchable.com/walkthrough
## Building SynthMorph images with Docker
FreeSurfer automatically ships the most recent `mri_synthmorph` and weight files.
Building a standalone container image requires fetching and unlocking the model files with `git-annex`, replacing the symbolic links with the actual files.
```sh
git fetch datasrc
git annex get .
git annex unlock synthmorph.*.h5
```
Build a new image with the appropriate version tag:
```sh
tag=X
docker build -t freesurfer/synthmorph:$tag .
```
## Testing the local Docker image
Update the version reference in the wrapper script and run it to test the local image with Docker.
```sh
sed -i "s/^\(version = \).*/\1$tag/" synthmorph
./synthmorph -h
```
## Testing with Apptainer
Testing the image with Apptainer (Singularity) before making it public requires conversion.
If your home directory has a low quota, set up a cache elsewhere:
```sh
d=$(mktemp -d)
export APPTAINER_CACHEDIR="$d"
export APPTAINER_TMPDIR="$d"
```
On the machine running Docker, convert the image with:
```sh
apptainer build -f synthmorph_$tag.sif docker-daemon://freesurfer/synthmorph:$tag
```
If you want to test the image on another machine, save it first.
After transfer to the target machine, build a SIF file as a non-root user using the fakeroot feature.
This relies on namespace mappings set up in /etc/subuid and /etc/subgid (likely by Help).
```sh
docker save synthmorph:$tag | gzip >synthmorph_$tag.tar.gz
apptainer build -f synthmorph_$tag.sif docker-archive://synthmorph_$tag.tar.gz
```
Finally, run the image.
```sh
apptainer run --nv -e -B /autofs synthmorph_$tag.sif
```
## Pushing to the Docker Hub
Push the new image to the Docker Hub to make it public.
Update the default "latest" tag, so that `docker pull freesurfer/synthmorph` without a tag will fetch the most recent image.
```sh
docker push freesurfer/synthmorph:$tag
docker tag freesurfer/synthmorph:$tag freesurfer/synthmorph:latest
docker push freesurfer/synthmorph:latest
```
## Exporting Python requirements
Export build artifacts for users who wish to create a custom Python environment.
```sh
docker build --target export --output env .
```
## Final steps
Lock the annexed weight files again to prevent modification.
```sh
git annex lock synthmorph.*.h5
```

View file

@ -0,0 +1,96 @@
#!/usr/bin/env python3
# This wrapper script facilitates setup and use of SynthMorph containers by
# pulling them from the Docker Hub and mounting the host directory defined by
# environment variable SUBJECTS_DIR to /mnt in the container. Invoke the script
# just like `mri_synthmorph` in FreeSurfer, with one exception: you can only
# read and write data under SUBJECTS_DIR, which will be the working directory
# in the container. If unset, SUBJECTS_DIR defaults to your current directory.
# This means you can access relative paths under your working directory without
# setting SUBJECTS_DIR. In other words, SUBJECTS_DIR sets the working directory
# for SynthMorph, and you can specify paths relative to it.
# Update the version to pull a different image, unless you already have it.
version = 4
# Local image location for Apptainer/Singularity. Set an absolute path to avoid
# pulling new images when you change the folder. Ignored for Docker and Podman.
sif_file = f'synthmorph_{version}.sif'
# We will use the first of the below container systems found in your PATH, from
# left to right. You may wish to reorder them, If you have several installed.
tools = ('docker', 'apptainer', 'singularity', 'podman')
import os
import sys
import signal
import shutil
import subprocess
# Report version. Avoid errors when piping, for example to `head`.
signal.signal(signal.SIGPIPE, handler=signal.SIG_DFL)
hub = 'https://hub.docker.com/u/freesurfer'
print(f'Running SynthMorph version {version} from {hub}')
# Find a container system.
for tool in tools:
path = shutil.which(tool)
if path:
print(f'Using {path} to manage containers')
break
if not path:
print(f'Cannot find container tools {tools} in PATH', file=sys.stderr)
exit(1)
# Prepare bind path and URL. Mount SUBJECTS_DIR as /mnt inside the container,
# which we made the working directory when building the image. While Docker
# and Podman will respect it, they require absolute paths for bind mounts.
host = os.environ.get('SUBJECTS_DIR', os.getcwd())
host = os.path.abspath(host)
print(f'Will bind /mnt in image to SUBJECTS_DIR="{host}"')
image = f'freesurfer/synthmorph:{version}'
if tool != 'docker':
image = f'docker://{image}'
# Run Docker containers with the UID and GID of the host user. This user will
# own bind mounts inside the container, preventing output files owned by root.
# Root inside a rootless Podman container maps to the non-root host user, which
# is what we want. If we set UID and GID inside the container to the non-root
# host user as we do for Docker, then these would get remapped according to
# /etc/subuid outside, causing problems with read and write permissions.
if tool in ('docker', 'podman'):
arg = ('run', '--rm', '-v', f'{host}:/mnt')
# Pretty-print help text.
if sys.stdout.isatty():
arg = (*arg, '-t')
if tool == 'docker':
arg = (*arg, '-u', f'{os.getuid()}:{os.getgid()}')
arg = (*arg, image)
# For Apptainer/Singularity, the user inside and outside the container is the
# same. The working directory is also the same, unless we explicitly set it.
if tool in ('apptainer', 'singularity'):
arg = ('run', '--nv', '--pwd', '/mnt', '-e', '-B', f'{host}:/mnt', sif_file)
if not os.path.isfile(sif_file):
print(f'Cannot find image {sif_file}, pulling it', file=sys.stderr)
proc = subprocess.run((tool, 'pull', sif_file, image))
if proc.returncode:
exit(proc.returncode)
# Summarize and launch container.
print('Command:', ' '.join((tool, *arg)))
print('SynthMorph arguments:', *sys.argv[1:])
proc = subprocess.run((tool, *arg, *sys.argv[1:]))
exit(proc.returncode)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,419 @@
#!/usr/bin/env python3
import os
import sys
import pathlib
import argparse
import textwrap
import surfa as sf
from synthmorph import utils
# Argument settings.
default = {
'model': 'joint',
'hyper': 0.5,
'extent': 256,
'steps': 7,
'method': 'linear',
'type': 'float32',
'fill': 0,
}
choices = {
'model': ('joint', 'deform', 'affine', 'rigid'),
'extent': (192, 256),
'method': ('linear', 'nearest'),
'type': ('uint8', 'uint16', 'int16', 'int32', 'float32'),
}
limits = {
'steps': 5,
}
resolve = ('model', 'method')
# Documentation.
n = '\033[0m' if sys.stdout.isatty() else ''
b = '\033[1m' if sys.stdout.isatty() else ''
u = '\033[4m' if sys.stdout.isatty() else ''
prog = os.path.basename(sys.argv[0])
# References.
ref = f'''
SynthMorph: learning contrast-invariant registration without acquired images\t
Hoffmann M, Billot B, Greve DN, Iglesias JE, Fischl B, Dalca AV\t
IEEE Transactions on Medical Imaging, 41 (3), 543-558, 2022\t
https://doi.org/10.1109/TMI.2021.3116879
Anatomy-specific acquisition-agnostic {u}affine{n} registration learned from fictitious images\t
Hoffmann M, Hoopes A, Fischl B*, Dalca AV* (*equal contribution)\t
SPIE Medical Imaging: Image Processing, 12464, 1246402, 2023\t
https://doi.org/10.1117/12.2653251\t
https://synthmorph.io/#papers (PDF)
Anatomy-aware and acquisition-agnostic {u}joint{n} registration with SynthMorph\t
Hoffmann M, Hoopes A, Greve DN, Fischl B*, Dalca AV* (*equal contribution)\t
Imaging Neuroscience, 2, 1-33, 2024\t
https://doi.org/10.1162/imag_a_00197
Website: https://synthmorph.io
'''
help_general = f'''{prog}
{b}NAME{n}
{b}{prog}{n} - register 3D brain images without preprocessing
{b}SYNOPSIS{n}
{b}{prog}{n} [-h] {u}command{n} [options]
{b}DESCRIPTION{n}
SynthMorph is a deep-learning tool for symmetric, acquisition-agnostic
registration of single-frame brain MRI of any geometry. The
registration is anatomy-aware, removing the need for skull-stripping,
and you can control the warp smoothness.
Pass an option or {u}command{n} from the following list. You can omit
trailing characters, as long as there is no ambiguity.
{b}register{n}
Register 3D brain images without preprocessing.
{b}apply{n}
Apply an existing transform to another 3D image or label map.
{b}-h{n}
Print this help text and exit.
{b}IMAGE FORMAT{n}
The registration supports single-frame image volumes of any size,
resolution, and orientation. The moving and the fixed image geometries
can differ. The accepted image file formats are: MGH (.mgz) and NIfTI
(.nii.gz, .nii).
Internally, the registration converts image buffers to: isotropic 1-mm
voxels, intensities min-max normalized into the interval [0, 1], and
left-inferior-anterior (LIA) axes. This conversion requires intact
image-to-world matrices. That is, the head must have the correct
anatomical orientation in a viewer like {b}freeview{n}.
{b}TRANSFORM FORMAT{n}
SynthMorph transformations operate in physical RAS space. We save
matrix transforms as text in LTA format (.lta) and displacement fields
as images with three frames indicating shifts in RAS direction.
{b}ENVIRONMENT{n}
The following environment variables affect {b}{prog}{n}:
SUBJECTS_DIR
Ignored unless {b}{prog}{n} runs inside a container. Mounts the
host directory SUBJECTS_DIR to {u}/mnt{n} inside the container.
Defaults to the current working directory.
{b}SEE ALSO{n}
For converting, composing, and applying transforms, consider FreeSurfer
tools {b}lta_convert{n}, {b}mri_warp_convert{n},
{b}mri_concatenate_lta{n}, {b}mri_concatenate_gcam{n}, and
{b}mri_convert{n}.
{b}CONTACT{n}
Reach out to freesurfer@nmr.mgh.harvard.edu or at
https://voxelmorph.net.
{b}REFERENCES{n}
If you use SynthMorph in a publication, please cite us!
''' + textwrap.indent(ref, prefix=' ' * 8)
help_register = f'''{prog}-register
{b}NAME{n}
{b}{prog}-register{n} - register 3D brain images without preprocessing
{b}SYNOPSIS{n}
{b}{prog} register{n} [options] {u}moving{n} {u}fixed{n}
{b}DESCRIPTION{n}
SynthMorph is a deep-learning tool for symmetric, acquisition-agnostic
registration of brain MRI with any volume size, resolution, and
orientation. The registration is anatomy-aware, removing the need for
skull-stripping, and you can control the warp smoothness.
SynthMorph registers a {u}moving{n} (source) image to a {u}fixed{n}
(target) image. Their geometries can differ. The options are as
follows:
{b}-m{n} {u}model{n}
Transformation model ({', '.join(choices['model'])}). Defaults
to {default['model']}. Joint includes affine and deformable but
differs from running both in sequence in that it applies the
deformable step in an affine mid-space to guarantee symmetric
joint transforms. Deformable assumes prior affine alignment or
initialization with {b}-i{n}.
{b}-o{n} {u}image{n}
Save {u}moving{n} registered to {u}fixed{n}.
{b}-O{n} {u}image{n}
Save {u}fixed{n} registered to {u}moving{n}.
{b}-H{n}
Update the voxel-to-world matrix instead of resampling when
saving images with {b}-o{n} and {b}-O{n}. For matrix transforms
only. Not all software supports headers with shear from affine
registration.
{b}-t{n} {u}trans{n}
Save the transform from {u}moving{n} to {u}fixed{n}, including
any initialization.
{b}-T{n} {u}trans{n}
Save the transform from {u}fixed{n} to {u}moving{n}, including
any initialization.
{b}-i{n} {u}trans{n}
Apply an initial matrix transform to {u}moving{n} before the
registration.
{b}-M{n}
Apply half the initial matrix transform to {u}moving{n} and
(the inverse of) the other half to {u}fixed{n}, for symmetry.
This will make running the deformable after an affine step
equivalent to joint registration. Requires {b}-i{n}.
{b}-j{n} {u}threads{n}
Number of TensorFlow threads. System default if unspecified.
{b}-g{n}
Use the GPU in environment variable CUDA_VISIBLE_DEVICES or GPU
0 if the variable is unset or empty.
{b}-r{n} {u}lambda{n}
Regularization parameter in the open interval (0, 1) for
deformable registration. Higher values lead to smoother warps.
Defaults to {default['hyper']}.
{b}-n{n} {u}steps{n}
Integration steps for deformable registration. Lower numbers
improve speed and memory use but can lead to inaccuracies and
folding voxels. Defaults to {default['steps']}. Should not be
less than {limits['steps']}.
{b}-e{n} {u}extent{n}
Isotropic extent of the registration space in unit voxels
{choices['extent']}. Lower values improve speed and memory use
but may crop the anatomy of interest. Defaults to
{default['extent']}.
{b}-w{n} {u}weights{n}
Use alternative model weights, exclusively. Repeat the flag
to set affine and deformable weights for joint registration,
or the result will disappoint.
{b}-h{n}
Print this help text and exit.
{b}ENVIRONMENT{n}
The following environment variables affect {b}{prog}-register{n}:
CUDA_VISIBLE_DEVICES
Use a specific GPU. If unset or empty, passing {b}-g{n} will
select GPU 0. Ignored without {b}-g{n}.
FREESURFER_HOME
Load model weights from directory {u}FREESURFER_HOME/models{n}.
Ignored when specifying weights with {b}-w{n}.
{b}EXAMPLES{n}
Joint affine-deformable registration, saving the moved image:
# {prog} register -o out.nii mov.nii fix.nii
Joint registration at 25% warp smoothness:
# {prog} register -r 0.25 -o out.nii mov.nii fix.nii
Affine registration saving the transform:
# {prog} register -m affine -t aff.lta mov.nii.gz fix.nii.gz
Deformable registration only, assuming prior affine alignment:
# {prog} register -m deform -t def.mgz mov.mgz fix.mgz
Deformable step initialized with an affine transform:
# {prog} reg -m def -i aff.lta -o out.mgz mov.mgz fix.mgz
Rigid registration, setting the output image header (no resampling):
# {prog} register -m rigid -Ho out.mgz mov.mgz fix.mgz
'''
help_apply = f'''{prog}-apply
{b}NAME{n}
{b}{prog}-apply{n} - apply an existing SynthMorph transform
{b}SYNOPSIS{n}
{b}{prog} apply{n} [options] {u}trans{n} {u}image{n} {u}output{n}
[{u}image{n} {u}output{n} ...]
{b}DESCRIPTION{n}
Apply a spatial transform {u}trans{n} estimated by SynthMorph to a 3D
{u}image{n} and write the result to {u}output{n}. You can pass any
number of image-output pairs to be processed in the same way.
The following options identically affect all input-output pairs.
{b}-H{n}
Update the voxel-to-world matrix of {u}output{n} instead of
resampling. For matrix transforms only. Not all software and
file formats support headers with shear from affine
registration.
{b}-m{n} {u}method{n}
Interpolation method ({', '.join(choices['method'])}). Defaults
to {default['method']}. Choose linear for images and nearest
for label (segmentation) maps.
{b}-t{n} {u}type{n}
Output data type ({', '.join(choices['type'])}). Defaults to
{default['type']}. Casting to a type other than
{default['type']} after linear interpolation may result in
information loss.
{b}-f{n} {u}fill{n}
Extrapolation fill value for areas outside the field-of-view of
{u}image{n}. Defaults to {default['fill']}.
{b}-h{n}
Print this help text and exit.
{b}EXAMPLES{n}
Apply an affine transform to an image:
# {prog} apply affine.lta image.nii out.nii.gz
Apply a warp to an image, saving the output in floating-point format:
# {prog} apply -t float32 warp.nii image.nii out.nii
Apply the same transform to two images:
# {prog} app warp.mgz im_1.mgz out_1.mgz im_2.mgz out_2.mgz
Transform a label map:
# {prog} apply -m nearest warp.nii labels.nii out.nii
'''
# Command-line parsing.
p = argparse.ArgumentParser()
p.format_help = lambda: utils.rewrap_text(help_general, end='\n\n')
sub = p.add_subparsers(dest='command')
commands = {f: sub.add_parser(f) for f in ('register', 'apply')}
def add_flags(f):
out = {}
if f in choices:
out.update(choices=choices[f])
if f in resolve:
out.update(type=lambda x: utils.resolve_abbrev(x, strings=choices[f]))
if f in default:
out.update(default=default[f])
return out
# Registration arguments.
r = commands['register']
r.add_argument('moving')
r.add_argument('fixed')
r.add_argument('-m', dest='model', **add_flags('model'))
r.add_argument('-o', dest='out_moving', metavar='image')
r.add_argument('-O', dest='out_fixed', metavar='image')
r.add_argument('-H', dest='header_only', action='store_true')
r.add_argument('-t', dest='trans', metavar='trans')
r.add_argument('-T', dest='inverse', metavar='trans')
r.add_argument('-i', dest='init', metavar='trans')
r.add_argument('-M', dest='mid_space', action='store_true')
r.add_argument('-j', dest='threads', metavar='threads', type=int)
r.add_argument('-g', dest='gpu', action='store_true')
r.add_argument('-r', dest='hyper', metavar='lambda', type=float, **add_flags('hyper'))
r.add_argument('-n', dest='steps', metavar='steps', type=int, **add_flags('steps'))
r.add_argument('-e', dest='extent', type=int, **add_flags('extent'))
r.add_argument('-w', dest='weights', metavar='weights', action='append')
r.add_argument('-v', dest='verbose', action='store_true')
r.add_argument('-d', dest='out_dir', metavar='dir', type=pathlib.Path)
r.format_help = lambda: utils.rewrap_text(help_register, end='\n\n')
# Transformation arguments.
a = commands['apply']
a.add_argument('trans')
a.add_argument('pairs', metavar='image output', nargs='+')
a.add_argument('-H', dest='header_only', action='store_true')
a.add_argument('-m', dest='method', **add_flags('method'))
a.add_argument('-t', dest='type', **add_flags('type'))
a.add_argument('-f', dest='fill', metavar='fill', type=float, **add_flags('fill'))
a.format_help = lambda: utils.rewrap_text(help_apply, end='\n\n')
# Parse arguments.
if len(sys.argv) == 1:
p.print_usage()
exit(0)
# Command resolution.
c = sys.argv[1] = utils.resolve_abbrev(sys.argv[1], commands)
if len(sys.argv) == 2 and c in commands:
commands[c].print_usage()
exit(0)
# Default command.
if c not in (*commands, '-h'):
sys.argv.insert(1, 'register')
arg = p.parse_args()
# Command.
if arg.command == 'register':
# Argument checking.
if arg.header_only and not arg.model in ('affine', 'rigid'):
sf.system.fatal('-H is not compatible with deformable registration')
if arg.mid_space and not arg.init:
sf.system.fatal('-M requires matrix initialization')
if not 0 < arg.hyper < 1:
sf.system.fatal('regularization strength not in open interval (0, 1)')
if arg.steps < limits['steps']:
sf.system.fatal('too few integration steps')
# TensorFlow setup.
gpu = os.environ.get('CUDA_VISIBLE_DEVICES', '0')
os.environ['CUDA_VISIBLE_DEVICES'] = gpu if arg.gpu else ''
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '0' if arg.verbose else '3'
os.environ['NEURITE_BACKEND'] = 'tensorflow'
os.environ['VXM_BACKEND'] = 'tensorflow'
from synthmorph import registration
registration.register(arg)
if arg.command == 'apply':
# Argument checking.
which = 'affine' if arg.trans.endswith('.lta') else 'warp'
if arg.header_only and which == 'warp':
sf.system.fatal('-H is not compatible with deformable transforms')
if len(arg.pairs) % 2:
sf.system.fatal('list of input-output pairs not of even length')
# Transform.
trans = getattr(sf, f'load_{which}')(arg.trans)
prop = dict(method=arg.method, resample=not arg.header_only, fill=arg.fill)
for inp, out in zip(arg.pairs[::2], arg.pairs[1::2]):
sf.load_volume(inp).transform(trans, **prop).astype(arg.type).save(out)
print('Thank you for choosing SynthMorph. Please cite us!')
print(utils.rewrap_text(ref))

View file

@ -0,0 +1,237 @@
#!/usr/bin/env python3
import os
import sys
import shutil
import textwrap
import argparse
import surfa as sf
# Settings.
default = {
'method': 'linear',
'fill': 0,
'type': 'float32',
}
choices = {
'method': ('linear', 'nearest'),
'type': ('uint8', 'uint16', 'int16', 'int32', 'float32'),
}
def rewrap(text, width=None, hard='\t\n', hard_indent=0):
"""Rewrap text such that lines fill the available horizontal space.
Reformats individual paragraphs of a text body, considering subsequent
lines with identical indentation as paragraphs. For unspecified width, the
function will attempt to determine the extent of the terminal.
Parameters
----------
text : str
Text to rewrap.
width : int, optional
Maximum line width. None means the width of the terminal as determined
by `textwrap`, defaulting to 80 characters for background processes.
hard : str, optional
String interpreted as a hard break when terminating a line. Useful for
inserting a line break without changing the indentation level. Must end
with a line break and will be removed from the output text.
hard_indent : int, optional
Number of additional whitespace characters by which to indent the lines
following a hard break. See `hard`.
Returns
-------
out : str
Reformatted text.
"""
# Inputs.
if width is None:
width = shutil.get_terminal_size().columns
lines = text.splitlines(keepends=True)
# Merge lines to paragraphs.
pad = []
pad_hard = []
par = []
for i, line in enumerate(lines):
ind = len(line) - len(line.lstrip())
if i == 0 or ind != pad[-1] or lines[i - 1].endswith(hard):
par.append('')
pad.append(ind)
pad_hard.append(ind)
if line.endswith(hard):
line = line.replace(hard, '\n')
pad_hard[-1] += hard_indent
par[-1] += line[ind:]
# Reformat paragraphs.
for i, _ in enumerate(par):
par[i] = textwrap.fill(
par[i], width,
initial_indent=' ' * pad[i], subsequent_indent=' ' * pad_hard[i],
)
return '\n'.join(par)
# Documentation.
n = '\033[0m' if sys.stdout.isatty() else ''
b = '\033[1m' if sys.stdout.isatty() else ''
u = '\033[4m' if sys.stdout.isatty() else ''
prog = os.path.basename(sys.argv[0])
doc = f'''{prog}
{b}NAME{n}
{b}{prog}{n} - apply a SynthMorph transform to 3D images
{b}SYNOPSIS{n}
{b}{prog}{n} [options] {u}trans{n} {u}image{n} {u}output{n}
[{u}image{n} {u}output{n} ...]
{b}DESCRIPTION{n}
Apply a spatial transform {u}trans{n} estimated by SynthMorph to a 3D
{u}image{n} and write the result to {u}output{n}. You can pass any
number of image-output pairs to be processed in the same way.
The following options identically affect all image-output pairs.
{b}-H{n}
Update the voxel-to-world matrix of the output image instead of
resampling. For matrix transforms only. Not all software and
file formats support headers with shear from affine
registration.
{b}-i{n} {u}method{n}
Interpolation method ({', '.join(choices['method'])}). Defaults
to {default['method']}. Choose linear for images and nearest
for label (segmentation) maps.
{b}-t{n} {u}type{n}
Output data type ({', '.join(choices['type'])}). Defaults to
{default['type']}. Casting to a narrower type can result in
information loss.
{b}-f{n} {u}fill{n}
Extrapolation fill value for areas outside the field-of-view of
the input image. Defaults to {default['fill']}.
{b}-h{n}
Print this help text and exit.
{b}IMAGE FORMAT{n}
Accepted file formats include: MGH (.mgz) and NIfTI (.nii.gz, .nii).
{b}TRANSFORMS{n}
Refer to the help text of the registration utility for information on
transform file formats.
For converting, composing, and applying transforms, consider the
FreeSurfer tools lta_convert, mri_warp_convert, mri_concatenate_lta,
mri_concatenate_gcam, mri_convert, mri_info.
{b}ENVIRONMENT{n}
The following environment variables affect {b}{prog}{n}:
SUBJECTS_DIR
Ignored unless {b}{prog}{n} runs inside a container. Mount the
host directory SUBJECTS_DIR to {u}/mnt{n} inside the container.
Defaults to the current working directory.
{b}EXAMPLES{n}
Apply an affine transform to an image:
# {prog} affine.lta image.nii out.nii.gz
Apply a warp to an image, saving the output in floating-point format:
# {prog} -t float32 warp.mgz image.mgz out.mgz
Apply a transform to each of two images:
# {prog} warp.mgz image_1.mgz out_1.mgz image_2.mgz out_2.mgz
Transform a label map:
# {prog} -i nearest warp.mgz labels.mgz out.mgz
{b}CONTACT{n}
Reach out to freesurfer@nmr.mgh.harvard.edu or at
https://github.com/voxelmorph/voxelmorph.
{b}REFERENCES{n}
If you use SynthMorph in a publication, please cite us!
'''
# References.
ref = '''
SynthMorph: learning contrast-invariant registration without acquired images\t
Hoffmann M, Billot B, Greve DN, Iglesias JE, Fischl B, Dalca AV\t
IEEE Transactions on Medical Imaging, 41 (3), 543-558, 2022\t
https://doi.org/10.1109/TMI.2021.3116879
Anatomy-specific acquisition-agnostic affine registration learned from fictitious images\t
Hoffmann M, Hoopes A, Fischl B*, Dalca AV* (*equal contribution)\t
SPIE Medical Imaging: Image Processing, 12464, 1246402, 2023\t
https://doi.org/10.1117/12.2653251\t
https://synthmorph.io/#papers (PDF)
Anatomy-aware and acquisition-agnostic joint registration with SynthMorph\t
Hoffmann M, Hoopes A, Greve DN, Fischl B*, Dalca AV* (*equal contribution)\t
Imaging Neuroscience, 2, 1-33, 2024\t
https://doi.org/10.1162/imag_a_00197
Website: https://synthmorph.io
'''
doc += textwrap.indent(ref, prefix=' ' * 8)
print(rewrap((
f'Warning: {prog} is deprecated in favor of `mri_synthmorph apply` and '
'will be removed in the future.'
)))
# Arguments.
p = argparse.ArgumentParser(add_help=False)
p.add_argument('trans')
p.add_argument('pairs', metavar='image output', nargs='+')
p.add_argument('-H', dest='header_only', action='store_true')
p.add_argument('-i', dest='method', choices=choices['method'], default=default['method'])
p.add_argument('-t', dest='type', choices=choices['type'], default=default['type'])
p.add_argument('-f', dest='fill', metavar='fill', type=float, default=default['fill'])
p.add_argument('-h', action='store_true')
# Help.
if len(sys.argv) == 1:
p.print_usage()
exit(0)
if any(f[0] == '-' and 'h' in f for f in sys.argv):
print(rewrap(doc), end='\n\n')
exit(0)
# Parsing.
arg = p.parse_args()
if len(arg.pairs) % 2:
sf.system.fatal('did not receive even-length list of input-output pairs')
# Transform.
f = 'affine' if arg.trans.endswith('.lta') else 'warp'
trans = getattr(sf, f'load_{f}')(arg.trans)
# Application.
pairs = zip(arg.pairs[::2], arg.pairs[1::2])
prop = dict(method=arg.method, resample=not arg.header_only, fill=arg.fill)
for inp, out in pairs:
sf.load_volume(inp).transform(trans, **prop).astype(arg.type).save(out)
print('Thank you for choosing SynthMorph. Please cite us!')
print(rewrap(ref))

View file

@ -0,0 +1,313 @@
import os
import h5py
import numpy as np
import surfa as sf
import tensorflow as tf
import voxelmorph as vxm
# Settings.
weights = {
'joint': ('synthmorph.affine.2.h5', 'synthmorph.deform.3.h5',),
'deform': ('synthmorph.deform.3.h5',),
'affine': ('synthmorph.affine.2.h5',),
'rigid': ('synthmorph.rigid.1.h5',),
}
def network_space(im, shape, center=None):
"""Construct transform from network space to the voxel space of an image.
Constructs a coordinate transform from the space the network will operate
in to the zero-based image index space. The network space has isotropic
1-mm voxels, left-inferior-anterior (LIA) orientation, and no shear. It is
centered on the field of view, or that of a reference image. This space is
an indexed voxel space, not world space.
Parameters
----------
im : surfa.Volume
Input image to construct the transform for.
shape : (3,) array-like
Spatial shape of the network space.
center : surfa.Volume, optional
Center the network space on the center of a reference image.
Returns
-------
out : tuple of (3, 4) NumPy arrays
Transform from network to input-image space and its inverse, thinking
coordinates.
"""
old = im.geom
new = sf.ImageGeometry(
shape=shape,
voxsize=1,
rotation='LIA',
center=old.center if center is None else center.geom.center,
shear=None,
)
net_to_vox = old.world2vox @ new.vox2world
vox_to_net = new.world2vox @ old.vox2world
return net_to_vox.matrix, vox_to_net.matrix
def transform(im, trans, shape=None, normalize=False, batch=False):
"""Apply a spatial transform to 3D image voxel data in dimensions.
Applies a transformation matrix operating in zero-based index space or a
displacement field to an image buffer.
Parameters
----------
im : surfa.Volume or NumPy array or TensorFlow tensor
Input image to transform, without batch dimension.
trans : array-like
Transform to apply to the image. A matrix of shape (3, 4), a matrix
of shape (4, 4), or a displacement field of shape (*space, 3),
without batch dimension.
shape : (3,) array-like, optional
Output shape used for converting matrices to dense transforms. None
means the shape of the input image will be used.
normalize : bool, optional
Min-max normalize the image intensities into the interval [0, 1].
batch : bool, optional
Prepend a singleton batch dimension to the output tensor.
Returns
-------
out : float TensorFlow tensor
Transformed image with a trailing feature dimension.
"""
# Add singleton feature dimension if needed.
if tf.rank(im) == 3:
im = im[..., tf.newaxis]
out = vxm.utils.transform(
im, trans, fill_value=0, shift_center=False, shape=shape,
)
if normalize:
out -= tf.reduce_min(out)
out /= tf.reduce_max(out)
if batch:
out = out[tf.newaxis, ...]
return out
def load_weights(model, weights):
"""Load weights into model or submodel.
Attempts to load (all) weights into a model or one of its submodels. If
that fails, `model` may be a submodel of what we got weights for, and we
attempt to load the weights of a submodel (layer) into `model`.
Parameters
----------
model : TensorFlow model
Model to initialize.
weights : str or pathlib.Path
Path to weights file.
Raises
------
ValueError
If unsuccessful at loading any weights.
"""
# Extract submodels.
models = [model]
i = 0
while i < len(models):
layers = [f for f in models[i].layers if isinstance(f, tf.keras.Model)]
models.extend(layers)
i += 1
# Add models wrapping a single model in case this was done in training.
# Requires list expansion or Python will get stuck.
models.extend([tf.keras.Model(m.inputs, m(m.inputs)) for m in models])
# Attempt to load all weights into one of the models.
for mod in models:
try:
mod.load_weights(weights)
return
except ValueError as e:
pass
# Assume `model` is a submodel of what we got weights for.
with h5py.File(weights, mode='r') as h5:
layers = h5.attrs['layer_names']
weights = [list(h5[lay].attrs['weight_names']) for lay in layers]
# Layers with weights. Attempt loading.
layers, weights = zip(*filter(lambda f: f[1], zip(layers, weights)))
for lay, wei in zip(layers, weights):
try:
model.set_weights([h5[lay][w] for w in wei])
return
except ValueError as e:
if lay is layers[-1]:
raise e
def register(arg):
# Parse arguments.
in_shape = (arg.extent,) * 3
is_mat = arg.model in ('affine', 'rigid')
# Threading.
if arg.threads:
tf.config.threading.set_inter_op_parallelism_threads(arg.threads)
tf.config.threading.set_intra_op_parallelism_threads(arg.threads)
# Input data.
mov = sf.load_volume(arg.moving)
fix = sf.load_volume(arg.fixed)
if not len(mov.shape) == len(fix.shape) == 3:
sf.system.fatal('input images are not single-frame volumes')
# Transforms between native voxel and network coordinates. Voxel and
# network spaces differ for each image. The networks expect isotropic 1-mm
# LIA spaces. Center these on the original images, except in the deformable
# case: it assumes prior affine registration, so we center the moving
# network space on the fixed image, to take into account affine transforms
# via resampling, updating the header, or passed on the command line alike.
center = fix if arg.model == 'deform' else None
net_to_mov, mov_to_net = network_space(mov, shape=in_shape, center=center)
net_to_fix, fix_to_net = network_space(fix, shape=in_shape)
# Coordinate transforms from and to world space. There is only one world.
mov_to_ras = mov.geom.vox2world.matrix
fix_to_ras = fix.geom.vox2world.matrix
ras_to_mov = mov.geom.world2vox.matrix
ras_to_fix = fix.geom.world2vox.matrix
# Incorporate an initial matrix transform from moving to fixed coordinates,
# as LTAs store the inverse. For mid-space initialization, compute the
# square root of the transform between fixed and moving network space.
if arg.init:
init = sf.load_affine(arg.init).convert(space='voxel')
if init.ndim != 3 \
or not sf.transform.image_geometry_equal(mov.geom, init.source, tol=1e-3) \
or not sf.transform.image_geometry_equal(fix.geom, init.target, tol=1e-3):
sf.system.fatal('initial transform geometry does not match images')
init = fix_to_net @ init @ net_to_mov
if arg.mid_space:
init = tf.linalg.sqrtm(init)
if np.any(np.isnan(init)):
sf.system.fatal(f'cannot compute matrix square root of {arg.init}')
net_to_fix = net_to_fix @ init
fix_to_net = np.linalg.inv(net_to_fix)
net_to_mov = net_to_mov @ tf.linalg.inv(init)
mov_to_net = np.linalg.inv(net_to_mov)
# Take the input images to network space. When saving the moving image with
# the correct voxel-to-RAS matrix after incorporating an initial transform,
# an image viewer taking this matrix into account will show an unchanged
# image. The networks only see the voxel data, which have been moved.
inputs = (
transform(mov, net_to_mov, shape=in_shape, normalize=True, batch=True),
transform(fix, net_to_fix, shape=in_shape, normalize=True, batch=True),
)
# Network. For deformable-only registration, `HyperVxmJoint` ignores the
# `mid_space` argument, and the initialization will determine the space.
prop = dict(in_shape=in_shape, bidir=True)
if is_mat:
prop.update(make_dense=False, rigid=arg.model == 'rigid')
model = vxm.networks.VxmAffineFeatureDetector(**prop)
else:
prop.update(mid_space=True, int_steps=arg.steps, skip_affine=arg.model == 'deform')
model = vxm.networks.HyperVxmJoint(**prop)
inputs = (tf.constant([arg.hyper]), *inputs)
# Weights.
if not arg.weights:
fs = os.environ.get('FREESURFER_HOME')
if not fs:
sf.system.fatal('set environment variable FREESURFER_HOME or weights')
arg.weights = [os.path.join(fs, 'models', f) for f in weights[arg.model]]
for f in arg.weights:
load_weights(model, weights=f)
# Inference. The first transform maps from the moving to the fixed image,
# or equivalently, from fixed to moving coordinates. The second is the
# inverse. Convert transforms between moving and fixed network spaces to
# transforms between the original voxel spaces.
pred = tuple(map(tf.squeeze, model(inputs)))
fw, bw = pred
fw = vxm.utils.compose((net_to_mov, fw, fix_to_net), shift_center=False, shape=fix.shape)
bw = vxm.utils.compose((net_to_fix, bw, mov_to_net), shift_center=False, shape=mov.shape)
# Associate image geometries with the transforms. LTAs store the inverse.
if is_mat:
fw, bw = bw, fw
fw = sf.Affine(fw, source=mov, target=fix, space='voxel')
bw = sf.Affine(bw, source=fix, target=mov, space='voxel')
format = dict(space='world')
else:
fw = sf.Warp(fw, source=mov, target=fix, format=sf.Warp.Format.disp_crs)
bw = sf.Warp(bw, source=fix, target=mov, format=sf.Warp.Format.disp_crs)
format = dict(format=sf.Warp.Format.disp_ras)
# Output transforms.
if arg.trans:
fw.convert(**format).save(arg.trans)
if arg.inverse:
bw.convert(**format).save(arg.inverse)
# Moved images.
if arg.out_moving:
mov.transform(fw, resample=not arg.header_only).save(arg.out_moving)
if arg.out_fixed:
fix.transform(bw, resample=not arg.header_only).save(arg.out_fixed)
# Outputs in network space.
if arg.out_dir:
arg.out_dir.mkdir(exist_ok=True)
# Input images.
mov = sf.ImageGeometry(in_shape, vox2world=mov_to_ras @ net_to_mov)
fix = sf.ImageGeometry(in_shape, vox2world=fix_to_ras @ net_to_fix)
mov = sf.Volume(inputs[-2][0], geometry=fix if arg.init else mov)
fix = sf.Volume(inputs[-1][0], geometry=fix)
mov.save(filename=arg.out_dir / 'inp_1.nii.gz')
fix.save(filename=arg.out_dir / 'inp_2.nii.gz')
fw, bw = pred
if is_mat:
fw, bw = bw, fw
fw = sf.Affine(fw, source=mov, target=fix, space='voxel')
bw = sf.Affine(bw, source=fix, target=mov, space='voxel')
ext = 'lta'
else:
fw = sf.Warp(fw, source=mov, target=fix, format=sf.Warp.Format.disp_crs)
bw = sf.Warp(bw, source=fix, target=mov, format=sf.Warp.Format.disp_crs)
ext = 'nii.gz'
# Transforms.
fw.convert(**format).save(filename=arg.out_dir / f'tra_1.{ext}')
bw.convert(**format).save(filename=arg.out_dir / f'tra_2.{ext}')
# Moved images.
mov.transform(fw).save(filename=arg.out_dir / 'out_1.nii.gz')
fix.transform(bw).save(filename=arg.out_dir / 'out_2.nii.gz')
vmpeak = sf.system.vmpeak()
if vmpeak is not None:
print(f'#@# mri_synthmorph: {arg.model}, threads: {arg.threads}, VmPeak: {vmpeak}')

View file

@ -0,0 +1,93 @@
import shutil
import textwrap
def resolve_abbrev(needle, strings, lower=False):
"""Return a full-length string matching a substring from the beginning.
Parameters
----------
needle : str
Substring of one of several `strings`.
strings : str or iterable of str
Full-length strings, one of which should begin with `needle`.
lower : bool, optional
Convert needle to lowercase before matching.
Returns
-------
str
String in `strings` that begins with `needle` if there is no ambiguity.
If there is not exactly one match, the function will return `needle`.
"""
if isinstance(strings, str):
strings = [strings]
strings = tuple(strings)
if lower:
needle = needle.lower()
matches = [f for f in strings if f.startswith(needle)]
return matches[0] if len(matches) == 1 else needle
def rewrap_text(text, width=None, hard='\t\n', hard_indent=0, end=''):
"""Rewrap text such that lines fill the available horizontal space.
Reformats individual paragraphs of a text body, considering subsequent
lines with identical indentation as paragraphs. For unspecified width, the
function will attempt to determine the extent of the terminal.
Parameters
----------
text : str
Text to rewrap.
width : int, optional
Maximum line width. None means the width of the terminal as determined
by `textwrap`, defaulting to 80 characters for background processes.
hard : str, optional
String interpreted as a hard break when terminating a line. Useful for
inserting a line break without changing the indentation level. Must end
with a line break and will be removed from the output text.
hard_indent : int, optional
Number of additional whitespace characters by which to indent the lines
following a hard break. See `hard`.
end : str, optional
Append to the reformatted text.
Returns
-------
out : str
Reformatted text.
"""
# Inputs.
if width is None:
width = shutil.get_terminal_size().columns
lines = text.splitlines(keepends=True)
# Merge lines to paragraphs.
pad = []
pad_hard = []
par = []
for i, line in enumerate(lines):
ind = len(line) - len(line.lstrip())
if i == 0 or ind != pad[-1] or lines[i - 1].endswith(hard):
par.append('')
pad.append(ind)
pad_hard.append(ind)
if line.endswith(hard):
line = line.replace(hard, '\n')
pad_hard[-1] += hard_indent
par[-1] += line[ind:]
# Reformat paragraphs.
for i, _ in enumerate(par):
par[i] = textwrap.fill(
par[i], width,
initial_indent=' ' * pad[i], subsequent_indent=' ' * pad_hard[i],
)
return '\n'.join(par) + end

View file

@ -0,0 +1,69 @@
#!/usr/bin/env bash
. "$(dirname "$0")/../test.sh"
t() { test_command mri_synthmorph "$@" ; }
# image interpolation
t apply affine.lta moving.mgz out.mgz
compare_vol out.mgz affine.mgz --thresh 0.02 --res-thresh 1e-3 --geo-thresh 1e-3
# NIfTI format
t apply affine.lta moving.mgz out.nii.gz
compare_vol out.nii.gz affine.mgz --thresh 0.02 --res-thresh 1e-3 --geo-thresh 1e-3
# dense transform
t apply identity.mgz moving.mgz out.mgz -t uint8
compare_vol out.mgz moving.mgz --res-thresh 1e-3 --geo-thresh 1e-3
# matrix update
t apply -H rigid.lta -t uint8 moving.mgz out.mgz
compare_vol out.mgz header.mgz --res-thresh 1e-3 --geo-thresh 1e-3
# label interpolation
t apply -m nearest -t uint8 affine.lta labels.moving.mgz out.mgz
compare_vol out.mgz labels.affine.mgz --res-thresh 1e-3 --geo-thresh 1e-3
# multiple input pairs
t apply rigid.lta moving.mgz out_1.mgz moving.mgz out_2.mgz
compare_vol out_1.mgz rigid.mgz --thresh 0.02 --res-thresh 1e-3 --geo-thresh 1e-3
compare_vol out_2.mgz rigid.mgz --thresh 0.02 --res-thresh 1e-3 --geo-thresh 1e-3
# data types, command abbreviation
t a -t uint8 rigid.lta moving.mgz out.mgz
run_comparison mri_info --type out.mgz | grep -Fx uchar
t ap -t uint16 rigid.lta moving.mgz out.mgz
run_comparison mri_info --type out.mgz | grep -Fx ushrt
t app -t int16 rigid.lta moving.mgz out.mgz
run_comparison mri_info --type out.mgz | grep -Fx short
t appl -t int32 rigid.lta moving.mgz out.mgz
run_comparison mri_info --type out.mgz | grep -Fx int
t apply -t float32 rigid.lta moving.mgz out.mgz
run_comparison mri_info --type out.mgz | grep -Fx float
# method abbreviation
FSTEST_NO_DATA_RESET=1
for method in l li lin line linea linear n ne nea near neare neares nearest; do
t apply -m "$method" rigid.lta moving.mgz out.mgz
done
# usage, help
t
t -h
t apply
t apply -h
# NIfTI warp
FSTEST_NO_DATA_RESET=1
mri_convert identity.mgz identity.nii.gz
t apply identity.nii.gz moving.mgz out.mgz -t uint8
compare_vol out.mgz moving.mgz --res-thresh 1e-3 --geo-thresh 1e-3
# illegal arguments
EXPECT_FAILURE=1
t slice
t apply -H identity.mgz moving.mgz fixed.mgz
t apply affine.lta moving.mgz out.mgz odd-number-of-io-pairs.mgz

View file

@ -0,0 +1,83 @@
#!/usr/bin/env bash
. "$(dirname "$0")/../test.sh"
t() { test_command mri_synthmorph "$@" ; }
# affine registration
t -m affine -o out.mgz moving.mgz fixed.mgz
compare_vol out.mgz affine.mgz --thresh 0.02
# affine symmetry
t -m aff -O out.mgz fixed.mgz moving.mgz
compare_vol out.mgz affine.mgz --thresh 0.02
# affine inverse consistency
t -m a -t out.lta -T inv.lta moving.mgz fixed.mgz
lta_diff out.lta inv.lta --invert2 | awk 'END {print $0; exit !($0<1e-3)}'
# output directory creation
t -ma -d outputs fixed.mgz moving.mgz
[ -d outputs ]
# rigid inverse consistency
t -m rigid -t out.lta -T inv.lta moving.mgz fixed.mgz
lta_diff out.lta inv.lta --invert2 | awk 'END {print $0; exit !($0<1e-3)}'
# rigid registration
t -m rig -o out.mgz moving.mgz fixed.mgz
compare_vol out.mgz rigid.mgz --thresh 0.02
# geometry update
t -m r -Ho out.mgz moving.mgz fixed.mgz
compare_vol out.mgz header.mgz --thresh 0.02 --res-thresh 1e-3 --geo-thresh 1e-3
# deformable registration with initialization
t -m deform -i affine.lta -o out_1.mgz -O out_2.mgz moving.mgz fixed.mgz
compare_vol out_1.mgz deform_1.mgz --thresh 0.02
compare_vol out_2.mgz deform_2.mgz --thresh 0.02
# deformable registration with mid-space initialization
t -m def -Mi affine.lta -o out.nii.gz moving.mgz fixed.mgz
compare_vol out.nii.gz deform_mid.nii.gz --thresh 0.02 --geo-thresh 1e-4
# joint registration
t -m joint -o out.mgz moving.mgz fixed.mgz
compare_vol out.mgz joint.mgz --thresh 0.02
# default model
t -o out.mgz moving.mgz fixed.mgz
compare_vol out.mgz joint.mgz --thresh 0.02
# help, usage, command abbreviation
FSTEST_NO_DATA_RESET=1
t register -h
for cmd in r re reg regi regis registe register; do
t "$cmd"
done
# deformable flags, explicit command
t register moving.mgz fixed.mgz -md -j16 -e256 -n7 -r0.5
t register moving.mgz fixed.mgz -m j -j 16 -e 192 -n 5 -r 0.7
# NIfTI warps
FSTEST_NO_DATA_RESET=1
mri_convert=$(find_path $FSTEST_CWD mri_convert/mri_convert)
t -t out.nii.gz moving.mgz fixed.mgz
test_command $mri_convert -odt float -at out.nii.gz moving.mgz out.mgz
compare_vol out.mgz joint.mgz --thresh 1
# displacements in RAS space
mri_warp_convert=$(find_path $FSTEST_CWD mri_warp_convert/mri_warp_convert)
test_command $mri_warp_convert -g moving.mgz --inras out.nii.gz --outmgzwarp out.mgz
test_command $mri_convert -odt float -at out.mgz moving.mgz moved.mgz
compare_vol moved.mgz joint.mgz --thresh 1 --geo-thresh 1e-4
# illegal arguments
EXPECT_FAILURE=1
t moving.mgz fixed.mgz -m banana
t moving.mgz fixed.mgz -e 1
t moving.mgz fixed.mgz -n 4
t moving.mgz fixed.mgz -r 0
t moving.mgz fixed.mgz -r 1
t moving.mgz fixed.mgz -Hm deform
t moving.mgz fixed.mgz -Hm joint

4
src/conda-create.sh Normal file
View file

@ -0,0 +1,4 @@
conda deactivate
conda create -y -n 25reg -c conda-forge simpleitk tensorflow-gpu
conda activate 25reg
pip install -r requirements.txt

376
src/g4synthmorph.py Normal file
View file

@ -0,0 +1,376 @@
'''
Use SynthMorph to register G4 images
https://download-directory.github.io/
https://github.com/freesurfer/freesurfer/tree/dev/mri_synthmorph
XLA_FLAGS=--xla_gpu_cuda_data_dir=/home/xfr/.conda/envs/25reg time ./mri_synthmorph -m affine -o ../test.nii.gz -g '/mnt/1218/Public/dataset2/M6/ZYRGTRKJ/20230728/MR/3D_SAG_T1_MPRAGE_+C_MPR_Tra_20230728143005_14.nii.gz' '/mnt/1218/Public/dataset2/M6/ZYRGTRKJ/20230728/CT/1.1_CyberKnife_head(MAR)_20230728111920_3.nii.gz'
XLA_FLAGS=--xla_gpu_cuda_data_dir=/home/xfr/.conda/envs/25reg time mri_synthmorph/mri_synthmorph -m affine -o affine.nii.gz -g moving.nii.gz clipped.nii.gz
'''
from pathlib import Path
import argparse
import logging
import json
import os
# import pathlib
import shelve
import shutil
import time
from skimage.metrics import normalized_mutual_information
import filelock
import matplotlib.pyplot as plt
import numpy as np
import SimpleITK as sitk
from mri_synthmorph.synthmorph import registration
# from synthmorph import registration
import surfa as sf
PATIENTS_ROOT = '/mnt/1220/Public/dataset2/G4'
OUT_ROOT = '/mnt/1220/Public/dataset2/G4-synthmorph'
SHELVE = os.path.join(OUT_ROOT, '0shelve')
MAX_Y = 256
SIZE_X = 249
SIZE_Y = 249
SIZE_Z = 192
# SIZE_Z = 256
MIN_OVERLAP = 0.50
MIN_METRIC = -0.50
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(),
logging.FileHandler('g4synthmorph.log')
]
)
logger = logging.getLogger(__name__)
# def resize_with_crop_or_pad(image, tx = SIZE_X, ty = SIZE_Y, tz = SIZE_Z):
def resize_with_pad(image, tx = SIZE_X, ty = SIZE_Y, tz = SIZE_Z):
sx, sy, sz = image.GetSize()
l = [(tx-sx)//2,
(ty-sy)//2,
(tz-sz)//2,]
u = [tx-sx-l[0],
ty-sy-l[1],
tz-sz-l[2],
]
# print (l, u)
return sitk.ConstantPad(image, l, u)
def draw_sitk(image, d, post):
a = sitk.GetArrayFromImage(image)
s = a.shape
fig, axs = plt.subplots(1, 3)
# fig.suptitle('%dx%dx%d'%(s[2], s[1], s[0]))
axs.flat[0].imshow(a[s[0]//2,:,:], cmap='gray')
axs.flat[1].imshow(a[:,s[1]//2,:], cmap='gray')
axs.flat[2].imshow(a[:,:,s[2]//2], cmap='gray')
axs.flat[0].axis('off')
axs.flat[1].axis('off')
axs.flat[2].axis('off')
axs.flat[1].invert_yaxis()
axs.flat[2].invert_yaxis()
plt.tight_layout()
os.makedirs(d, exist_ok=True)
plt.savefig(os.path.join(d, '%dx%dx%d-%s'%(s[2],s[1],s[0],post)))
plt.close()
# exit()
def bbox2_3D(img):
r = np.any(img, axis=(1, 2))
c = np.any(img, axis=(0, 2))
z = np.any(img, axis=(0, 1))
if not np.any(r):
return -1, -1, -1, -1, -1, -1
rmin, rmax = np.where(r)[0][[0, -1]]
cmin, cmax = np.where(c)[0][[0, -1]]
zmin, zmax = np.where(z)[0][[0, -1]]
return rmin, rmax, cmin, cmax, zmin, zmax
'''
Namespace(command='register', moving='/nn/7295866/20250127/nii/7_3D_SAG_T1_MPRAGE_+C_20250127132612_100.nii.gz', fixed='/123/onlylian/0/tmpgp96622o/clipped.nii.gz',
model='joint', out_moving='/123/onlylian/0/tmpgp96622o/joint.nii.gz', out_fixed='/123/onlylian/0/tmpgp96622o/out_fixed-joint.nii.gz',
header_only=False, trans='/123/onlylian/0/tmpgp96622o/moving_to_fixed-joint.nii.gz', inverse='/123/onlylian/0/tmpgp96622o/fixed_to_moving-joint.nii.gz',
init=None, mid_space=False, threads=None, gpu=True, hyper=0.5, steps=7, extent=256, weights=None, verbose=False, out_dir=None)
'''
def register(ct0, ct1, moving, out_root):
FREESURFER_HOME = '/mnt/1218/Public/packages/freesurfer-8.0.0-beta/'
# out_root = Path(ct0).resolve().parent/os.path.basename(mr).replace('.nii.gz','')
# print(out_root)
modality = os.path.basename(out_root)
# exit()
out_root = Path(out_root)/os.path.basename(moving).replace('.nii.gz','')
out_root.mkdir(exist_ok=True)
logger.info(' '.join((ct0, ct1, moving, str(out_root))))
orig = sf.load_volume(moving)
if modality == 'CT':
clipped = out_root/'clipped.nii.gz'
cl = orig.clip(0, 80)
cl.save(clipped)
MODELS = [
'rigid',
# 'affine',
# 'joint',
]
else:
clipped = moving
MODELS = [
'rigid',
'affine',
'joint',
]
# exit()
default = {
'command': 'register',
'header_only': False,
'init': None,
'mid_space': False,
'threads': None,
# 'gpu': False,
'gpu': True,
'verbose': False,
# 'verbose': True,
'hyper': 0.5,
'steps': 7,
'extent': 256,
'weights': None,
# 'model': 'affine',
# 'out_dir': None,
# 'out_fixed': 'out_fixed.nii.gz',
# 'out_moving': 'out_moving.nii.gz',
# 'trans': None,
# 'inverse': None,
'out_fixed': None,
'out_moving': None,
'trans': None,
'inverse': None,
'moving' : clipped,
'fixed' : ct1,
# 'weights': str(Path(__file__).resolve().parent/'mri_synthmorph/models/synthmorph.affine.2.h5'),
}
os.environ["FREESURFER_HOME"] = FREESURFER_HOME
os.environ["XLA_FLAGS"] = '--xla_gpu_cuda_data_dir=%s'% os.environ["CONDA_PREFIX"]
for m in MODELS:
default['model'] = m
default['out_dir'] = out_root/m
# if m in ('affine', 'rigid'):
# default['trans'] = 'trans.lta'
# default['inverse'] = 'inverse.lta'
# else:
# default['trans'] = 'trans.nii.gz'
# default['inverse'] = 'inverse.nii.gz'
arg=argparse.Namespace(**default)
# CONDA_PREFIX=/home/xfr/.conda/envs/25reg
# XLA_FLAGS=--xla_gpu_cuda_data_dir=/path/to/cuda
logger.info('registering %s'%m)
registration.register(arg)
logger.info('registered %s'%m)
if m in (
'rigid',
'affine',
'joint',
):
# which = 'affine' if arg.trans.endswith('.lta') else 'warp'
out = out_root/('%s.nii.gz'%m)
if m in ['affine', 'rigid']:
trans = sf.load_affine(default['out_dir']/'tra_1.lta')
prop = dict(method='linear', resample=True, fill=0)
orig.transform(trans, **prop).save(out)
logger.info('transformed %s'%out)
else:
# need to resample before transform in warp, too complicated, just copy it
# trans1 = default['out_dir']/'tra_1.nii.gz'
# trans = sf.load_warp(trans1)
shutil.copy(default['out_dir']/'out_1.nii.gz', out)
logger.info('copied %s'% out)
with open(out_root/'metric.txt', 'w') as f_metrics:
for m in MODELS:
out1 = sf.load_volume(out_root/m/'out_1.nii.gz').data
inp2 = sf.load_volume(out_root/m/'inp_2.nii.gz').data
met = normalized_mutual_information(out1, inp2)
f_metrics.write('%s\t%f\n'%(m, met))
return out_root
def check(epath):
registered = 0
for root, dirs, files in os.walk(epath):
dirs.sort()
RT_DIR = os.path.join(root, 'RT')
ORGAN_DIR = os.path.join(RT_DIR, 'ORGAN')
if not os.path.isdir(ORGAN_DIR):
continue
# if there is no eye, it's no a brain image
eye = None
organs = sorted(os.scandir(ORGAN_DIR), key=lambda e: e.name)
for o in organs:
if 'eye' in o.name.lower():
eye = o
if eye is None:
logger.info('no eye... skip ' + root)
# exit()
return None
ct_image = os.path.join(RT_DIR, 'ct_image.nii.gz')
outdir = os.path.join(OUT_ROOT, os.path.relpath(root, PATIENTS_ROOT))
logger.info(outdir)
os.makedirs(outdir, exist_ok=True)
# ct0_nii = os.path.join(outdir, 'ct0.nii.gz')
ct1_nii = os.path.join(outdir, 'clipped.nii.gz')
# shutil.copy(ct_image, ct0_nii)
ct = sf.load_volume(ct_image)
clipped = ct.clip(0, 80)
clipped.save(ct1_nii)
for root2, dirs2, files2 in os.walk(root):
dirs2.sort()
outdir = os.path.join(OUT_ROOT, os.path.relpath(root2, PATIENTS_ROOT))
if root2.endswith('RT'):
modality = 'RT'
logger.info('copying %s %s' %(root2, outdir))
shutil.copytree(root2, outdir)
# exit()
continue
skip = (root2==root) or ('RT' in root2.split('/'))
if skip:
continue
if root2.endswith('CT'):
modality = 'CT'
else:
modality = 'other'
logger.info(' '.join([str(skip), root2, modality]))
outdir = os.path.join(OUT_ROOT, os.path.relpath(root2, PATIENTS_ROOT))
os.makedirs(outdir, exist_ok=True)
for e in sorted(os.scandir(root2), key=lambda e: e.name):
if not e.name.endswith('.nii.gz'):
continue
if '_RTDOSE_' in e.name:
continue
if '_DTI_' in e.name:
continue
if '_ROI1.' in e.name:
continue
OUT_IMG = os.path.join(outdir, e.name)
if os.path.isfile(OUT_IMG):
logger.info('skip '+ OUT_IMG)
continue
logger.info(' '.join([e.name, e.path]))
moving = e.path
register(ct_image, ct1_nii, moving, outdir)
registered += 1
# exit()
# exit()
return registered
def main():
# check('/mnt/1220/Public/dataset2/G4/3L6LOEER') # bad registration
# exit()
EXCLUDE = (
# 'LLUQJUY4', #cervical
)
os.makedirs(OUT_ROOT, exist_ok=True)
LOCK_DIR = os.path.join(OUT_ROOT, '0lock')
os.makedirs(LOCK_DIR, exist_ok=True)
for e in sorted(os.scandir(PATIENTS_ROOT), key=lambda e: e.name):
if e.is_dir():
d = shelve.open(SHELVE)
if e.name in d or e.name in EXCLUDE:
logger.info('skip '+ e.name)
d.close()
continue
d.close()
lock_path = os.path.join(LOCK_DIR, '%s.lock'%e.name)
lock = filelock.FileLock(lock_path, timeout=1)
try:
lock.acquire()
except:
logger.info(lock_path + ' locked')
continue
ret = check(e.path)
lock.release()
# exit()
d = shelve.open(SHELVE)
d[e.name] = ret
d.close()
if __name__ == '__main__':
main()

1
src/mri_synthmorph Symbolic link
View file

@ -0,0 +1 @@
../mri_synthmorph/

6
src/requirements.txt Normal file
View file

@ -0,0 +1,6 @@
filelock
git+https://github.com/adalca/neurite.git
git+https://github.com/freesurfer/surfa.git
git+https://github.com/voxelmorph/voxelmorph.git

1
src/synthmorph Symbolic link
View file

@ -0,0 +1 @@
../mri_synthmorph/synthmorph

59
src/synthmorph-ct.py Normal file
View file

@ -0,0 +1,59 @@
'''
conda create -n voxelmorph -c conda-forge python simpleitk tensorflow-gpu
conda create -n voxelmorph -c conda-forge "python<3.11" simpleitk "tensorflow-gpu<2.16"
conda activate voxelmorph
pip install git+https://github.com/adalca/neurite.git git+https://github.com/freesurfer/surfa.git git+https://github.com/voxelmorph/voxelmorph.git
wget https://raw.githubusercontent.com/freesurfer/freesurfer/dev/mri_synthmorph/mri_synthmorph
wget https://surfer.nmr.mgh.harvard.edu/docs/synthmorph/synthmorph.affine.2.h5
export FREESURFER_HOME=/home/xfr/git9/Taipei-1/trials
time ./mri_synthmorph -m affine -t trans.lta '/mnt/1218/Public/dataset2/M6/ZYRGTRKJ/20230728/MR/3D_SAG_T1_MPRAGE_+C_MPR_Tra_20230728143005_14.nii.gz' clipped.nii.gz
#### https://github.com/freesurfer/freesurfer/tree/dev/mri_synthmorph
conda create -n synthmorph -c conda-forge -c nvidia python=3.11 simpleitk tensorflow-gpu=2.17 cuda-nvcc
conda create -y -n synthmorph -c conda-forge -c nvidia python simpleitk tensorflow-gpu cuda-nvcc
conda activate synthmorph
pip install git+https://github.com/adalca/neurite.git git+https://github.com/freesurfer/surfa.git git+https://github.com/voxelmorph/voxelmorph.git
XLA_FLAGS=--xla_gpu_cuda_data_dir=/home/xfr/.conda/envs/synthmorph time ./mri_synthmorph -m affine -t trans.lta -o out-aff.nii.gz moving.nii.gz clipped.nii.gz -g
XLA_FLAGS=--xla_gpu_cuda_data_dir=/home/xfr/.conda/envs/synthmorph time ./mri_synthmorph -o out.nii.gz moving.nii.gz clipped.nii.gz -g
export FREESURFER_HOME=/mnt/1218/Public/packages/freesurfer-7.4.1
export FREESURFER_HOME=/mnt/1218/Public/packages/freesurfer-8.0.0-beta
source $FREESURFER_HOME/SetUpFreeSurfer.sh
time mri_synthmorph -m affine -t trans.lta -o out-aff.nii.gz moving.nii.gz clipped.nii.gz -g
time mri_easyreg --ref clipped.nii.gz --flo moving.nii.gz
./cuda_sdk_lib
/home/conda/feedstock_root/build_artifacts/tensorflow-split_1729095706337/_build_env/targets/x86_64-linux
/usr/local/cuda
/home/xfr/.conda/envs/synthmorph/lib/python3.11/site-packages/tensorflow/python/platform/../../../nvidia/cuda_nvcc
/home/xfr/.conda/envs/synthmorph/lib/python3.11/site-packages/tensorflow/python/platform/../../../../nvidia/cuda_nvcc
Less than 10 seconds!!!
'''
fi = '/nn/7295866/20250127/nii/a_1.1_CyberKnife_head(MAR)_20250127111447_5.nii.gz'
mv = '/nn/7295866/20250127/nii/7_3D_SAG_T1_MPRAGE_+C_20250127132612_100.nii.gz'
fi = '/mnt/1218/Public/dataset2/M6/ZYRGTRKJ/20230728/CT/1.1_CyberKnife_head(MAR)_20230728111920_3.nii.gz'
mv = '/mnt/1218/Public/dataset2/M6/ZYRGTRKJ/20230728/MR/3D_SAG_T1_MPRAGE_+C_MPR_Tra_20230728143005_14.nii.gz'
import shutil
import surfa as sf
ct = sf.load_volume(fi)
clipped = ct.clip(0, 80)
clipped.save('../clipped.nii.gz')
shutil.copy(mv, '../moving.nii.gz')