diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index f86aba8..9c62237 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -3,14 +3,14 @@ name: Build & test on: push: branches: - - main + - main tags: - - "*" + - "*" pull_request: branches: - - main + - main schedule: - - cron: '0 0 * * 1' + - cron: '0 0 * * 1' defaults: run: @@ -31,71 +31,71 @@ jobs: commit_message: ${{ steps.get_commit_message.outputs.commit_message }} version: ${{ steps.show_version.outputs.version }} steps: - - name: Checkout - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - name: Print head git commit message - id: get_commit_message - run: | - if [[ -z "$COMMIT_MSG" ]]; then - COMMIT_MSG=$(git show -s --format=%s $REF) - fi - echo commit_message=$COMMIT_MSG | tee -a $GITHUB_OUTPUT - env: - COMMIT_MSG: ${{ github.event.head_commit.message }} - REF: ${{ github.event.pull_request.head.sha }} - - name: Detect version - id: show_version - run: | - if [[ "$GITHUB_REF" == refs/tags/* ]]; then - VERSION=${GITHUB_REF##*/} - else - pipx run hatch version # Once to avoid output of initial setup - VERSION=$( pipx run hatch version ) - fi - echo version=$VERSION | tee -a $GITHUB_OUTPUT + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Print head git commit message + id: get_commit_message + run: | + if [[ -z "$COMMIT_MSG" ]]; then + COMMIT_MSG=$(git show -s --format=%s $REF) + fi + echo commit_message=$COMMIT_MSG | tee -a $GITHUB_OUTPUT + env: + COMMIT_MSG: ${{ github.event.head_commit.message }} + REF: ${{ github.event.pull_request.head.sha }} + - name: Detect version + id: show_version + run: | + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + VERSION=${GITHUB_REF##*/} + else + pipx run hatch version # Once to avoid output of initial setup + VERSION=$( pipx run hatch version ) + fi + echo version=$VERSION | tee -a $GITHUB_OUTPUT build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - name: Setup Python - uses: actions/setup-python@v4 - with: - python-version: 3 - - name: Display Python information - run: python -c "import sys; print(sys.version)" - - name: Build sdist and wheel - run: pipx run build - - name: Check release tools - run: pipx run twine check dist/* - - name: Save build output - uses: actions/upload-artifact@v3 - with: - name: dist - path: dist/ + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: 3 + - name: Display Python information + run: python -c "import sys; print(sys.version)" + - name: Build sdist and wheel + run: pipx run build + - name: Check release tools + run: pipx run twine check dist/* + - name: Save build output + uses: actions/upload-artifact@v3 + with: + name: dist + path: dist/ test: needs: [job_metadata, build] runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] install: [repo] include: - - python-version: "3.11" - install: sdist - - python-version: "3.11" - install: wheel - - python-version: "3.11" - install: editable + - python-version: "3.12" + install: sdist + - python-version: "3.12" + install: wheel + - python-version: "3.12" + install: editable env: INSTALL_TYPE: ${{ matrix.install }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 if: matrix.install == 'repo' || matrix.install == 'editable' with: fetch-depth: 0 @@ -138,11 +138,11 @@ jobs: needs: test if: github.repository == 'nipreps/synthstrip' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') steps: - - uses: actions/download-artifact@v3 - with: - name: dist - path: dist/ - - uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} + - uses: actions/download-artifact@v3 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml new file mode 100644 index 0000000..3372ab0 --- /dev/null +++ b/.github/workflows/style.yml @@ -0,0 +1,40 @@ +name: Contribution checks + +on: + push: + branches: + - main + pull_request: + branches: + - main + +defaults: + run: + shell: bash + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + style: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pipx run ruff check . + - run: pipx run ruff format --diff . + + codespell: + name: Check for spelling errors + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Codespell + uses: codespell-project/actions-codespell@v2 + with: + exclude_file: ORIGINAL_LICENSE diff --git a/nipreps/synthstrip/__main__.py b/nipreps/synthstrip/__main__.py index 6163f49..370884c 100644 --- a/nipreps/synthstrip/__main__.py +++ b/nipreps/synthstrip/__main__.py @@ -21,12 +21,14 @@ # https://www.nipreps.org/community/licensing/ # -if __name__ == "__main__": +if __name__ == '__main__': import sys + from nipreps.synthstrip.cli import main + from . import __name__ as module # `python -m ` typically displays the command as __main__.py - if "__main__.py" in sys.argv[0]: - sys.argv[0] = "%s -m %s" % (sys.executable, module) + if '__main__.py' in sys.argv[0]: + sys.argv[0] = f'{sys.executable} -m {module}' main() diff --git a/nipreps/synthstrip/cli.py b/nipreps/synthstrip/cli.py index a4681c0..ee42b7d 100644 --- a/nipreps/synthstrip/cli.py +++ b/nipreps/synthstrip/cli.py @@ -49,43 +49,40 @@ def main(): """Entry point to SynthStrip.""" import os from argparse import ArgumentParser + + import nibabel as nb import numpy as np import scipy - import nibabel as nb import torch + from .model import StripModel # parse command line parser = ArgumentParser(description=__doc__) parser.add_argument( - "-i", - "--image", - metavar="file", + '-i', + '--image', + metavar='file', required=True, - help="Input image to skullstrip.", + help='Input image to skullstrip.', ) + parser.add_argument('-o', '--out', metavar='file', help='Save stripped image to path.') + parser.add_argument('-m', '--mask', metavar='file', help='Save binary brain mask to path.') + parser.add_argument('-g', '--gpu', action='store_true', help='Use the GPU.') + parser.add_argument('-n', '--num-threads', action='store', type=int, help='number of threads') parser.add_argument( - "-o", "--out", metavar="file", help="Save stripped image to path." - ) - parser.add_argument( - "-m", "--mask", metavar="file", help="Save binary brain mask to path." - ) - parser.add_argument("-g", "--gpu", action="store_true", help="Use the GPU.") - parser.add_argument( - "-n", "--num-threads", action="store", type=int, help="number of threads") - parser.add_argument( - "-b", - "--border", + '-b', + '--border', default=1, type=int, - help="Mask border threshold in mm. Default is 1.", + help='Mask border threshold in mm. Default is 1.', ) - parser.add_argument("--model", metavar="file", help="Alternative model weights.") + parser.add_argument('--model', metavar='file', help='Alternative model weights.') args = parser.parse_args() # sanity check on the inputs if not args.out and not args.mask: - parser.fatal("Must provide at least --out or --mask output flags.") + parser.fatal('Must provide at least --out or --mask output flags.') # necessary for speed gains (I think) torch.backends.cudnn.benchmark = True @@ -93,19 +90,19 @@ def main(): # configure GPU device if args.gpu: - os.environ["CUDA_VISIBLE_DEVICES"] = "0" - device = torch.device("cuda") - device_name = "GPU" + os.environ['CUDA_VISIBLE_DEVICES'] = '0' + device = torch.device('cuda') + device_name = 'GPU' else: - os.environ["CUDA_VISIBLE_DEVICES"] = "-1" - device = torch.device("cpu") - device_name = "CPU" + os.environ['CUDA_VISIBLE_DEVICES'] = '-1' + device = torch.device('cpu') + device_name = 'CPU' if args.num_threads and args.num_threads > 0: torch.set_num_threads(args.num_threads) # configure model - print(f"Configuring model on the {device_name}") + print(f'Configuring model on the {device_name}') with torch.no_grad(): model = StripModel() @@ -115,20 +112,20 @@ def main(): # load model weights if args.model is not None: modelfile = args.model - print("Using custom model weights") + print('Using custom model weights') else: - raise RuntimeError("A model must be provided.") + raise RuntimeError('A model must be provided.') checkpoint = torch.load(modelfile, map_location=device) - model.load_state_dict(checkpoint["model_state_dict"]) + model.load_state_dict(checkpoint['model_state_dict']) # load input volume - print(f"Input image read from: {args.image}") + print(f'Input image read from: {args.image}') # normalize intensities image = nb.load(args.image) conformed = conform(image) - in_data = conformed.get_fdata(dtype="float32") + in_data = conformed.get_fdata(dtype='float32') in_data -= in_data.min() in_data = np.clip(in_data / np.percentile(in_data, 99), 0, 1) in_data = in_data[np.newaxis, np.newaxis] @@ -142,10 +139,10 @@ def main(): sdt_target = resample_like( nb.Nifti1Image(sdt, conformed.affine, None), image, - output_dtype="int16", + output_dtype='int16', cval=100, ) - sdt_data = np.asanyarray(sdt_target.dataobj).astype("int16") + sdt_data = np.asanyarray(sdt_target.dataobj).astype('int16') # find largest CC (just do this to be safe for now) components = scipy.ndimage.label(sdt_data.squeeze() < args.border)[0] @@ -161,25 +158,25 @@ def main(): nb.Nifti1Image(img_data, image.affine, image.header).to_filename( args.out, ) - print(f"Masked image saved to: {args.out}") + print(f'Masked image saved to: {args.out}') # write the brain mask if args.mask: hdr = image.header.copy() - hdr.set_data_dtype("uint8") + hdr.set_data_dtype('uint8') nb.Nifti1Image(mask, image.affine, hdr).to_filename(args.mask) - print(f"Binary brain mask saved to: {args.mask}") + print(f'Binary brain mask saved to: {args.mask}') - print("If you use SynthStrip in your analysis, please cite:") - print("----------------------------------------------------") - print("SynthStrip: Skull-Stripping for Any Brain Image.") - print("A Hoopes, JS Mora, AV Dalca, B Fischl, M Hoffmann.") + print('If you use SynthStrip in your analysis, please cite:') + print('----------------------------------------------------') + print('SynthStrip: Skull-Stripping for Any Brain Image.') + print('A Hoopes, JS Mora, AV Dalca, B Fischl, M Hoffmann.') def conform(input_nii): """Resample image as SynthStrip likes it.""" - import numpy as np import nibabel as nb + import numpy as np from nitransforms.linear import Affine shape = np.array(input_nii.shape[:3]) @@ -199,10 +196,7 @@ def conform(input_nii): ) # Get corner voxel centers in mm - corners_xyz = ( - affine - @ np.hstack((corner_centers_ijk, np.ones((len(corner_centers_ijk), 1)))).T - ) + corners_xyz = affine @ np.hstack((corner_centers_ijk, np.ones((len(corner_centers_ijk), 1)))).T # Target affine is 1mm voxels in LIA orientation target_affine = np.diag([-1.0, 1.0, -1.0, 1.0])[:, (0, 2, 1, 3)] @@ -212,9 +206,7 @@ def conform(input_nii): target_shape = ((extent[1] - extent[0]) / 1.0 + 0.999).astype(int) # SynthStrip likes dimensions be multiple of 64 (192, 256, or 320) - target_shape = np.clip( - np.ceil(np.array(target_shape) / 64).astype(int) * 64, 192, 320 - ) + target_shape = np.clip(np.ceil(np.array(target_shape) / 64).astype(int) * 64, 192, 320) # Ensure shape ordering is LIA too target_shape[2], target_shape[1] = target_shape[1:3] @@ -239,5 +231,5 @@ def resample_like(image, target, output_dtype=None, cval=0): return Affine(reference=target).apply(image, output_dtype=output_dtype, cval=cval) -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/nipreps/synthstrip/model.py b/nipreps/synthstrip/model.py index 075a56f..3081d2d 100644 --- a/nipreps/synthstrip/model.py +++ b/nipreps/synthstrip/model.py @@ -44,9 +44,9 @@ """ +import numpy as np import torch import torch.nn as nn -import numpy as np class StripModel(nn.Module): @@ -60,7 +60,6 @@ def __init__( max_pool=2, return_mask=False, ): - super().__init__() # dimensionality @@ -69,19 +68,15 @@ def __init__( # build feature list automatically if isinstance(nb_features, int): if nb_levels is None: - raise ValueError( - "must provide unet nb_levels if nb_features is an integer" - ) - feats = np.round(nb_features * feat_mult ** np.arange(nb_levels)).astype( - int - ) + raise ValueError('must provide unet nb_levels if nb_features is an integer') + feats = np.round(nb_features * feat_mult ** np.arange(nb_levels)).astype(int) feats = np.clip(feats, 1, max_features) nb_features = [ np.repeat(feats[:-1], nb_conv_per_level), np.repeat(np.flip(feats), nb_conv_per_level), ] elif nb_levels is not None: - raise ValueError("cannot use nb_levels if nb_features is not an integer") + raise ValueError('cannot use nb_levels if nb_features is not an integer') # extract any surplus (full resolution) decoder convolutions enc_nf, dec_nf = nb_features @@ -94,11 +89,9 @@ def __init__( max_pool = [max_pool] * self.nb_levels # cache downsampling / upsampling operations - MaxPooling = getattr(nn, "MaxPool%dd" % ndims) + MaxPooling = getattr(nn, 'MaxPool%dd' % ndims) self.pooling = [MaxPooling(s) for s in max_pool] - self.upsampling = [ - nn.Upsample(scale_factor=s, mode="nearest") for s in max_pool - ] + self.upsampling = [nn.Upsample(scale_factor=s, mode='nearest') for s in max_pool] # configure encoder (down-sampling path) prev_nf = 1 @@ -128,7 +121,7 @@ def __init__( # now we take care of any remaining convolutions self.remaining = nn.ModuleList() - for num, nf in enumerate(final_convs): + for nf in final_convs: self.remaining.append(ConvBlock(ndims, prev_nf, nf)) prev_nf = nf @@ -140,7 +133,6 @@ def __init__( self.remaining.append(ConvBlock(ndims, prev_nf, 1, activation=None)) def forward(self, x): - # encoder forward pass x_history = [x] for level, convs in enumerate(self.encoder): @@ -169,17 +161,17 @@ class ConvBlock(nn.Module): Specific convolutional block followed by leakyrelu for unet. """ - def __init__(self, ndims, in_channels, out_channels, stride=1, activation="leaky"): + def __init__(self, ndims, in_channels, out_channels, stride=1, activation='leaky'): super().__init__() - Conv = getattr(nn, "Conv%dd" % ndims) + Conv = getattr(nn, 'Conv%dd' % ndims) self.conv = Conv(in_channels, out_channels, 3, stride, 1) - if activation == "leaky": + if activation == 'leaky': self.activation = nn.LeakyReLU(0.2) elif activation is None: self.activation = None else: - raise ValueError(f"Unknown activation: {activation}") + raise ValueError(f'Unknown activation: {activation}') def forward(self, x): out = self.conv(x) diff --git a/nipreps/synthstrip/wrappers/nipype.py b/nipreps/synthstrip/wrappers/nipype.py index 0843d12..2362916 100644 --- a/nipreps/synthstrip/wrappers/nipype.py +++ b/nipreps/synthstrip/wrappers/nipype.py @@ -21,19 +21,21 @@ # https://www.nipreps.org/community/licensing/ # """SynthStrip interface.""" + import os from pathlib import Path + from nipype.interfaces.base import ( CommandLine, CommandLineInputSpec, File, TraitedSpec, - traits, Undefined, + traits, ) -_fs_home = os.getenv("FREESURFER_HOME", None) -_default_model_path = Path(_fs_home) / "models" / "synthstrip.1.pt" if _fs_home else Undefined +_fs_home = os.getenv('FREESURFER_HOME', None) +_default_model_path = Path(_fs_home) / 'models' / 'synthstrip.1.pt' if _fs_home else Undefined if _fs_home and not _default_model_path.exists(): _default_model_path = Undefined @@ -43,43 +45,39 @@ class _SynthStripInputSpec(CommandLineInputSpec): in_file = File( exists=True, mandatory=True, - argstr="-i %s", - desc="Input image to be brain extracted", - ) - use_gpu = traits.Bool( - False, usedefault=True, argstr="-g", desc="Use GPU", nohash=True + argstr='-i %s', + desc='Input image to be brain extracted', ) + use_gpu = traits.Bool(False, usedefault=True, argstr='-g', desc='Use GPU', nohash=True) model = File( str(_default_model_path), usedefault=True, exists=True, - argstr="--model %s", + argstr='--model %s', desc="file containing model's weights", ) - border_mm = traits.Int( - 1, usedefault=True, argstr="-b %d", desc="Mask border threshold in mm" - ) + border_mm = traits.Int(1, usedefault=True, argstr='-b %d', desc='Mask border threshold in mm') out_file = File( - name_source=["in_file"], - name_template="%s_desc-brain.nii.gz", - argstr="-o %s", - desc="store brain-extracted input to file", + name_source=['in_file'], + name_template='%s_desc-brain.nii.gz', + argstr='-o %s', + desc='store brain-extracted input to file', ) out_mask = File( - name_source=["in_file"], - name_template="%s_desc-brain_mask.nii.gz", - argstr="-m %s", - desc="store brainmask to file", + name_source=['in_file'], + name_template='%s_desc-brain_mask.nii.gz', + argstr='-m %s', + desc='store brainmask to file', ) - num_threads = traits.Int(desc="Number of threads", argstr="-n %d", nohash=True) + num_threads = traits.Int(desc='Number of threads', argstr='-n %d', nohash=True) class _SynthStripOutputSpec(TraitedSpec): - out_file = File(desc="brain-extracted image") - out_mask = File(desc="brain mask") + out_file = File(desc='brain-extracted image') + out_mask = File(desc='brain mask') class SynthStrip(CommandLine): - _cmd = "nipreps-synthstrip" + _cmd = 'nipreps-synthstrip' input_spec = _SynthStripInputSpec output_spec = _SynthStripOutputSpec diff --git a/nipreps/synthstrip/wrappers/pydra.py b/nipreps/synthstrip/wrappers/pydra.py index 2109186..f6c8b90 100644 --- a/nipreps/synthstrip/wrappers/pydra.py +++ b/nipreps/synthstrip/wrappers/pydra.py @@ -23,12 +23,13 @@ """SynthStrip interface.""" import os -import attr from pathlib import Path + +import attr import pydra -_fs_home = os.getenv("FREESURFER_HOME", None) -_default_model_path = Path(_fs_home) / "models" / "synthstrip.1.pt" if _fs_home else None +_fs_home = os.getenv('FREESURFER_HOME', None) +_default_model_path = Path(_fs_home) / 'models' / 'synthstrip.1.pt' if _fs_home else None if _fs_home and not _default_model_path.exists(): _default_model_path = None @@ -41,7 +42,7 @@ attr.ib( type=str, metadata={ - 'argstr': "-i", + 'argstr': '-i', 'help_string': 'Input image to skullstrip', 'mandatory': True, }, @@ -51,18 +52,18 @@ 'out_file', str, { - 'argstr': "-o", - "help_string": "Save stripped image to path", - "output_file_template": "{in_file}_desc-brain.nii.gz", + 'argstr': '-o', + 'help_string': 'Save stripped image to path', + 'output_file_template': '{in_file}_desc-brain.nii.gz', }, ), ( 'out_mask', str, { - 'argstr': "-m", - "help_string": "Save binary brain mask to path", - "output_file_template": "{in_file}_desc-brain_mask.nii.gz", + 'argstr': '-m', + 'help_string': 'Save binary brain mask to path', + 'output_file_template': '{in_file}_desc-brain_mask.nii.gz', }, ), ( @@ -70,7 +71,7 @@ bool, False, { - 'argstr': "-g", + 'argstr': '-g', 'help_string': 'Use the GPU', }, ), @@ -79,8 +80,8 @@ int, 1, { - 'argstr': "-b", - "help_string": "Mask border threshold in mm", + 'argstr': '-b', + 'help_string': 'Mask border threshold in mm', }, ), ( @@ -88,7 +89,7 @@ bool, False, { - 'argstr': "--no-csf", + 'argstr': '--no-csf', 'help_string': 'Exclude CSF from brain border', }, ), @@ -97,16 +98,16 @@ pydra.specs.File, str(_default_model_path), { - 'argstr': "--model", - "help_string": "File containing model's weights", + 'argstr': '--model', + 'help_string': "File containing model's weights", }, ), ( 'num_threads', int, { - 'argstr': "-n", - "help_string": "Number of threads", + 'argstr': '-n', + 'help_string': 'Number of threads', }, ), ], @@ -114,6 +115,5 @@ ) SynthStrip = pydra.ShellCommandTask( - executable="nipreps-synthstrip", - input_spec = SynthStripInputSpec + executable='nipreps-synthstrip', input_spec=SynthStripInputSpec ) diff --git a/pyproject.toml b/pyproject.toml index 50acc95..a4cee45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] requires-python = ">=3.8" dependencies = [ @@ -36,8 +37,7 @@ Homepage = "https://github.com/nipreps/synthstrip" [project.optional-dependencies] dev = [ - "black", - "isort", + "ruff", "hatch", ] nipype = [ @@ -62,10 +62,53 @@ version-file = "nipreps/synthstrip/_version.py" [tool.hatch.build.sources] "nipreps/synthstrip" = "nipreps/synthstrip" +[tool.hatch.build.targets.wheel] +packages = ["nipreps/synthstrip"] + [tool.black] +exclude = '.*' + +[tool.ruff] line-length = 99 -target-version = ['py310'] -skip-string-normalization = true -[tool.isort] -profile = 'black' +[tool.ruff.lint] +extend-select = [ + "F", + "E", + "W", + "I", + "UP", + "YTT", + "S", + "BLE", + "B", + "A", + # "CPY", + "C4", + "DTZ", + "T10", + # "EM", + "EXE", + "FA", + "ISC", + "ICN", + "PT", + "Q", +] +ignore = [ + "S311", # We are not using random for cryptographic purposes + "ISC001", + "S603", +] + +[tool.ruff.lint.flake8-quotes] +inline-quotes = "single" + +[tool.ruff.format] +quote-style = "single" + +[tool.coverage.run] +branch = true +omit = [ + "*/_version.py" +]