From ca020978b86adcee7ef79fa4d07f43d0337466ee Mon Sep 17 00:00:00 2001 From: Yehuda Warszawer <36595323+yw7@users.noreply.github.com> Date: Sat, 5 Oct 2024 15:46:06 +0300 Subject: [PATCH 1/5] Update DOI badge and remove sacrum processing scripts Added a DOI badge to the README for better project citation. Upgraded dataset links and version in `pyproject.toml`. Removed all sacrum processing-related scripts, configuration files, and documentation, likely due to changes or simplification in how sacrum data is managed within the project. Consider reviewing how sacrum data processing is now handled or incorporated. --- README.md | 1 + pyproject.toml | 7 +- scripts/generate_sacrum_masks/README.md | 99 --- .../generate_sacrum_masks/generate_sacrum.sh | 138 ---- .../convert_config_to_nnunet.py | 200 ----- .../data_management/init_data_config.py | 105 --- totalspineseg/data_management/utils.py | 87 --- .../resources/configs/test_sacrum.json | 462 ------------ totalspineseg/utils/create_json_sidecar.py | 36 - totalspineseg/utils/image.py | 685 ------------------ .../utils/largest_component_filewise.py | 89 --- totalspineseg/utils/register_CT_to_MR.py | 85 --- totalspineseg/utils/reorient_images.py | 106 --- .../run_nnunet_inference_single_subject.py | 216 ------ 14 files changed, 4 insertions(+), 2312 deletions(-) delete mode 100644 scripts/generate_sacrum_masks/README.md delete mode 100644 scripts/generate_sacrum_masks/generate_sacrum.sh delete mode 100644 totalspineseg/data_management/convert_config_to_nnunet.py delete mode 100644 totalspineseg/data_management/init_data_config.py delete mode 100644 totalspineseg/data_management/utils.py delete mode 100644 totalspineseg/resources/configs/test_sacrum.json delete mode 100644 totalspineseg/utils/create_json_sidecar.py delete mode 100644 totalspineseg/utils/image.py delete mode 100644 totalspineseg/utils/largest_component_filewise.py delete mode 100644 totalspineseg/utils/register_CT_to_MR.py delete mode 100644 totalspineseg/utils/reorient_images.py delete mode 100644 totalspineseg/utils/run_nnunet_inference_single_subject.py diff --git a/README.md b/README.md index 906d251..a68a892 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # TotalSpineSeg +[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.13891845.svg)](https://doi.org/10.5281/zenodo.13891845) TotalSpineSeg is a tool for automatic instance segmentation of all vertebrae, intervertebral discs (IVDs), spinal cord, and spinal canal in MRI images. It is robust to various MRI contrasts, acquisition orientations, and resolutions. The model used in TotalSpineSeg is based on [nnU-Net](https://github.com/MIC-DKFZ/nnUNet) as the backbone for training and inference. diff --git a/pyproject.toml b/pyproject.toml index b17c0b3..7b2a300 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "totalspineseg" -version = "20240921" +version = "20241005" requires-python = ">=3.9" description = "TotalSpineSeg is a tool for automatic instance segmentation and labeling of all vertebrae, intervertebral discs (IVDs), spinal cord, and spinal canal in MRI images." readme = "README.md" @@ -60,9 +60,8 @@ dependencies = [ [project.urls] homepage = "https://github.com/neuropoly/totalspineseg" repository = "https://github.com/neuropoly/totalspineseg" -Dataset101_TotalSpineSeg_step1 = "https://github.com/neuropoly/totalspineseg/releases/download/r20240921/Dataset101_TotalSpineSeg_step1_r20240921.zip" -Dataset102_TotalSpineSeg_step2 = "https://github.com/neuropoly/totalspineseg/releases/download/r20240921/Dataset102_TotalSpineSeg_step2_r20240921.zip" -Dataset300_SacrumDataset = "https://github.com/neuropoly/totalspineseg/releases/download/sacrum-seg/Dataset300_SacrumDataset.zip" +Dataset101_TotalSpineSeg_step1 = "https://github.com/neuropoly/totalspineseg/releases/download/r20241005/Dataset101_TotalSpineSeg_step1_r20241005.zip" +Dataset102_TotalSpineSeg_step2 = "https://github.com/neuropoly/totalspineseg/releases/download/r20241005/Dataset102_TotalSpineSeg_step2_r20241005.zip" [project.scripts] totalspineseg = "totalspineseg.inference:main" diff --git a/scripts/generate_sacrum_masks/README.md b/scripts/generate_sacrum_masks/README.md deleted file mode 100644 index 2ef7ec9..0000000 --- a/scripts/generate_sacrum_masks/README.md +++ /dev/null @@ -1,99 +0,0 @@ -# Generate sacrum masks guides - -This file provides the different steps that were carried out to generate sacrum masks for the MRI totalspineseg project. - -More information about the context and problems can be found in this [issue](https://github.com/neuropoly/totalspineseg/issues/18). - -The main idea was to use open-source datasets with "easy to make" sacrum masks on MRI (T1w and T2w) scans to train a [nnUNetV2](https://github.com/MIC-DKFZ/nnUNet) model that will be able to segment sacrums on the whole-spine dataset and the spider dataset. - -# Training - -If you want to retrain the model, you can follow these steps. - -## I - Dowload the training datasets - -To generate the sacrum masks, 3 open-source datasets were used: - -| [GoldAtlas](https://zenodo.org/records/583096) | [SynthRAD2023](https://aapm.onlinelibrary.wiley.com/doi/full/10.1002/mp.16529) | [MRSpineSeg](https://paperswithcode.com/dataset/mrspineseg-challenge) | -| :---: | :---: | :---: | -| Screenshot 2024-01-02 at 5 10 39 AM | Screenshot 2024-01-02 at 5 10 53 AM | Screenshot 2024-01-02 at 5 11 19 AM | - -> These datasets were chosen because they had these sacrum masks available or because they had co-registered MRI and CT images that allowed us to rely on the [CT total segmentator network](https://github.com/wasserth/TotalSegmentator) to generate these labels. - -These datasets were BIDSified and stored on our internal servers: -- SynthRAD2023 (Internal access: `git@data.neuro.polymtl.ca:datasets/synthrad-challenge-2023.git`) -- MRSpineSeg (Internal access: `git@data.neuro.polymtl.ca:datasets/mrspineseg-challenge-2021.git`) -- GoldAtlas (Internal access: `git@data.neuro.polymtl.ca:datasets/goldatlas.git`) - -## II - Register CT labels to MRI - -> For this step, the https://github.com/spinalcordtoolbox/spinalcordtoolbox (SCT) was used. - -As specified before, some sacrum masks were generated using the [CT total segmentator network](https://github.com/wasserth/TotalSegmentator) but due to slightly different image shape between MRI (T1w and T2w) and CT scans (see [issue](https://github.com/neuropoly/totalspineseg/issues/18)), CT segmentations were registered to MRI space. To do that, the script `totalspineseg/utils/register_CT_seg_to_MR.py` was used on the three datasets. - -> Registration was also performed on the dataset `MRSpineSeg` due to slightly different q-form and s-form between segmentations and images. - -```bash -python "$TOTALSPINESEG/totalspineseg/utils/register_CT_to_MR.py" --path-img -``` - -## III - Generate a config file to select the data for training - -To select the data used for training, a [config file](https://github.com/spinalcordtoolbox/disc-labeling-hourglass/issues/25#issuecomment-1695818382) was used. - -First fetch the paths to all the sacrum masks that will be used for TRAINING/VALIDATION/TESTING. The datasets should be stored inside the same parent folder - -> Run this following command in the parent folder folder of the datasets. - -```bash -find ~+ -type f -name *_seg.nii.gz | grep -v CT | sort > train_sacrum.txt -``` - -Then run this command to generate the JSON config file. - -```bash -python "$TOTALSPINESEG/totalspineseg/data_management/init_data_config.py" --txt train_sacrum.txt --type LABEL --split-validation SPLIT_VAL --split-test SPLIT_TEST -``` - -With `SPLIT_VAL` the fraction of the data used for validation and `SPLIT_TEST` the fraction of the data used for testing. - -Finally, to organize your data according to nnUNetV2 format, run this last command. - -```bash -export nnUNet_raw="$TOTALSPINESEG_DATA"/nnUNet/raw -python "$TOTALSPINESEG/totalspineseg/data_management/convert_config_to_nnunet.py" --config train_sacrum.json --path-out "$nnUNet_raw" -dnum 300 -``` - -## IV - Train with nnUNetV2 - -> Regarding nnUNetV2 installation and general usage, please check https://github.com/ivadomed/utilities/blob/main/quick_start_guides/nnU-Net_quick_start_guide.md - -Now that your data is ready, you can run nnUNetV2 preprocessing - -```bash -export nnUNet_preprocessed="$TOTALSPINESEG_DATA"/nnUNet/preprocessed -export nnUNet_results="$TOTALSPINESEG_DATA"/nnUNet/results/sacrum -nnUNetv2_plan_and_preprocess -d 300 --verify_dataset_integrity -c 3d_fullres -``` - -Then train using this command - -```bash -CUDA_VISIBLE_DEVICES= nnUNetv2_train 300 3d_fullres 0 -``` - -# Inference on whole-spine and spider dataset - -To run nnUNetV2's inference, keep the largest component and store the data according to BIDS standard you must run: - -> Before running you must download the datasets `whole-spine` and `spider-challenge-2023` and update the variable `DATASETS_PATH` inside the config file `totalspineseg/resources/configs/test_sacrum.json`. -> This last path corresponds to the parent folder of the two datasets `whole-spine` and `spider-challenge-2023`. - -```bash -bash "$TOTALSPINESEG/scripts/generate_sacrum_masks/generate_sacrum.sh" -``` - - - - - diff --git a/scripts/generate_sacrum_masks/generate_sacrum.sh b/scripts/generate_sacrum_masks/generate_sacrum.sh deleted file mode 100644 index 1e2b030..0000000 --- a/scripts/generate_sacrum_masks/generate_sacrum.sh +++ /dev/null @@ -1,138 +0,0 @@ -#!/bin/bash - -# This script calls nnUNetV2's inference to generate sacrum masks using a JSON config file (see totalspineseg/ressources/configs) and saves labels following BIDS' convention. - -# The following variables and paths MUST be updated before running the script: -# - PATH_CONFIG: to the config file `test_sacrum.json` -# - DERIVATIVE_FOLDER: name of the derivative folder (default=labels) -# - PATH_REPO: to the repository -# - PATH_NNUNET_MODEL: to the nnunet model Dataset300_SacrumDataset -# - AUTHOR: the author - -# The totalspineseg environment must be activated before running the script - -# Uncomment for full verbose -# set -x - -# Immediately exit if error -set -e -o pipefail - -# Exit if user presses CTRL+C (Linux) or CMD+C (OSX) -trap "echo Caught Keyboard Interrupt within script. Exiting now.; exit" INT - -# GET PARAMS -# ====================================================================================================================== -# SET DEFAULT VALUES FOR PARAMETERS. -# ---------------------------------------------------------------------------------------------------------------------- -PATH_CONFIG="$TOTALSPINESEG/totalspineseg/resources/configs/test_sacrum.json" - -LABEL_SUFFIX="_label-sacrum_seg" -PATH_REPO="$TOTALSPINESEG" -NNUNET_RESULTS="$TOTALSPINESEG_DATA/nnUNet/results/sacrum" -NNUNET_EXPORTS="$TOTALSPINESEG_DATA/nnUNet/exports" -NNUNET_MODEL="Dataset300_SacrumDataset" -PATH_NNUNET_MODEL="$NNUNET_RESULTS/$NNUNET_MODEL/nnUNetTrainer__nnUNetPlans__3d_fullres/" -ZIP_URL="https://github.com/neuropoly/totalspineseg/releases/download/sacrum-seg/Dataset300_SacrumDataset.zip" -PROCESS="nnUNet3D" -DERIVATIVE_FOLDER="labels" -FOLD="0" - -# Print variables to allow easier debug -echo "See variables:" -echo "PATH_CONFIG: ${PATH_CONFIG}" -echo "DERIVATIVE_FOLDER: ${DERIVATIVE_FOLDER}" -echo "LABEL_SUFFIX: ${LABEL_SUFFIX}" -echo -echo "PATH_REPO: ${PATH_REPO}" -echo "NNUNET_RESULTS: ${NNUNET_RESULTS}" -echo "NNUNET_EXPORTS: ${NNUNET_EXPORTS}" -echo "NNUNET_MODEL: ${NNUNET_MODEL}" -echo "FOLD: ${FOLD}" -echo - -# FUNCTIONS -# ====================================================================================================================== -# Segment sacrum using our nnUNet model -segment_sacrum_nnUNet(){ - local file_in="$1" - local file_out="$2" - local nnunet_model="$3" - local fold="$4" - - # Call python function - python3 "${PATH_REPO}"/totalspineseg/utils/run_nnunet_inference_single_subject.py -i "${file_in}" -o "${file_out}" -path-model "${nnunet_model}" -fold "${fold}" -use-gpu -use-best-checkpoint -} - -# Generate a json sidecar file -generate_json(){ - local path_json="$1" - local process="$2" - - # Call python function - python3 "${PATH_REPO}"/totalspineseg/utils/create_json_sidecar.py -path-json "${path_json}" -process "${process}" -} - -# Keep largest component only -keep_largest_component(){ - local seg_in="$1" - local seg_out="$2" - - # Call python function - python3 "${PATH_REPO}"/totalspineseg/utils/largest_component_filewise.py --seg-in "${seg_in}" --seg-out "${seg_out}" -} - -# Keep largest component only -download_weights(){ - local dataset="$1" - local url="$2" - local results_path="$3" - local exports_path="$4" - - # Call python function - totalspineseg_install_weights --nnunet-dataset "${dataset}" --zip-url "${url}" --results-folder "${results_path}" --exports-folder "${exports_path}" -} - -# ====================================================================================================================== -# SCRIPT STARTS HERE -# ====================================================================================================================== -# Fetch datasets path -DATASETS_PATH=$(jq -r '.DATASETS_PATH' "${PATH_CONFIG}") - -# Go to folder where data will be copied and processed -cd "$DATASETS_PATH" - -# Fetch TESTING files -FILES=$(jq -r '.TESTING[]' "${PATH_CONFIG}") - -# Download and install nnUNet weights -download_weights "$NNUNET_MODEL" "$ZIP_URL" "$NNUNET_RESULTS" "$NNUNET_EXPORTS" - -# Loop across the files -for FILE_PATH in $FILES; do - BIDS_FOLDER=$(echo "$FILE_PATH" | cut -d / -f 1) - IN_FILE_NAME=$(echo "$FILE_PATH" | awk -F / '{print $NF}' ) - OUT_FILE_NAME=${IN_FILE_NAME/".nii.gz"/"${LABEL_SUFFIX}.nii.gz"} - IMG_PATH=${FILE_PATH/"${BIDS_FOLDER}/"/} - SUB_PATH=${IMG_PATH/"/${IN_FILE_NAME}"/} - BIDS_DERIVATIVES="${BIDS_FOLDER}/derivatives/${DERIVATIVE_FOLDER}" - OUT_FOLDER="${BIDS_DERIVATIVES}/${SUB_PATH}" - OUT_PATH="${OUT_FOLDER}/${OUT_FILE_NAME}" - - # Create DERIVATIVES_FOLDER if missing - if [[ ! -d ${OUT_FOLDER} ]]; then - echo "Creating folders $OUT_FOLDER" - mkdir -p "${OUT_FOLDER}" - fi - - # Generate output segmentation - echo "Generate segmentation ${FILE_PATH} ${OUT_PATH}" - segment_sacrum_nnUNet "$FILE_PATH" "$OUT_PATH" "$PATH_NNUNET_MODEL" "$FOLD" - keep_largest_component "$OUT_PATH" "$OUT_PATH" - - # Generate json sidecar - JSON_PATH=${OUT_PATH/".nii.gz"/".json"} - echo "Generate jsonsidecar ${JSON_PATH}" - generate_json "$JSON_PATH" "$PROCESS" - -done - diff --git a/totalspineseg/data_management/convert_config_to_nnunet.py b/totalspineseg/data_management/convert_config_to_nnunet.py deleted file mode 100644 index 01ded5f..0000000 --- a/totalspineseg/data_management/convert_config_to_nnunet.py +++ /dev/null @@ -1,200 +0,0 @@ -""" -This script is based on https://github.com/ivadomed/utilities/blob/main/dataset_conversion/convert_bids_to_nnUNetV2.py - -Converts BIDS-structured dataset to the nnUNetv2 dataset format. Full details about -the format can be found here: https://github.com/MIC-DKFZ/nnUNet/blob/master/documentation/dataset_format.md - -Naga Karthik, Jan Valosek, Théo Mathieu modified by Nathan Molinier -""" -import argparse -import pathlib -from pathlib import Path -import json -import os -from collections import OrderedDict - -from totalspineseg.data_management.utils import CONTRAST, get_img_path_from_label_path, fetch_subject_and_session, fetch_contrast -from totalspineseg.utils.image import Image - - -def get_parser(): - # parse command line arguments - parser = argparse.ArgumentParser(description='Convert BIDS-structured dataset to nnUNetV2 database format.') - parser.add_argument('--config', required=True, help='Config JSON file where every label used for TRAINING, VALIDATION and TESTING has its path specified ~//config_data.json (Required)') - parser.add_argument('--path-out', required=True, help='Path to output directory. Example: ~/data/dataset-nnunet (Required)') - parser.add_argument('--dataset-name', '-dname', default='SacrumDataset', type=str, - help='Specify the task name. (Default=SacrumDataset)') - parser.add_argument('--dataset-number', '-dnum', default=501, type=int, - help='Specify the task number, has to be greater than 500 but less than 999. (Default=501)') - parser.add_argument('--registered', default=False, type=bool, - help='Set this variable to True if all the modalities/contrasts are available and corregistered for every subject (Default=False)') - return parser - - -def convert_subjects(list_labels, path_out_images, path_out_labels, channel_dict, DS_name, counter_indent=0): - """Convert an image from an original BIDS dataset to nnunet format modify. - - Args: - list_labels (list): List containing the paths of training/testing labels in the BIDS format. - path_out_images (str): path to the images directory in the new dataset (test or train). - path_out_labels (str): path to the labels directory in the new dataset (test or train). - channel_dict (dict): Association dictionary between MRI contrasts and integer values compatible with nnUNet documentation (ex: T1w = 1, T2w = 2, FLAIR = 3). - DS_name (str): Dataset name. - counter_indent (int): indent for file numbering. - - Returns: - counter (int): Last file number used - - """ - counter = counter_indent - - for label_path in list_labels: - img_path = get_img_path_from_label_path(label_path) - if not os.path.exists(img_path) or not os.path.exists(label_path): - print(f'Error while loading subject\n {img_path} or {label_path} might not exist --> skipping subject') - else: - # Load and reorient image and label to RPI - label = Image(label_path).change_orientation('RPI') - img = Image(img_path).change_orientation('RPI') - - if img.data.shape == label.data.shape: - # Increment counter for every path --> different from nnunet conventional use where the same number is the same for every subject (but need full registration) - # TODO: fix this case to keep the subject number for each contrast - counter+=1 - - # Extract information from the img_path - sub_name, sessionID, filename, modality = fetch_subject_and_session(img_path) - - # Extract contrast from channel_dict - if 'multi_contrasts' in channel_dict.keys(): - contrast = 'multi_contrasts' - else: - contrast = fetch_contrast(img_path) - - # Create new nnunet paths - nnunet_label_path = os.path.join(path_out_labels, f"{DS_name}-{sub_name}_{counter:03d}.nii.gz") - nnunet_img_path = os.path.join(path_out_images, f"{DS_name}-{sub_name}_{counter:03d}_{channel_dict[contrast]:04d}.nii.gz") - - # Save images - label.save(nnunet_label_path) - img.save(nnunet_img_path) - else: - print(f'Error while loading subject\n {img_path} and {label_path} don"t have the same shape --> skipping subject') - return counter - - -def main(): - parser = get_parser() - args = parser.parse_args() - DS_name = args.dataset_name - path_out = Path(os.path.join(os.path.abspath(os.path.expanduser(args.path_out)), - f'Dataset{args.dataset_number:03d}_{args.dataset_name}')) - # Read json file and create a dictionary - with open(args.config, "r") as file: - config = json.load(file) - if config['TYPE'] != 'LABEL': - raise ValueError('Type error: please specify LABEL paths') - if 'DATASETS_PATH' in config.keys(): - config['TRAINING'] = [os.path.join(config['DATASETS_PATH'], rel_path) for rel_path in config['TRAINING']] - config['VALIDATION'] = [os.path.join(config['DATASETS_PATH'], rel_path) for rel_path in config['VALIDATION']] - config['TESTING'] = [os.path.join(config['DATASETS_PATH'], rel_path) for rel_path in config['TESTING']] - - # To use channel dict with different modalities/contrasts, images need to be corregistered and all modalities/contrasts - # need to be available. - channel_dict = {} - if args.registered: - for i, contrast in enumerate(CONTRAST[config['CONTRASTS']]): - channel_dict[contrast] = i - else: - channel_dict['multi_contrasts'] = 0 - - # create individual directories for train and test images and labels - path_out_imagesTr = Path(os.path.join(path_out, 'imagesTr')) - path_out_imagesTs = Path(os.path.join(path_out, 'imagesTs')) - path_out_labelsTr = Path(os.path.join(path_out, 'labelsTr')) - path_out_labelsTs = Path(os.path.join(path_out, 'labelsTs')) - - train_labels = config['TRAINING'] + config['VALIDATION'] - test_labels = config['TESTING'] - - # make the directories - pathlib.Path(path_out).mkdir(parents=True, exist_ok=True) - pathlib.Path(path_out_imagesTr).mkdir(parents=True, exist_ok=True) - pathlib.Path(path_out_imagesTs).mkdir(parents=True, exist_ok=True) - pathlib.Path(path_out_labelsTr).mkdir(parents=True, exist_ok=True) - pathlib.Path(path_out_labelsTs).mkdir(parents=True, exist_ok=True) - - # Convert training and validation subjects to nnunet format - counter_train = convert_subjects(list_labels=train_labels, - path_out_images=path_out_imagesTr, - path_out_labels=path_out_labelsTr, - channel_dict=channel_dict, - DS_name=DS_name) - - # Convert testing subjects to nnunet format - counter_test = convert_subjects(list_labels=test_labels, - path_out_images=path_out_imagesTs, - path_out_labels=path_out_labelsTs, - channel_dict=channel_dict, - DS_name=DS_name, - counter_indent=counter_train) - - print(f"Number of training and validation subjects: {counter_train}") - print(f"Number of test subjects: {counter_test-counter_train}") - - # c.f. dataset json generation - # In nnUNet V2, dataset.json file has become much shorter. The description of the fields and changes - # can be found here: https://github.com/MIC-DKFZ/nnUNet/blob/master/documentation/dataset_format.md#datasetjson - # this file can be automatically generated using the following code here: - # https://github.com/MIC-DKFZ/nnUNet/blob/master/nnunetv2/dataset_conversion/generate_dataset_json.py - # example: https://github.com/MIC-DKFZ/nnUNet/blob/master/nnunet/dataset_conversion/Task055_SegTHOR.py - - json_dict = OrderedDict() - - # The following keys are the most important ones. - """ - channel_names: - Channel names must map the index to the name of the channel. For BIDS, this refers to the contrast suffix. - { - "0": "FLAIR", - "1": "T1w", - "2": "T2", - "3": "T2w" - } - Note that the channel names may influence the normalization scheme!! Learn more in the documentation. - - labels: - This will tell nnU-Net what labels to expect. Important: This will also determine whether you use region-based - training or not. - Example regular labels: - { - 'background': 0, - 'left atrium': 1, - 'some other label': 2 - } - Example region-based training: - https://github.com/MIC-DKFZ/nnUNet/blob/master/documentation/region_based_training.md - { - 'background': 0, - 'whole tumor': (1, 2, 3), - 'tumor core': (2, 3), - 'enhancing tumor': 3 - } - Remember that nnU-Net expects consecutive values for labels! nnU-Net also expects 0 to be background! - """ - - json_dict['channel_names'] = {v: k for k, v in channel_dict.items()} - - json_dict['labels'] = {"background": 0, "sacrum": 1} - - json_dict["numTraining"] = counter_train - - # Needed for finding the files correctly. IMPORTANT! File endings must match between images and segmentations! - json_dict['file_ending'] = ".nii.gz" - json_dict["overwrite_image_reader_writer"] = "SimpleITKIO" - - # create dataset.json - json.dump(json_dict, open(os.path.join(path_out, "dataset.json"), "w"), indent=4) - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/totalspineseg/data_management/init_data_config.py b/totalspineseg/data_management/init_data_config.py deleted file mode 100644 index 089607f..0000000 --- a/totalspineseg/data_management/init_data_config.py +++ /dev/null @@ -1,105 +0,0 @@ -""" -Script based on https://github.com/spinalcordtoolbox/disc-labeling-hourglass -""" - -import os -import argparse -import random -import json -import itertools -import numpy as np - -from utils import CONTRAST, get_img_path_from_label_path, fetch_contrast - -CONTRAST_LOOKUP = {tuple(sorted(value)): key for key, value in CONTRAST.items()} - -def init_data_config(args): - """ - Create a JSON configuration file from a TXT file where images paths are specified - """ - if (args.split_validation + args.split_test) > 1: - raise ValueError("The sum of the ratio between testing and validation cannot exceed 1") - - # Get input paths, could be label files or image files, - # and make sure they all exist. - file_paths = [os.path.abspath(path.replace('\n', '')) for path in open(args.txt)] - if args.type == 'LABEL': - label_paths = file_paths - img_paths = [get_img_path_from_label_path(lp) for lp in label_paths] - file_paths = label_paths + img_paths - elif args.type == 'IMAGE': - img_paths = file_paths - else: - raise ValueError(f"invalid args.type: {args.type}") - missing_paths = [ - path for path in file_paths - if not os.path.isfile(path) - ] - - if missing_paths: - raise ValueError("missing files:\n" + '\n'.join(missing_paths)) - - # Extract BIDS parent folder path - dataset_parent_path_list = ['/'.join(path.split('/sub')[0].split('/')[:-1]) for path in img_paths] - - # Check if all the BIDS folders are stored inside the same parent repository - if (np.array(dataset_parent_path_list) == dataset_parent_path_list[0]).all(): - dataset_parent_path = dataset_parent_path_list[0] - else: - raise ValueError('Please store all the BIDS datasets inside the same parent folder !') - - # Look up the right code for the set of contrasts present - contrasts = CONTRAST_LOOKUP[tuple(sorted(set(map(fetch_contrast, img_paths))))] - - config = { - 'TYPE': args.type, - 'CONTRASTS': contrasts, - 'DATASETS_PATH': dataset_parent_path - } - - # Split into training, validation, and testing sets - split_ratio = (1 - (args.split_validation + args.split_test), args.split_validation, args.split_test) # TRAIN, VALIDATION, and TEST - config_paths = label_paths if args.type == 'LABEL' else img_paths - config_paths = [path.split(dataset_parent_path + '/')[-1] for path in config_paths] # Remove DATASETS_PATH - random.shuffle(config_paths) - splits = [0] + [ - int(len(config_paths) * ratio) - for ratio in itertools.accumulate(split_ratio) - ] - for key, (begin, end) in zip( - ['TRAINING', 'VALIDATION', 'TESTING'], - pairwise(splits), - ): - config[key] = config_paths[begin:end] - - # Save the config - config_path = args.txt.replace('.txt', '') + '.json' - json.dump(config, open(config_path, 'w'), indent=4) - -def pairwise(iterable): - # pairwise('ABCDEFG') --> AB BC CD DE EF FG - # based on https://docs.python.org/3.11/library/itertools.html - a, b = itertools.tee(iterable) - next(b, None) - return zip(a, b) - - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description='Create config JSON from a TXT file which contains list of paths') - - ## Parameters - parser.add_argument('--txt', required=True, - help='Path to TXT file that contains only image or label paths. (Required)') - parser.add_argument('--type', choices=('LABEL', 'IMAGE'), - help='Type of paths specified. Choices "LABEL" or "IMAGE". (Required)') - parser.add_argument('--split-validation', type=float, default=0.1, - help='Split ratio for validation. Default=0.1') - parser.add_argument('--split-test', type=float, default=0.1, - help='Split ratio for testing. Default=0.1') - - args = parser.parse_args() - - if args.split_test > 0.9: - args.split_validation = 1 - args.split_test - - init_data_config(args) diff --git a/totalspineseg/data_management/utils.py b/totalspineseg/data_management/utils.py deleted file mode 100644 index 787a80a..0000000 --- a/totalspineseg/data_management/utils.py +++ /dev/null @@ -1,87 +0,0 @@ -""" -Script based on https://github.com/spinalcordtoolbox/disc-labeling-hourglass -""" - -import os -import re -from pathlib import Path - -## Variables -CONTRAST = {'t1': ['T1w'], - 't2': ['T2w'], - 't2s':['T2star'], - 't1_t2': ['T1w', 'T2w'], - 'psir': ['PSIR'], - 'stir': ['STIR'], - 'psir_stir': ['PSIR', 'STIR'], - 't1_t2_psir_stir': ['T1w', 'T2w', 'PSIR', 'STIR'] - } - -## Functions -def get_img_path_from_label_path(str_path): - """ - This function does 2 things: ⚠️ Files need to be stored in a BIDS compliant dataset - - Step 1: Remove label suffix (e.g. "_labels-disc-manual"). The suffix is always between the MRI contrast and the file extension. - - Step 2: Remove derivatives path (e.g. derivatives/labels/). The first folders is always called derivatives but the second may vary (e.g. labels_soft) - - :param path: absolute path to the label img. Example: //derivatives/labels/sub-amuALT/anat/sub-amuALT_T1w_labels-disc-manual.nii.gz - :return: img path. Example: //sub-amuALT/anat/sub-amuALT_T1w.nii.gz - Copied from https://github.com/spinalcordtoolbox/disc-labeling-hourglass - - """ - # Load path - path = Path(str_path) - - # Extract file extension - ext = ''.join(path.suffixes) - - # Get img name - img_name = '_'.join(path.name.split('_')[:-1]) + ext - - # Create a list of the directories - dir_list = str(path.parent).split('/') - - # Remove "derivatives" and "labels" folders - derivatives_idx = dir_list.index('derivatives') - dir_path = '/'.join(dir_list[0:derivatives_idx] + dir_list[derivatives_idx+2:]) - - # Recreate img path - img_path = os.path.join(dir_path, img_name) - - return img_path - -def fetch_subject_and_session(filename_path): - """ - Get subject ID, session ID and filename from the input BIDS-compatible filename or file path - The function works both on absolute file path as well as filename - :param filename_path: input nifti filename (e.g., sub-001_ses-01_T1w.nii.gz) or file path - (e.g., /home/user/MRI/bids/derivatives/labels/sub-001/ses-01/anat/sub-001_ses-01_T1w.nii.gz - :return: subjectID: subject ID (e.g., sub-001) - :return: sessionID: session ID (e.g., ses-01) - :return: filename: nii filename (e.g., sub-001_ses-01_T1w.nii.gz) - Copied from https://github.com/spinalcordtoolbox/manual-correction - """ - - _, filename = os.path.split(filename_path) # Get just the filename (i.e., remove the path) - subject = re.search('sub-(.*?)[_/]', filename_path) # [_/] means either underscore or slash - subjectID = subject.group(0)[:-1] if subject else "" # [:-1] removes the last underscore or slash - - session = re.search('ses-(.*?)[_/]', filename_path) # [_/] means either underscore or slash - sessionID = session.group(0)[:-1] if session else "" # [:-1] removes the last underscore or slash - # REGEX explanation - # . - match any character (except newline) - # *? - match the previous element as few times as possible (zero or more times) - - contrast = 'dwi' if 'dwi' in filename_path else 'anat' # Return contrast (dwi or anat) - - return subjectID, sessionID, filename, contrast - - -def fetch_contrast(filename_path): - ''' - Extract MRI contrast from a BIDS-compatible filename/filepath - The function handles images only. - :param filename_path: image file path or file name. (e.g sub-001_ses-01_T1w.nii.gz) - Copied from https://github.com/spinalcordtoolbox/disc-labeling-hourglass - ''' - return filename_path.rstrip(''.join(Path(filename_path).suffixes)).split('_')[-1] \ No newline at end of file diff --git a/totalspineseg/resources/configs/test_sacrum.json b/totalspineseg/resources/configs/test_sacrum.json deleted file mode 100644 index aec5ecb..0000000 --- a/totalspineseg/resources/configs/test_sacrum.json +++ /dev/null @@ -1,462 +0,0 @@ -{ - "TYPE": "IMAGE", - "CONTRASTS": "t1_t2", - "DATASETS_PATH": "/home/GRAMES.POLYMTL.CA/p118739/data/datasets", - "TRAINING": [], - "VALIDATION": [], - "TESTING": [ - "spider-challenge-2023/sub-053/anat/sub-053_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-112/anat/sub-112_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-030/anat/sub-030_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-167/anat/sub-167_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-100/anat/sub-100_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-098/anat/sub-098_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-146/anat/sub-146_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-093/anat/sub-093_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-078/anat/sub-078_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-004/anat/sub-004_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-088/anat/sub-088_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-242/anat/sub-242_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-005/anat/sub-005_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-050/anat/sub-050_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-160/anat/sub-160_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-162/anat/sub-162_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-060/anat/sub-060_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-163/anat/sub-163_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-012/anat/sub-012_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-145/anat/sub-145_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-207/anat/sub-207_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-053/anat/sub-053_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-198/anat/sub-198_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-081/anat/sub-081_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-038/anat/sub-038_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-136/anat/sub-136_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-209/anat/sub-209_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-155/anat/sub-155_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-007/anat/sub-007_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-011/anat/sub-011_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-109/anat/sub-109_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-244/anat/sub-244_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-015/anat/sub-015_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-190/anat/sub-190_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-011/anat/sub-011_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-159/anat/sub-159_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-145/anat/sub-145_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-220/anat/sub-220_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-156/anat/sub-156_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-253/anat/sub-253_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-077/anat/sub-077_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-202/anat/sub-202_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-201/anat/sub-201_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-223/anat/sub-223_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-252/anat/sub-252_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-002/anat/sub-002_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-015/anat/sub-015_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-035/anat/sub-035_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-189/anat/sub-189_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-215/anat/sub-215_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-066/anat/sub-066_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-056/anat/sub-056_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-181/anat/sub-181_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-034/anat/sub-034_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-225/anat/sub-225_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-204/anat/sub-204_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-089/anat/sub-089_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-144/anat/sub-144_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-165/anat/sub-165_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-177/anat/sub-177_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-205/anat/sub-205_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-123/anat/sub-123_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-069/anat/sub-069_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-094/anat/sub-094_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-075/anat/sub-075_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-065/anat/sub-065_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-186/anat/sub-186_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-197/anat/sub-197_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-115/anat/sub-115_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-188/anat/sub-188_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-113/anat/sub-113_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-086/anat/sub-086_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-200/anat/sub-200_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-090/anat/sub-090_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-249/anat/sub-249_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-175/anat/sub-175_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-035/anat/sub-035_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-072/anat/sub-072_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-024/anat/sub-024_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-117/anat/sub-117_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-233/anat/sub-233_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-105/anat/sub-105_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-251/anat/sub-251_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-071/anat/sub-071_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-008/anat/sub-008_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-048/anat/sub-048_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-008/anat/sub-008_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-055/anat/sub-055_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-218/anat/sub-218_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-203/anat/sub-203_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-209/anat/sub-209_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-209/anat/sub-209_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-234/anat/sub-234_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-229/anat/sub-229_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-052/anat/sub-052_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-134/anat/sub-134_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-207/anat/sub-207_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-034/anat/sub-034_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-063/anat/sub-063_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-061/anat/sub-061_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-231/anat/sub-231_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-166/anat/sub-166_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-210/anat/sub-210_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-138/anat/sub-138_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-237/anat/sub-237_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-001/anat/sub-001_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-203/anat/sub-203_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-226/anat/sub-226_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-202/anat/sub-202_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-166/anat/sub-166_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-257/anat/sub-257_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-007/anat/sub-007_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-200/anat/sub-200_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-009/anat/sub-009_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-064/anat/sub-064_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-020/anat/sub-020_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-214/anat/sub-214_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-220/anat/sub-220_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-035/anat/sub-035_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-062/anat/sub-062_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-245/anat/sub-245_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-133/anat/sub-133_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-192/anat/sub-192_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-011/anat/sub-011_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-081/anat/sub-081_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-161/anat/sub-161_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-255/anat/sub-255_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-031/anat/sub-031_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-030/anat/sub-030_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-171/anat/sub-171_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-252/anat/sub-252_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-100/anat/sub-100_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-099/anat/sub-099_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-071/anat/sub-071_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-108/anat/sub-108_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-185/anat/sub-185_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-221/anat/sub-221_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-074/anat/sub-074_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-106/anat/sub-106_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-172/anat/sub-172_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-174/anat/sub-174_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-104/anat/sub-104_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-037/anat/sub-037_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-044/anat/sub-044_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-227/anat/sub-227_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-086/anat/sub-086_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-017/anat/sub-017_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-108/anat/sub-108_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-171/anat/sub-171_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-236/anat/sub-236_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-217/anat/sub-217_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-253/anat/sub-253_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-045/anat/sub-045_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-007/anat/sub-007_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-245/anat/sub-245_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-090/anat/sub-090_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-146/anat/sub-146_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-208/anat/sub-208_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-031/anat/sub-031_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-210/anat/sub-210_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-141/anat/sub-141_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-137/anat/sub-137_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-241/anat/sub-241_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-109/anat/sub-109_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-226/anat/sub-226_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-017/anat/sub-017_acq-lowresSag_T1w.nii.gz", - "whole-spine/sub-amuVG/anat/sub-amuVG_T2w.nii.gz", - "spider-challenge-2023/sub-117/anat/sub-117_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-057/anat/sub-057_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-156/anat/sub-156_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-085/anat/sub-085_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-168/anat/sub-168_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-151/anat/sub-151_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-028/anat/sub-028_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-228/anat/sub-228_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-032/anat/sub-032_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-217/anat/sub-217_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-065/anat/sub-065_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-189/anat/sub-189_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-191/anat/sub-191_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-193/anat/sub-193_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-019/anat/sub-019_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-057/anat/sub-057_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-232/anat/sub-232_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-010/anat/sub-010_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-059/anat/sub-059_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-166/anat/sub-166_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-033/anat/sub-033_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-061/anat/sub-061_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-143/anat/sub-143_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-062/anat/sub-062_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-116/anat/sub-116_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-200/anat/sub-200_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-122/anat/sub-122_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-006/anat/sub-006_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-167/anat/sub-167_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-142/anat/sub-142_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-165/anat/sub-165_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-154/anat/sub-154_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-078/anat/sub-078_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-163/anat/sub-163_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-095/anat/sub-095_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-104/anat/sub-104_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-039/anat/sub-039_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-181/anat/sub-181_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-059/anat/sub-059_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-022/anat/sub-022_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-140/anat/sub-140_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-018/anat/sub-018_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-255/anat/sub-255_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-234/anat/sub-234_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-115/anat/sub-115_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-129/anat/sub-129_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-187/anat/sub-187_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-012/anat/sub-012_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-169/anat/sub-169_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-023/anat/sub-023_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-196/anat/sub-196_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-195/anat/sub-195_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-184/anat/sub-184_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-152/anat/sub-152_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-239/anat/sub-239_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-085/anat/sub-085_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-170/anat/sub-170_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-096/anat/sub-096_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-024/anat/sub-024_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-198/anat/sub-198_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-132/anat/sub-132_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-215/anat/sub-215_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-080/anat/sub-080_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-161/anat/sub-161_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-251/anat/sub-251_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-013/anat/sub-013_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-023/anat/sub-023_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-231/anat/sub-231_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-048/anat/sub-048_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-124/anat/sub-124_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-113/anat/sub-113_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-107/anat/sub-107_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-182/anat/sub-182_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-061/anat/sub-061_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-191/anat/sub-191_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-174/anat/sub-174_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-242/anat/sub-242_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-083/anat/sub-083_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-193/anat/sub-193_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-016/anat/sub-016_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-257/anat/sub-257_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-121/anat/sub-121_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-001/anat/sub-001_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-018/anat/sub-018_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-241/anat/sub-241_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-056/anat/sub-056_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-249/anat/sub-249_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-040/anat/sub-040_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-064/anat/sub-064_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-244/anat/sub-244_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-022/anat/sub-022_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-172/anat/sub-172_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-169/anat/sub-169_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-236/anat/sub-236_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-122/anat/sub-122_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-050/anat/sub-050_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-065/anat/sub-065_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-082/anat/sub-082_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-036/anat/sub-036_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-250/anat/sub-250_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-120/anat/sub-120_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-074/anat/sub-074_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-127/anat/sub-127_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-107/anat/sub-107_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-025/anat/sub-025_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-066/anat/sub-066_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-037/anat/sub-037_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-125/anat/sub-125_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-183/anat/sub-183_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-137/anat/sub-137_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-058/anat/sub-058_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-144/anat/sub-144_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-088/anat/sub-088_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-040/anat/sub-040_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-241/anat/sub-241_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-225/anat/sub-225_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-180/anat/sub-180_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-075/anat/sub-075_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-118/anat/sub-118_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-081/anat/sub-081_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-073/anat/sub-073_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-036/anat/sub-036_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-110/anat/sub-110_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-118/anat/sub-118_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-116/anat/sub-116_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-125/anat/sub-125_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-078/anat/sub-078_acq-lowresSag_T2w.nii.gz", - "whole-spine/sub-unfErssm012/anat/sub-unfErssm012_T2w.nii.gz", - "spider-challenge-2023/sub-243/anat/sub-243_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-118/anat/sub-118_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-097/anat/sub-097_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-213/anat/sub-213_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-250/anat/sub-250_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-196/anat/sub-196_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-091/anat/sub-091_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-155/anat/sub-155_acq-lowresSag_T1w.nii.gz", - "whole-spine/sub-unfErssm001/anat/sub-unfErssm001_T1w.nii.gz", - "spider-challenge-2023/sub-050/anat/sub-050_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-129/anat/sub-129_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-073/anat/sub-073_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-110/anat/sub-110_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-018/anat/sub-018_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-177/anat/sub-177_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-239/anat/sub-239_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-025/anat/sub-025_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-219/anat/sub-219_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-214/anat/sub-214_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-095/anat/sub-095_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-237/anat/sub-237_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-185/anat/sub-185_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-068/anat/sub-068_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-087/anat/sub-087_acq-lowresSag_T2w.nii.gz", - "whole-spine/sub-unfErssm029/anat/sub-unfErssm029_T1w.nii.gz", - "spider-challenge-2023/sub-098/anat/sub-098_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-127/anat/sub-127_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-162/anat/sub-162_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-069/anat/sub-069_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-107/anat/sub-107_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-160/anat/sub-160_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-152/anat/sub-152_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-152/anat/sub-152_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-223/anat/sub-223_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-186/anat/sub-186_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-243/anat/sub-243_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-069/anat/sub-069_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-034/anat/sub-034_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-044/anat/sub-044_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-112/anat/sub-112_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-121/anat/sub-121_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-185/anat/sub-185_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-256/anat/sub-256_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-138/anat/sub-138_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-063/anat/sub-063_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-224/anat/sub-224_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-042/anat/sub-042_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-094/anat/sub-094_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-173/anat/sub-173_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-188/anat/sub-188_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-052/anat/sub-052_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-045/anat/sub-045_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-232/anat/sub-232_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-067/anat/sub-067_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-130/anat/sub-130_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-212/anat/sub-212_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-256/anat/sub-256_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-055/anat/sub-055_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-045/anat/sub-045_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-136/anat/sub-136_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-105/anat/sub-105_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-127/anat/sub-127_acq-highresSag_T2w.nii.gz", - "whole-spine/sub-amuFR/anat/sub-amuFR_T1w.nii.gz", - "spider-challenge-2023/sub-133/anat/sub-133_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-140/anat/sub-140_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-019/anat/sub-019_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-147/anat/sub-147_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-129/anat/sub-129_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-213/anat/sub-213_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-149/anat/sub-149_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-051/anat/sub-051_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-234/anat/sub-234_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-052/anat/sub-052_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-060/anat/sub-060_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-101/anat/sub-101_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-179/anat/sub-179_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-192/anat/sub-192_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-190/anat/sub-190_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-106/anat/sub-106_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-161/anat/sub-161_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-047/anat/sub-047_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-143/anat/sub-143_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-110/anat/sub-110_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-239/anat/sub-239_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-068/anat/sub-068_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-033/anat/sub-033_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-131/anat/sub-131_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-195/anat/sub-195_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-028/anat/sub-028_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-154/anat/sub-154_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-029/anat/sub-029_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-005/anat/sub-005_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-224/anat/sub-224_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-021/anat/sub-021_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-032/anat/sub-032_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-228/anat/sub-228_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-131/anat/sub-131_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-022/anat/sub-022_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-039/anat/sub-039_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-013/anat/sub-013_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-058/anat/sub-058_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-029/anat/sub-029_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-221/anat/sub-221_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-233/anat/sub-233_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-182/anat/sub-182_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-136/anat/sub-136_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-254/anat/sub-254_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-130/anat/sub-130_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-218/anat/sub-218_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-254/anat/sub-254_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-177/anat/sub-177_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-010/anat/sub-010_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-041/anat/sub-041_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-055/anat/sub-055_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-187/anat/sub-187_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-057/anat/sub-057_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-170/anat/sub-170_acq-lowresSag_T2w.nii.gz", - "whole-spine/sub-unfErssm012/anat/sub-unfErssm012_T1w.nii.gz", - "spider-challenge-2023/sub-220/anat/sub-220_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-051/anat/sub-051_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-126/anat/sub-126_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-227/anat/sub-227_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-184/anat/sub-184_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-101/anat/sub-101_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-098/anat/sub-098_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-179/anat/sub-179_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-009/anat/sub-009_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-093/anat/sub-093_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-004/anat/sub-004_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-162/anat/sub-162_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-080/anat/sub-080_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-212/anat/sub-212_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-146/anat/sub-146_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-142/anat/sub-142_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-083/anat/sub-083_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-038/anat/sub-038_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-191/anat/sub-191_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-222/anat/sub-222_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-246/anat/sub-246_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-222/anat/sub-222_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-005/anat/sub-005_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-002/anat/sub-002_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-096/anat/sub-096_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-229/anat/sub-229_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-082/anat/sub-082_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-205/anat/sub-205_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-204/anat/sub-204_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-142/anat/sub-142_acq-highresSag_T2w.nii.gz", - "spider-challenge-2023/sub-067/anat/sub-067_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-047/anat/sub-047_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-168/anat/sub-168_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-087/anat/sub-087_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-003/anat/sub-003_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-149/anat/sub-149_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-173/anat/sub-173_acq-lowresSag_T1w.nii.gz", - "spider-challenge-2023/sub-003/anat/sub-003_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-016/anat/sub-016_acq-lowresSag_T2w.nii.gz", - "spider-challenge-2023/sub-175/anat/sub-175_acq-lowresSag_T2w.nii.gz" - ] -} diff --git a/totalspineseg/utils/create_json_sidecar.py b/totalspineseg/utils/create_json_sidecar.py deleted file mode 100644 index 90603dd..0000000 --- a/totalspineseg/utils/create_json_sidecar.py +++ /dev/null @@ -1,36 +0,0 @@ -import json -import time -import argparse - -def get_parser(): - # parse command line arguments - parser = argparse.ArgumentParser(description='Segment an image using nnUNetV2 model.') - parser.add_argument('-path-json', help='Input image to segment. Example: derivatives/labels/sub-001/anat/sub-001_T2w_label-sacrum_seg.json', required=True) - parser.add_argument('-process', help='Process used to generate the data. Example: nnUNet3D', required=True, type=str) - return parser - -def create_json_file(): - """ - Create a json sidecar file - :param path_file_out: path to the output file - """ - parser = get_parser() - args = parser.parse_args() - - path_json_out = args.path_json - process=args.process - data_json = { - "SpatialReference": "orig", - "GeneratedBy": [ - { - "Name": process, - "Date": time.strftime('%Y-%m-%d %H:%M:%S') - } - ] - } - with open(path_json_out, 'w') as f: - json.dump(data_json, f, indent=4) - print(f'Created: {path_json_out}') - -if __name__=='__main__': - create_json_file() \ No newline at end of file diff --git a/totalspineseg/utils/image.py b/totalspineseg/utils/image.py deleted file mode 100644 index 203f759..0000000 --- a/totalspineseg/utils/image.py +++ /dev/null @@ -1,685 +0,0 @@ -import os -import numpy as np -import nibabel as nib -import logging -from copy import deepcopy - -logger = logging.getLogger(__name__) - -class Image(object): - """ - Compact version of SCT's Image Class (https://github.com/spinalcordtoolbox/spinalcordtoolbox/blob/master/spinalcordtoolbox/image.py#L245) - Create an object that behaves similarly to nibabel's image object. Useful additions include: dims, change_orientation and getNonZeroCoordinates. - """ - - def __init__(self, param=None, hdr=None, orientation=None, absolutepath=None, dim=None): - """ - :param param: string indicating a path to a image file or an `Image` object. - """ - - # initialization of all parameters - self.affine = None - self.data = None - self._path = None - self.ext = "" - - if absolutepath is not None: - self._path = os.path.abspath(absolutepath) - - # Case 1: load an image from file - if isinstance(param, str): - self.loadFromPath(param) - # Case 2: create a copy of an existing `Image` object - elif isinstance(param, type(self)): - self.copy(param) - # Case 3: create a blank image from a list of dimensions - elif isinstance(param, list): - self.data = np.zeros(param) - self.hdr = hdr.copy() if hdr is not None else nib.Nifti1Header() - self.hdr.set_data_shape(self.data.shape) - # Case 4: create an image from an existing data array - elif isinstance(param, (np.ndarray, np.generic)): - self.data = param - self.hdr = hdr.copy() if hdr is not None else nib.Nifti1Header() - self.hdr.set_data_shape(self.data.shape) - else: - raise TypeError('Image constructor takes at least one argument.') - - # Fix any mismatch between the array's datatype and the header datatype - self.fix_header_dtype() - - @property - def dim(self): - return get_dimension(self) - - @property - def orientation(self): - return get_orientation(self) - - @property - def absolutepath(self): - """ - Storage path (either actual or potential) - - Notes: - - - As several tools perform chdir() it's very important to have absolute paths - - When set, if relative: - - - If it already existed, it becomes a new basename in the old dirname - - Else, it becomes absolute (shortcut) - - Usually not directly touched (use `Image.save`), but in some cases it's - the best way to set it. - """ - return self._path - - @absolutepath.setter - def absolutepath(self, value): - if value is None: - self._path = None - return - elif not os.path.isabs(value) and self._path is not None: - value = os.path.join(os.path.dirname(self._path), value) - elif not os.path.isabs(value): - value = os.path.abspath(value) - self._path = value - - @property - def header(self): - return self.hdr - - @header.setter - def header(self, value): - self.hdr = value - - def __deepcopy__(self, memo): - return type(self)(deepcopy(self.data, memo), deepcopy(self.hdr, memo), deepcopy(self.orientation, memo), deepcopy(self.absolutepath, memo), deepcopy(self.dim, memo)) - - def copy(self, image=None): - if image is not None: - self.affine = deepcopy(image.affine) - self.data = deepcopy(image.data) - self.hdr = deepcopy(image.hdr) - self._path = deepcopy(image._path) - else: - return deepcopy(self) - - def loadFromPath(self, path): - """ - This function load an image from an absolute path using nibabel library - - :param path: path of the file from which the image will be loaded - :return: - """ - - self.absolutepath = os.path.abspath(path) - im_file = nib.load(self.absolutepath, mmap=True) - self.affine = im_file.affine.copy() - self.data = np.asanyarray(im_file.dataobj) - self.hdr = im_file.header.copy() - if path != self.absolutepath: - logger.debug("Loaded %s (%s) orientation %s shape %s", path, self.absolutepath, self.orientation, self.data.shape) - else: - logger.debug("Loaded %s orientation %s shape %s", path, self.orientation, self.data.shape) - - def change_orientation(self, orientation, inverse=False): - """ - Change orientation on image (in-place). - - :param orientation: orientation string (SCT "from" convention) - - :param inverse: if you think backwards, use this to specify that you actually\ - want to transform *from* the specified orientation, not *to*\ - it. - - """ - change_orientation(self, orientation, self, inverse=inverse) - return self - - def getNonZeroCoordinates(self, sorting=None, reverse_coord=False): - """ - This function return all the non-zero coordinates that the image contains. - Coordinate list can also be sorted by x, y, z, or the value with the parameter sorting='x', sorting='y', sorting='z' or sorting='value' - If reverse_coord is True, coordinate are sorted from larger to smaller. - - Removed Coordinate object - """ - n_dim = 1 - if self.dim[3] == 1: - n_dim = 3 - else: - n_dim = 4 - if self.dim[2] == 1: - n_dim = 2 - - if n_dim == 3: - X, Y, Z = (self.data > 0).nonzero() - list_coordinates = [[X[i], Y[i], Z[i], self.data[X[i], Y[i], Z[i]]] for i in range(0, len(X))] - elif n_dim == 2: - try: - X, Y = (self.data > 0).nonzero() - list_coordinates = [[X[i], Y[i], 0, self.data[X[i], Y[i]]] for i in range(0, len(X))] - except ValueError: - X, Y, Z = (self.data > 0).nonzero() - list_coordinates = [[X[i], Y[i], 0, self.data[X[i], Y[i], 0]] for i in range(0, len(X))] - - if sorting is not None: - if reverse_coord not in [True, False]: - raise ValueError('reverse_coord parameter must be a boolean') - - if sorting == 'x': - list_coordinates = sorted(list_coordinates, key=lambda el: el[0], reverse=reverse_coord) - elif sorting == 'y': - list_coordinates = sorted(list_coordinates, key=lambda el: el[1], reverse=reverse_coord) - elif sorting == 'z': - list_coordinates = sorted(list_coordinates, key=lambda el: el[2], reverse=reverse_coord) - elif sorting == 'value': - list_coordinates = sorted(list_coordinates, key=lambda el: el[3], reverse=reverse_coord) - else: - raise ValueError("sorting parameter must be either 'x', 'y', 'z' or 'value'") - - return list_coordinates - - def change_type(self, dtype): - """ - Change data type on image. - - Note: the image path is voided. - """ - change_type(self, dtype, self) - return self - - def fix_header_dtype(self): - """ - Change the header dtype to the match the datatype of the array. - """ - # Using bool for nibabel headers is unsupported, so use uint8 instead: - # `nibabel.spatialimages.HeaderDataError: data dtype "bool" not supported` - dtype_data = self.data.dtype - if dtype_data == bool: - dtype_data = np.uint8 - - dtype_header = self.hdr.get_data_dtype() - if dtype_header != dtype_data: - logger.warning(f"Image header specifies datatype '{dtype_header}', but array is of type " - f"'{dtype_data}'. Header metadata will be overwritten to use '{dtype_data}'.") - self.hdr.set_data_dtype(dtype_data) - - def save(self, path=None, dtype=None, verbose=1, mutable=False): - """ - Write an image in a nifti file - - :param path: Where to save the data, if None it will be taken from the\ - absolutepath member.\ - If path is a directory, will save to a file under this directory\ - with the basename from the absolutepath member. - - :param dtype: if not set, the image is saved in the same type as input data\ - if 'minimize', image storage space is minimized\ - (2, 'uint8', np.uint8, "NIFTI_TYPE_UINT8"),\ - (4, 'int16', np.int16, "NIFTI_TYPE_INT16"),\ - (8, 'int32', np.int32, "NIFTI_TYPE_INT32"),\ - (16, 'float32', np.float32, "NIFTI_TYPE_FLOAT32"),\ - (32, 'complex64', np.complex64, "NIFTI_TYPE_COMPLEX64"),\ - (64, 'float64', np.float64, "NIFTI_TYPE_FLOAT64"),\ - (256, 'int8', np.int8, "NIFTI_TYPE_INT8"),\ - (512, 'uint16', np.uint16, "NIFTI_TYPE_UINT16"),\ - (768, 'uint32', np.uint32, "NIFTI_TYPE_UINT32"),\ - (1024,'int64', np.int64, "NIFTI_TYPE_INT64"),\ - (1280, 'uint64', np.uint64, "NIFTI_TYPE_UINT64"),\ - (1536, 'float128', _float128t, "NIFTI_TYPE_FLOAT128"),\ - (1792, 'complex128', np.complex128, "NIFTI_TYPE_COMPLEX128"),\ - (2048, 'complex256', _complex256t, "NIFTI_TYPE_COMPLEX256"), - - :param mutable: whether to update members with newly created path or dtype - """ - if mutable: # do all modifications in-place - # Case 1: `path` not specified - if path is None: - if self.absolutepath: # Fallback to the original filepath - path = self.absolutepath - else: - raise ValueError("Don't know where to save the image (no absolutepath or path parameter)") - # Case 2: `path` points to an existing directory - elif os.path.isdir(path): - if self.absolutepath: # Use the original filename, but save to the directory specified by `path` - path = os.path.join(os.path.abspath(path), os.path.basename(self.absolutepath)) - else: - raise ValueError("Don't know where to save the image (path parameter is dir, but absolutepath is " - "missing)") - # Case 3: `path` points to a file (or a *nonexistent* directory) so use its value as-is - # (We're okay with letting nonexistent directories slip through, because it's difficult to distinguish - # between nonexistent directories and nonexistent files. Plus, `nibabel` will catch any further errors.) - else: - pass - - if os.path.isfile(path) and verbose: - logger.warning("File %s already exists. Will overwrite it.", path) - if os.path.isabs(path): - logger.debug("Saving image to %s orientation %s shape %s", - path, self.orientation, self.data.shape) - else: - logger.debug("Saving image to %s (%s) orientation %s shape %s", - path, os.path.abspath(path), self.orientation, self.data.shape) - - # Now that `path` has been set and log messages have been written, we can assign it to the image itself - self.absolutepath = os.path.abspath(path) - - if dtype is not None: - self.change_type(dtype) - - if self.hdr is not None: - self.hdr.set_data_shape(self.data.shape) - self.fix_header_dtype() - - # nb. that copy() is important because if it were a memory map, save() would corrupt it - dataobj = self.data.copy() - affine = None - header = self.hdr.copy() if self.hdr is not None else None - nib.save(nib.nifti1.Nifti1Image(dataobj, affine, header), self.absolutepath) - if not os.path.isfile(self.absolutepath): - raise RuntimeError(f"Couldn't save image to {self.absolutepath}") - else: - # if we're not operating in-place, then make any required modifications on a throw-away copy - self.copy().save(path, dtype, verbose, mutable=True) - return self - - -class SlicerOneAxis(object): - """ - Image slicer to use when you don't care about the 2D slice orientation, - and don't want to specify them. - The slicer will just iterate through the right axis that corresponds to - its specification. - - Can help getting ranges and slice indices. - - Copied from https://github.com/spinalcordtoolbox/spinalcordtoolbox/image.py - """ - - def __init__(self, im, axis="IS"): - opposite_character = {'L': 'R', 'R': 'L', 'A': 'P', 'P': 'A', 'I': 'S', 'S': 'I'} - axis_labels = "LRPAIS" - if len(axis) != 2: - raise ValueError() - if axis[0] not in axis_labels: - raise ValueError() - if axis[1] not in axis_labels: - raise ValueError() - if axis[0] != opposite_character[axis[1]]: - raise ValueError() - - for idx_axis in range(2): - dim_nr = im.orientation.find(axis[idx_axis]) - if dim_nr != -1: - break - if dim_nr == -1: - raise ValueError() - - # SCT convention - from_dir = im.orientation[dim_nr] - self.direction = +1 if axis[0] == from_dir else -1 - self.nb_slices = im.dim[dim_nr] - self.im = im - self.axis = axis - self._slice = lambda idx: tuple([(idx if x in axis else slice(None)) for x in im.orientation]) - - def __len__(self): - return self.nb_slices - - def __getitem__(self, idx): - """ - - :return: an image slice, at slicing index idx - :param idx: slicing index (according to the slicing direction) - """ - if isinstance(idx, slice): - raise NotImplementedError() - - if idx >= self.nb_slices: - raise IndexError("I just have {} slices!".format(self.nb_slices)) - - if self.direction == -1: - idx = self.nb_slices - 1 - idx - - return self.im.data[self._slice(idx)] - -def get_dimension(im_file, verbose=1): - """ - Copied from https://github.com/spinalcordtoolbox/spinalcordtoolbox/ - - Get dimension from Image or nibabel object. Manages 2D, 3D or 4D images. - - :param: im_file: Image or nibabel object - :return: nx, ny, nz, nt, px, py, pz, pt - """ - if not isinstance(im_file, (nib.nifti1.Nifti1Image, Image)): - raise TypeError("The provided image file is neither a nibabel.nifti1.Nifti1Image instance nor an Image instance") - # initializating ndims [nx, ny, nz, nt] and pdims [px, py, pz, pt] - ndims = [1, 1, 1, 1] - pdims = [1, 1, 1, 1] - data_shape = im_file.header.get_data_shape() - zooms = im_file.header.get_zooms() - for i in range(min(len(data_shape), 4)): - ndims[i] = data_shape[i] - pdims[i] = zooms[i] - return *ndims, *pdims - - -def change_orientation(im_src, orientation, im_dst=None, inverse=False): - """ - Copied from https://github.com/spinalcordtoolbox/spinalcordtoolbox/ - - :param im_src: source image - :param orientation: orientation string (SCT "from" convention) - :param im_dst: destination image (can be the source image for in-place - operation, can be unset to generate one) - :param inverse: if you think backwards, use this to specify that you actually - want to transform *from* the specified orientation, not *to* it. - :return: an image with changed orientation - - .. note:: - - the resulting image has no path member set - - if the source image is < 3D, it is reshaped to 3D and the destination is 3D - """ - - if len(im_src.data.shape) < 3: - pass # Will reshape to 3D - elif len(im_src.data.shape) == 3: - pass # OK, standard 3D volume - elif len(im_src.data.shape) == 4: - pass # OK, standard 4D volume - elif len(im_src.data.shape) == 5 and im_src.header.get_intent()[0] == "vector": - pass # OK, physical displacement field - else: - raise NotImplementedError("Don't know how to change orientation for this image") - - im_src_orientation = im_src.orientation - im_dst_orientation = orientation - if inverse: - im_src_orientation, im_dst_orientation = im_dst_orientation, im_src_orientation - - perm, inversion = _get_permutations(im_src_orientation, im_dst_orientation) - - if im_dst is None: - im_dst = im_src.copy() - im_dst._path = None - - im_src_data = im_src.data - if len(im_src_data.shape) < 3: - im_src_data = im_src_data.reshape(tuple(list(im_src_data.shape) + ([1] * (3 - len(im_src_data.shape))))) - - # Update data by performing inversions and swaps - - # axes inversion (flip) - data = im_src_data[::inversion[0], ::inversion[1], ::inversion[2]] - - # axes manipulations (transpose) - if perm == [1, 0, 2]: - data = np.swapaxes(data, 0, 1) - elif perm == [2, 1, 0]: - data = np.swapaxes(data, 0, 2) - elif perm == [0, 2, 1]: - data = np.swapaxes(data, 1, 2) - elif perm == [2, 0, 1]: - data = np.swapaxes(data, 0, 2) # transform [2, 0, 1] to [1, 0, 2] - data = np.swapaxes(data, 0, 1) # transform [1, 0, 2] to [0, 1, 2] - elif perm == [1, 2, 0]: - data = np.swapaxes(data, 0, 2) # transform [1, 2, 0] to [0, 2, 1] - data = np.swapaxes(data, 1, 2) # transform [0, 2, 1] to [0, 1, 2] - elif perm == [0, 1, 2]: - # do nothing - pass - else: - raise NotImplementedError() - - # Update header - - im_src_aff = im_src.hdr.get_best_affine() - aff = nib.orientations.inv_ornt_aff( - np.array((perm, inversion)).T, - im_src_data.shape) - im_dst_aff = np.matmul(im_src_aff, aff) - - im_dst.header.set_qform(im_dst_aff) - im_dst.header.set_sform(im_dst_aff) - im_dst.header.set_data_shape(data.shape) - im_dst.data = data - - return im_dst - - -def _get_permutations(im_src_orientation, im_dst_orientation): - """ - Copied from https://github.com/spinalcordtoolbox/spinalcordtoolbox/ - - :param im_src_orientation str: Orientation of source image. Example: 'RPI' - :param im_dest_orientation str: Orientation of destination image. Example: 'SAL' - :return: list of axes permutations and list of inversions to achieve an orientation change - """ - - opposite_character = {'L': 'R', 'R': 'L', 'A': 'P', 'P': 'A', 'I': 'S', 'S': 'I'} - - perm = [0, 1, 2] - inversion = [1, 1, 1] - for i, character in enumerate(im_src_orientation): - try: - perm[i] = im_dst_orientation.index(character) - except ValueError: - perm[i] = im_dst_orientation.index(opposite_character[character]) - inversion[i] = -1 - - return perm, inversion - - -def get_orientation(im): - """ - Copied from https://github.com/spinalcordtoolbox/spinalcordtoolbox/ - - :param im: an Image - :return: reference space string (ie. what's in Image.orientation) - """ - res = "".join(nib.orientations.aff2axcodes(im.hdr.get_best_affine())) - return orientation_string_nib2sct(res) - - -def orientation_string_nib2sct(s): - """ - Copied from https://github.com/spinalcordtoolbox/spinalcordtoolbox/ - - :return: SCT reference space code from nibabel one - """ - opposite_character = {'L': 'R', 'R': 'L', 'A': 'P', 'P': 'A', 'I': 'S', 'S': 'I'} - return "".join([opposite_character[x] for x in s]) - - -def change_type(im_src, dtype, im_dst=None): - """ - Change the voxel type of the image - - :param dtype: if not set, the image is saved in standard type\ - if 'minimize', image space is minimize\ - if 'minimize_int', image space is minimize and values are approximated to integers\ - (2, 'uint8', np.uint8, "NIFTI_TYPE_UINT8"),\ - (4, 'int16', np.int16, "NIFTI_TYPE_INT16"),\ - (8, 'int32', np.int32, "NIFTI_TYPE_INT32"),\ - (16, 'float32', np.float32, "NIFTI_TYPE_FLOAT32"),\ - (32, 'complex64', np.complex64, "NIFTI_TYPE_COMPLEX64"),\ - (64, 'float64', np.float64, "NIFTI_TYPE_FLOAT64"),\ - (256, 'int8', np.int8, "NIFTI_TYPE_INT8"),\ - (512, 'uint16', np.uint16, "NIFTI_TYPE_UINT16"),\ - (768, 'uint32', np.uint32, "NIFTI_TYPE_UINT32"),\ - (1024,'int64', np.int64, "NIFTI_TYPE_INT64"),\ - (1280, 'uint64', np.uint64, "NIFTI_TYPE_UINT64"),\ - (1536, 'float128', _float128t, "NIFTI_TYPE_FLOAT128"),\ - (1792, 'complex128', np.complex128, "NIFTI_TYPE_COMPLEX128"),\ - (2048, 'complex256', _complex256t, "NIFTI_TYPE_COMPLEX256"), - :return: - - Copied from https://github.com/spinalcordtoolbox/spinalcordtoolbox/ - """ - - if im_dst is None: - im_dst = im_src.copy() - im_dst._path = None - - if dtype is None: - return im_dst - - # get min/max from input image - min_in = np.nanmin(im_src.data) - max_in = np.nanmax(im_src.data) - - # find optimum type for the input image - if dtype in ('minimize', 'minimize_int'): - # warning: does not take intensity resolution into account, neither complex voxels - - # check if voxel values are real or integer - isInteger = True - if dtype == 'minimize': - for vox in im_src.data.flatten(): - if int(vox) != vox: - isInteger = False - break - - if isInteger: - if min_in >= 0: # unsigned - if max_in <= np.iinfo(np.uint8).max: - dtype = np.uint8 - elif max_in <= np.iinfo(np.uint16): - dtype = np.uint16 - elif max_in <= np.iinfo(np.uint32).max: - dtype = np.uint32 - elif max_in <= np.iinfo(np.uint64).max: - dtype = np.uint64 - else: - raise ValueError("Maximum value of the image is to big to be represented.") - else: - if max_in <= np.iinfo(np.int8).max and min_in >= np.iinfo(np.int8).min: - dtype = np.int8 - elif max_in <= np.iinfo(np.int16).max and min_in >= np.iinfo(np.int16).min: - dtype = np.int16 - elif max_in <= np.iinfo(np.int32).max and min_in >= np.iinfo(np.int32).min: - dtype = np.int32 - elif max_in <= np.iinfo(np.int64).max and min_in >= np.iinfo(np.int64).min: - dtype = np.int64 - else: - raise ValueError("Maximum value of the image is to big to be represented.") - else: - # if max_in <= np.finfo(np.float16).max and min_in >= np.finfo(np.float16).min: - # type = 'np.float16' # not supported by nibabel - if max_in <= np.finfo(np.float32).max and min_in >= np.finfo(np.float32).min: - dtype = np.float32 - elif max_in <= np.finfo(np.float64).max and min_in >= np.finfo(np.float64).min: - dtype = np.float64 - - dtype = to_dtype(dtype) - else: - dtype = to_dtype(dtype) - - # if output type is int, check if it needs intensity rescaling - if "int" in dtype.name: - # get min/max from output type - min_out = np.iinfo(dtype).min - max_out = np.iinfo(dtype).max - # before rescaling, check if there would be an intensity overflow - - if (min_in < min_out) or (max_in > max_out): - # This condition is important for binary images since we do not want to scale them - logger.warning(f"To avoid intensity overflow due to convertion to +{dtype.name}+, intensity will be rescaled to the maximum quantization scale") - # rescale intensity - data_rescaled = im_src.data * (max_out - min_out) / (max_in - min_in) - im_dst.data = data_rescaled - (data_rescaled.min() - min_out) - - # change type of data in both numpy array and nifti header - im_dst.data = getattr(np, dtype.name)(im_dst.data) - im_dst.hdr.set_data_dtype(dtype) - return im_dst - - -def to_dtype(dtype): - """ - Take a dtypeification and return an np.dtype - - :param dtype: dtypeification (string or np.dtype or None are supported for now) - :return: dtype or None - - Copied from https://github.com/spinalcordtoolbox/spinalcordtoolbox/ - """ - # TODO add more or filter on things supported by nibabel - - if dtype is None: - return None - if isinstance(dtype, type): - if isinstance(dtype(0).dtype, np.dtype): - return dtype(0).dtype - if isinstance(dtype, np.dtype): - return dtype - if isinstance(dtype, str): - return np.dtype(dtype) - - raise TypeError("data type {}: {} not understood".format(dtype.__class__, dtype)) - - -def zeros_like(img, dtype=None): - """ - - :param img: reference image - :param dtype: desired data type (optional) - :return: an Image with the same shape and header, filled with zeros - - Similar to numpy.zeros_like(), the goal of the function is to show the developer's - intent and avoid doing a copy, which is slower than initialization with a constant. - - Copied from https://github.com/spinalcordtoolbox/spinalcordtoolbox/image.py - """ - zimg = Image(np.zeros_like(img.data), hdr=img.hdr.copy()) - if dtype is not None: - zimg.change_type(dtype) - return zimg - - -def empty_like(img, dtype=None): - """ - :param img: reference image - :param dtype: desired data type (optional) - :return: an Image with the same shape and header, whose data is uninitialized - - Similar to numpy.empty_like(), the goal of the function is to show the developer's - intent and avoid touching the allocated memory, because it will be written to - afterwards. - - Copied from https://github.com/spinalcordtoolbox/spinalcordtoolbox/image.py - """ - dst = change_type(img, dtype) - return dst - - -def find_zmin_zmax(im, threshold=0.1): - """ - Find the min (and max) z-slice index below which (and above which) slices only have voxels below a given threshold. - - :param im: Image object - :param threshold: threshold to apply before looking for zmin/zmax, typically corresponding to noise level. - :return: [zmin, zmax] - - Copied from https://github.com/spinalcordtoolbox/spinalcordtoolbox/image.py - """ - slicer = SlicerOneAxis(im, axis="IS") - - # Make sure image is not empty - if not np.any(slicer): - logger.error('Input image is empty') - - # Iterate from bottom to top until we find data - for zmin in range(0, len(slicer)): - if np.any(slicer[zmin] > threshold): - break - - # Conversely from top to bottom - for zmax in range(len(slicer) - 1, zmin, -1): - if np.any(slicer[zmax] > threshold): - break - - return zmin, zmax \ No newline at end of file diff --git a/totalspineseg/utils/largest_component_filewise.py b/totalspineseg/utils/largest_component_filewise.py deleted file mode 100644 index 09bb46f..0000000 --- a/totalspineseg/utils/largest_component_filewise.py +++ /dev/null @@ -1,89 +0,0 @@ -import os, argparse, textwrap -from scipy.ndimage import label -from pathlib import Path -import numpy as np -import nibabel as nib - - -def get_parser(): - # parse command line arguments - parser = argparse.ArgumentParser( - description=textwrap.dedent(f''' - This script processes a NIfTI segmentation file, leaving the largest component for each label. - '''), - formatter_class=argparse.RawTextHelpFormatter - ) - parser.add_argument( - '--seg-in', type=str, required=True, - help='Input segmentation path' - ) - parser.add_argument( - '--seg-out', type=str, required=True, - help='Output segmentation path' - ) - parser.add_argument( - '--verbose', '-v', type=int, default=1, choices=[0, 1], - help='Verbosity level. 0: Errors/warnings only, 1: Errors/warnings + info (default: 1)' - ) - return parser - - -def main(): - """ - """ - parser = get_parser() - args = parser.parse_args() - - # Get arguments - seg_in = args.seg_in - seg_out = args.seg_out - verbose = args.verbose - - if verbose: - print(textwrap.dedent(f''' - Running {Path(__file__).stem} with the following params: - seg_in = "{seg_in}" - seg_out = "{seg_out}" - verbose = {verbose} - ''')) - - # Keep largest component - keep_largest_component(seg_in, seg_out) - - -def keep_largest_component( - seg_in, - seg_out - ): - - # Load segmentation - seg = nib.load(seg_in) - seg_data = seg.get_fdata() - - # Convert data to uint8 to avoid issues with segmentation IDs - seg_data_src = seg_data.astype(np.uint8) - - seg_data = np.zeros_like(seg_data_src) - - for l in [_ for _ in np.unique(seg_data_src) if _ != 0]: - mask = seg_data_src == l - mask_labeled, num_labels = label(mask, np.ones((3, 3, 3))) - # Find the label of the largest component - label_sizes = np.bincount(mask_labeled.ravel())[1:] # Skip 0 label size - largest_label = label_sizes.argmax() + 1 # +1 because bincount labels start at 0 - seg_data[mask_labeled == largest_label] = l - - # Create result segmentation - seg = nib.Nifti1Image(seg_data, seg.affine, seg.header) - seg.set_data_dtype(np.uint8) - - # Make sure output directory exists - if not os.path.exists(os.path.dirname(seg_out)): - os.makedirs(os.path.dirname(seg_out)) - - # Save mapped segmentation - nib.save(seg, seg_out) - - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/totalspineseg/utils/register_CT_to_MR.py b/totalspineseg/utils/register_CT_to_MR.py deleted file mode 100644 index 663c67f..0000000 --- a/totalspineseg/utils/register_CT_to_MR.py +++ /dev/null @@ -1,85 +0,0 @@ -import os -import argparse -import subprocess - -from totalspineseg.utils.image import Image - - -def get_parser(): - # parse command line arguments - parser = argparse.ArgumentParser(description='Register and duplicate segmentations from CT to MRI if not available.') - parser.add_argument('--path-img', required=True, type=str, help='Path to the BIDS-compliant folder where CT and MRI images are stored (Required)') - parser.add_argument('--path-label', type=str, default='', help='Path to the folder with labels where CT and MRI segmentations are stored. If not specified "--path-img + derivatives/labels" will be used') - parser.add_argument('--path-out', type=str, default='', help='Path to output directory where registered segmentations will be stored. If not specified "--path-img + derivatives/reg-labels" will be used') - parser.add_argument('--seg-suffix', type=str, default='_seg', help='Suffix used for segmentations. This suffix will be added to the raw filename to name the segmentation file. Default="_seg"') - parser.add_argument('--sacrum-idx', type=int, default=92, help='Index used to map the sacrum area. Default=92') - return parser - -def main(): - parser = get_parser() - args = parser.parse_args() - - # Fetch input variables - path_img = args.path_img - path_label = os.path.join(args.path_img, 'derivatives/labels') if not args.path_label else args.path_label - path_out = os.path.join(args.path_img, 'derivatives/reg-labels') if not args.path_out else args.path_out - suffix_seg = args.seg_suffix - - # Loop inside BIDS raw folder - for sub in os.listdir(path_img): - if sub.startswith('sub'): - # Fetch subject files - path_sub_anat = os.path.join(path_img, sub, 'anat') - path_der_sub_anat = os.path.join(path_label, sub, 'anat') - path_out_sub_anat = os.path.join(path_out, sub, 'anat') - raw_files = os.listdir(path_sub_anat) - der_files = os.listdir(path_der_sub_anat) - - # Check if segmentations are available for a given contrast - for raw_file in raw_files: - path_raw = os.path.join(path_sub_anat, raw_file) - ext = '.' + raw_file.split('_')[-1].split('.', 1)[-1] - if ext == '.nii.gz': - cont = raw_file.split('_')[-1].split(ext)[0] - out_seg = raw_file.split('.')[0] + suffix_seg + ext - file_seg = raw_file.split('.')[0] + suffix_seg + ext - - # If no segmentation is available, the CT segmentation is used instead - if not file_seg in der_files: - file_seg = '_'.join(raw_file.split('_')[:-1]) + '_CT' + suffix_seg + ext - seg_from_CT = True - - # Check if CT segmentation does not exists - if not file_seg in der_files: - raise ValueError(f'Subject {sub} has no segmentations') - else: - seg_from_CT = False - - path_seg = os.path.join(path_der_sub_anat, file_seg) - path_out_seg = os.path.join(path_out_sub_anat, out_seg) - - # Keep only sacrum segmentation - seg = Image(path_seg) - if not args.sacrum_idx in seg.data: - raise ValueError(f'The value {args.sacrum_idx} was not detected in the mask') - - seg.data[seg.data != args.sacrum_idx] = 0 - seg.data[seg.data == args.sacrum_idx] = 1 - - # Save the new segmentation in the output folder - os.makedirs(os.path.dirname(path_out_seg), exist_ok=True) - seg.save(path=path_out_seg, dtype='float32') - - # Perform a registration to align the image shapes and resolutions - subprocess.check_call([ - 'sct_register_multimodal', - '-i', path_out_seg, - '-d', path_raw, - '-o', path_out_seg, - '-identity', '1', - '-x', 'nn' - ]) - - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/totalspineseg/utils/reorient_images.py b/totalspineseg/utils/reorient_images.py deleted file mode 100644 index cf9814d..0000000 --- a/totalspineseg/utils/reorient_images.py +++ /dev/null @@ -1,106 +0,0 @@ -import sys, argparse, textwrap -import multiprocessing as mp -from functools import partial -from tqdm.contrib.concurrent import process_map -from pathlib import Path -import warnings -from totalspineseg.utils.image import Image - -warnings.filterwarnings("ignore") - - -def main(): - - # Description and arguments - parser = argparse.ArgumentParser( - description=textwrap.dedent(f''' - This script processes NIfTI (Neuroimaging Informatics Technology Initiative) images - and reorient them to a specific orientation.''' - ), - epilog=textwrap.dedent(''' - Examples: - generate_resampled_images -i images -o images - '''), - formatter_class=argparse.RawTextHelpFormatter - ) - parser.add_argument( - '--images-dir', '-i', type=Path, required=True, - help='The folder where input NIfTI images files are located (required).' - ) - parser.add_argument( - '--output-images-dir', '-o', type=Path, required=True, - help='The folder where the output images will be saved. (required).' - ) - parser.add_argument( - '--orientation', type=str, default='LPI', - help='The target orientation of the output image. Default is LPI.' - ) - parser.add_argument( - '--max-workers', '-w', type=int, default=mp.cpu_count(), - help='Max worker to run in parallel proccess, defaults to multiprocessing.cpu_count().' - ) - parser.add_argument( - '--verbose', '-v', type=int, default=1, choices=[0, 1], - help='verbose. 0: Display only errors/warnings, 1: Errors/warnings + info messages. Default is 1.' - ) - - # Parse the command-line arguments - try: - args = parser.parse_args() - except BaseException as e: - sys.exit() - - # Get the command-line argument values - images_path = args.images_dir - output_images_path = args.output_images_dir - orientation = args.orientation - max_workers = args.max_workers - verbose = args.verbose - - # Print the argument values if verbose is enabled - if verbose: - print(textwrap.dedent(f''' - Running {Path(__file__).stem} with the following params: - images_path = "{images_path}" - output_images_path = "{output_images_path}" - orientation = "{orientation}" - max_workers = {max_workers} - verbose = {verbose} - ''')) - - glob_pattern = f'*.nii.gz' - - # Process the NIfTI image and segmentation files - images_path_list = list(images_path.glob(glob_pattern)) - - # Create a partially-applied function with the extra arguments - partial_reorient_images = partial( - reorient_images, - images_path=images_path, - output_images_path=output_images_path, - orientation=orientation, - verbose=verbose - ) - - with mp.Pool() as pool: - process_map(partial_reorient_images, images_path_list, max_workers=max_workers) - - -def reorient_images( - image_path, - images_path, - output_images_path, - orientation, - verbose - ): - - # Load image - image = Image(str(image_path)).change_orientation(orientation) - - # Make sure output directory exists and save with original header image dtype - output_image_path = output_images_path / image_path.relative_to(images_path).parent / image_path.name - output_image_path.parent.mkdir(parents=True, exist_ok=True) - image.save(output_image_path, verbose=verbose) - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/totalspineseg/utils/run_nnunet_inference_single_subject.py b/totalspineseg/utils/run_nnunet_inference_single_subject.py deleted file mode 100644 index 7d8f963..0000000 --- a/totalspineseg/utils/run_nnunet_inference_single_subject.py +++ /dev/null @@ -1,216 +0,0 @@ -""" -Author: Jan Valosek -Modified by Nathan Molinier - -Copied from https://github.com/ivadomed/model-spinal-rootlets/blob/main/packaging/run_inference_single_subject.py - -This script is used to run inference on a single subject using a nnUNetV2 model. - -Note: conda environment with nnUNetV2 is required to run this script. -For details how to install nnUNetV2, see: -https://github.com/ivadomed/utilities/blob/main/quick_start_guides/nnU-Net_quick_start_guide.md#installation - -Example: - python run_inference_single_subject.py - -i sub-001_T2w.nii.gz - -o sub-001_T2w_label-rootlet.nii.gz - -path-model - -tile-step-size 0.5 - -fold 1 -""" - - -import os -import shutil -import subprocess -import argparse -import datetime - -import torch -import glob -import time -import tempfile - -from totalspineseg.utils.image import Image -from nnunetv2.inference.predict_from_raw_data import nnUNetPredictor -from batchgenerators.utilities.file_and_folder_operations import join - - -def get_parser(): - # parse command line arguments - parser = argparse.ArgumentParser(description='Segment an image using nnUNetV2 model.') - parser.add_argument('-i', help='Input image to segment. Example: sub-001_T2w.nii.gz', required=True) - parser.add_argument('-o', help='Output filename. Example: sub-001_T2w_label-sacrum_seg.nii.gz', required=True) - parser.add_argument('-path-model', help='Path to the model folder. This folder should contain individual ' - 'folders like fold_0, fold_1, etc. and dataset.json, ' - 'dataset_fingerprint.json and plans.json files.', required=True, type=str) - parser.add_argument('-use-gpu', action='store_true', default=False, - help='Use GPU for inference. Default: False') - parser.add_argument('-fold', type=str, required=True, - help='Fold(s) to use for inference. Example(s): 2 (single fold), 2,3 (multiple folds), ' - 'all (fold_all).', choices=['0', '1', '2', '3', '4', 'all']) - parser.add_argument('-use-best-checkpoint', action='store_true', default=False, - help='Use the best checkpoint (instead of the final checkpoint) for prediction. ' - 'NOTE: nnUNet by default uses the final checkpoint. Default: False') - parser.add_argument('-tile-step-size', default=0.5, type=float, - help='Tile step size defining the overlap between images patches during inference. ' - 'Default: 0.5 ' - 'NOTE: changing it from 0.5 to 0.9 makes inference faster but there is a small drop in ' - 'performance.') - - return parser - - -def tmp_create(): - """ - Create temporary folder and return its path - """ - prefix = f"sciseg_prediction_{datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}_" - tmpdir = tempfile.mkdtemp(prefix=prefix) - print(f"Creating temporary folder ({tmpdir})") - return tmpdir - - -def splitext(fname): - """ - Split a fname (folder/file + ext) into a folder/file and extension. - Note: for .nii.gz the extension is understandably .nii.gz, not .gz - (``os.path.splitext()`` would want to do the latter, hence the special case). - Taken (shamelessly) from: https://github.com/spinalcordtoolbox/manual-correction/blob/main/utils.py - """ - dir, filename = os.path.split(fname) - for special_ext in ['.nii.gz', '.tar.gz']: - if filename.endswith(special_ext): - stem, ext = filename[:-len(special_ext)], special_ext - return os.path.join(dir, stem), ext - # If no special case, behaves like the regular splitext - stem, ext = os.path.splitext(filename) - return os.path.join(dir, stem), ext - - -def add_suffix(fname, suffix): - """ - Add suffix between end of file name and extension. Taken (shamelessly) from: - https://github.com/spinalcordtoolbox/manual-correction/blob/main/utils.py - :param fname: absolute or relative file name. Example: t2.nii.gz - :param suffix: suffix. Example: _mean - :return: file name with suffix. Example: t2_mean.nii - Examples: - - add_suffix(t2.nii, _mean) -> t2_mean.nii - - add_suffix(t2.nii.gz, a) -> t2a.nii.gz - """ - stem, ext = splitext(fname) - return os.path.join(stem + suffix + ext) - - -def main(): - parser = get_parser() - args = parser.parse_args() - - fname_file = args.i - fname_file_out = args.o - print(f'\nFound {fname_file} file.') - - # Create temporary directory in the temp to store the reoriented images - tmpdir = tmp_create() - # Copy the file to the temporary directory using shutil.copyfile - fname_file_tmp = os.path.join(tmpdir, os.path.basename(fname_file)) - shutil.copyfile(fname_file, fname_file_tmp) - print(f'Copied {fname_file} to {fname_file_tmp}') - - # Get the original orientation of the image - img = Image(fname_file_tmp) - orig_orientation = img.orientation - - # Reorient the image to RPI orientation if not already in RPI - if orig_orientation != 'RPI': - print(f'Original orientation: {orig_orientation}') - print(f'Reorienting to RPI orientation...') - # reorient the image to RPI using SCT Image - img.change_orientation('RPI').save(fname_file_tmp) - - # NOTE: for individual images, the _0000 suffix is not needed. - # BUT, the images should be in a list of lists - fname_file_tmp_list = [[fname_file_tmp]] - - # Use fold_all (all train/val subjects were used for training) or specific fold(s) - folds_avail = 'all' if args.fold == 'all' else [int(f) for f in args.fold.split(',')] - print(f'Using fold(s): {folds_avail}') - - # Create directory for nnUNet prediction - tmpdir_nnunet = os.path.join(tmpdir, 'nnUNet_prediction') - fname_prediction = os.path.join(tmpdir_nnunet, os.path.basename(add_suffix(fname_file_tmp, '_pred'))) - os.mkdir(tmpdir_nnunet) - - # Run nnUNet prediction - print('Starting inference...it may take a few minutes...\n') - start = time.time() - # directly call the predict function - predictor = nnUNetPredictor( - tile_step_size=args.tile_step_size, # changing it from 0.5 to 0.9 makes inference faster - use_gaussian=True, # applies gaussian noise and gaussian blur - use_mirroring=False, # test time augmentation by mirroring on all axes - perform_everything_on_device=True if args.use_gpu else False, - device=torch.device('cuda') if args.use_gpu else torch.device('cpu'), - verbose_preprocessing=False, - allow_tqdm=True - ) - - print('Running inference on device: {}'.format(predictor.device)) - - # initializes the network architecture, loads the checkpoint - predictor.initialize_from_trained_model_folder( - join(args.path_model), - use_folds=folds_avail, - checkpoint_name='checkpoint_final.pth' if not args.use_best_checkpoint else 'checkpoint_best.pth', - ) - print('Model loaded successfully. Fetching data...') - - # NOTE: for individual files, the image should be in a list of lists - predictor.predict_from_files( - list_of_lists_or_source_folder=fname_file_tmp_list, - output_folder_or_list_of_truncated_output_files=tmpdir_nnunet, - save_probabilities=False, - overwrite=True, - num_processes_preprocessing=4, - num_processes_segmentation_export=4, - folder_with_segs_from_prev_stage=None, - num_parts=1, - part_id=0 - ) - - end = time.time() - - print('Inference done.') - total_time = end - start - print('Total inference time: {} minute(s) {} seconds\n'.format(int(total_time // 60), int(round(total_time % 60)))) - - # Copy .nii.gz file from tmpdir_nnunet to tmpdir - pred_file = glob.glob(os.path.join(tmpdir_nnunet, '*.nii.gz'))[0] - shutil.copyfile(pred_file, fname_prediction) - print(f'Copied {pred_file} to {fname_prediction}') - - # Reorient the image back to original orientation - # skip if already in RPI - if orig_orientation != 'RPI': - print(f'Reorienting to original orientation {orig_orientation}...') - # reorient the image to the original orientation using SCT Image - pred = Image(fname_prediction) - pred.change_orientation(orig_orientation).save(fname_prediction) - - # Copy level-specific (i.e., non-binary) segmentation - shutil.copyfile(fname_prediction, fname_file_out) - print(f'Copied {fname_prediction} to {fname_file_out}') - - print('Deleting the temporary folder...') - # Delete the temporary folder - shutil.rmtree(tmpdir) - - print('-' * 50) - print(f"Input file: {fname_file}") - print(f"nnUNet segmentation: {fname_file_out}") - print('-' * 50) - - -if __name__ == '__main__': - main() \ No newline at end of file From ca3395b0df0c54f72b52f0e6df8fc0bb3dc6206b Mon Sep 17 00:00:00 2001 From: Yehuda Warszawer <36595323+yw7@users.noreply.github.com> Date: Sun, 6 Oct 2024 01:58:52 +0300 Subject: [PATCH 2/5] Refactor and enhance weight installation script Refactored the weight installation script to improve clarity and usability. Added detailed help descriptions and examples for command-line arguments. Suppressed warnings to enhance user experience and adjusted the script structure to ensure clear output when not in quiet mode. This should make the script more user-friendly and self-explanatory, reducing confusion for users. --- totalspineseg/inference.py | 1 - totalspineseg/utils/install_weights.py | 86 +++++++++++++++++++------- 2 files changed, 64 insertions(+), 23 deletions(-) diff --git a/totalspineseg/inference.py b/totalspineseg/inference.py index 3c6f224..2a0ca8c 100644 --- a/totalspineseg/inference.py +++ b/totalspineseg/inference.py @@ -1,7 +1,6 @@ import os, argparse, warnings, subprocess, textwrap, torch, psutil, shutil from fnmatch import fnmatch from pathlib import Path -from urllib.request import urlretrieve from importlib.metadata import metadata from tqdm import tqdm from totalspineseg import * diff --git a/totalspineseg/utils/install_weights.py b/totalspineseg/utils/install_weights.py index 97a6415..4ad2f4c 100644 --- a/totalspineseg/utils/install_weights.py +++ b/totalspineseg/utils/install_weights.py @@ -1,44 +1,87 @@ -import os -import argparse +import os, argparse, subprocess, textwrap from pathlib import Path -import subprocess -from importlib.metadata import metadata from urllib.request import urlretrieve from tqdm import tqdm +import warnings + +warnings.filterwarnings("ignore") + def main(): - # parse command line arguments - parser = argparse.ArgumentParser(description='Download nnunet weights from totalspineseg repository.') - parser.add_argument('--nnunet-dataset', required=True, type=str, help='Name of the nnUNet dataset present in the pyproject.toml (Required)') - parser.add_argument('--zip-url', required=True, type=str, help='URL of the weights contained inside the pyproject.toml (Required)') - parser.add_argument('--results-folder', required=True, type=Path, help='Results folder where the weights will be stored (Required)') - parser.add_argument('--exports-folder', required=True, type=Path, help='Exports folder where the zipped weights will be dowloaded (Required)') - parser.add_argument('--quiet', '-q', action="store_true", default=False, help='Do not display inputs and progress bar, defaults to false (Default=False).') + + # Description and arguments + parser = argparse.ArgumentParser( + description=' '.join(f''' + This script download nnunet weights zip file from url into exports folder and install it into results folder. + '''.split()), + epilog=textwrap.dedent(''' + Examples: + install_weights --nnunet-dataset Dataset101_TotalSpineSeg_step1 --zip-url https://github.com/neuropoly/totalspineseg/releases/download/r20241005/Dataset101_TotalSpineSeg_step1_r20241005.zip --results-folder results --exports-folder exports + '''), + formatter_class=argparse.RawTextHelpFormatter + ) + parser.add_argument( + '--nnunet-dataset', type=str, required=True, + help='Name of the nnUNet dataset to install (required).' + ) + parser.add_argument( + '--zip-url', type=str, required=True, + help='URL of the zip file to download (required).' + ) + parser.add_argument( + '--results-folder', type=Path, required=True, + help='Results folder where the weights will be stored (Required).' + ) + parser.add_argument( + '--exports-folder', type=Path, required=True, + help='Exports folder where the zipped weights will be dowloaded (Required).' + ) + parser.add_argument( + '--quiet', '-q', action="store_true", default=False, + help='Do not display inputs and progress bar, defaults to false (display).' + ) + + # Parse the command-line arguments args = parser.parse_args() - # Datasets data + # Get the command-line argument values nnunet_dataset = args.nnunet_dataset - zip_url=args.zip_url + zip_url = args.zip_url results_folder = args.results_folder exports_folder = args.exports_folder quiet = args.quiet - # Install nnUNet weight + # Print the argument values if not quiet + if not quiet: + print(textwrap.dedent(f''' + Running {Path(__file__).stem} with the following params: + nnunet_dataset = "{nnunet_dataset}" + zip_url = "{zip_url}" + results_folder = "{results_folder}" + exports_folder = "{exports_folder}" + quiet = {quiet} + ''')) + install_weights( nnunet_dataset=nnunet_dataset, zip_url=zip_url, results_folder=results_folder, exports_folder=exports_folder, - quiet=quiet + quiet=quiet, ) def install_weights( - nnunet_dataset, - zip_url, - results_folder, - exports_folder, - quiet -): + nnunet_dataset, + zip_url, + results_folder, + exports_folder, + quiet=False, + ): + ''' + Download nnunet weights from url. + ''' + results_folder = Path(results_folder) + exports_folder = Path(exports_folder) # Create the download and export folder if they do not exist results_folder.mkdir(parents=True, exist_ok=True) @@ -70,6 +113,5 @@ def install_weights( os.environ['nnUNet_results'] = str(results_folder) subprocess.run(['nnUNetv2_install_pretrained_model_from_zip', str(zip_file)]) - if __name__ == '__main__': main() \ No newline at end of file From 53cb7e60bebe18757395bfccf9ea3a4826b379d4 Mon Sep 17 00:00:00 2001 From: Yehuda Warszawer <36595323+yw7@users.noreply.github.com> Date: Sun, 6 Oct 2024 03:19:49 +0300 Subject: [PATCH 3/5] Refactor file organization for input processing Changed input file handling to use a temporary 'input_raw' directory, separating the copy and processing stages. This enhances clarity by organizing raw inputs separately and reduces potential errors related to file manipulation. Added interim logging to improve the visibility of the processing steps. Cleaned up by removing 'input_raw' directory post-processing. Addresses issues with incorrect file reference paths during transformations. --- totalspineseg/inference.py | 43 +++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/totalspineseg/inference.py b/totalspineseg/inference.py index 2a0ca8c..162e1e2 100644 --- a/totalspineseg/inference.py +++ b/totalspineseg/inference.py @@ -173,14 +173,14 @@ def main(): if not quiet: print('\n' 'Making input dir with _0000 suffix:') if input_path.name.endswith('.nii.gz'): - # If the input is a single file, copy it to the input folder - (output_path / 'input').mkdir(parents=True, exist_ok=True) - shutil.copy(input_path, output_path / 'input' / input_path.name.replace('.nii.gz', '_0000.nii.gz')) + # If the input is a single file, copy it to the input_raw folder + (output_path / 'input_raw').mkdir(parents=True, exist_ok=True) + shutil.copy(input_path, output_path / 'input_raw' / input_path.name.replace('.nii.gz', '_0000.nii.gz')) else: - # If the input is a folder, copy the files to the input folder + # If the input is a folder, copy the files to the input_raw folder cpdir_mp( input_path, - output_path / 'input', + output_path / 'input_raw', pattern=sum([[f'*{s}.nii.gz', f'sub-*/anat/*{s}.nii.gz'] for s in suffix], []), flat=True, replace={'.nii.gz': '_0000.nii.gz'}, @@ -189,6 +189,15 @@ def main(): quiet=quiet, ) + if not quiet: print('\n' 'Copying the input images to the input folder for processing:') + cpdir_mp( + output_path / 'input_raw', + output_path / 'input', + overwrite=True, + max_workers=max_workers, + quiet=quiet, + ) + if loc_path is not None: if not quiet: print('\n' 'Copying localizers to the output folder:') @@ -221,6 +230,8 @@ def main(): max_workers=max_workers, quiet=quiet, ) + + if not quiet: print('\n' 'Generating preview images for the localizers with tags:') preview_jpg_mp( output_path / 'input', output_path / 'preview', @@ -412,6 +423,8 @@ def main(): max_workers=max_workers, quiet=quiet, ) + + if not quiet: print('\n' 'Generating preview images for the step 1 labeled images with tags:') preview_jpg_mp( output_path / 'input', output_path / 'preview', @@ -665,6 +678,8 @@ def main(): max_workers=max_workers, quiet=quiet, ) + + if not quiet: print('\n' 'Generating preview images for the final output with tags:') preview_jpg_mp( output_path / 'input', output_path / 'preview', @@ -690,20 +705,18 @@ def main(): if not output_iso: if not quiet: print('\n' 'Resampling step1_output to the input images space:') transform_seg2image_mp( - input_path, + output_path / 'input_raw', output_path / 'step1_output', output_path / 'step1_output', - image_suffix = '', overwrite=True, max_workers=max_workers, quiet=quiet, ) if not quiet: print('\n' 'Resampling step1_cord to the input images space:') transform_seg2image_mp( - input_path, + output_path / 'input_raw', output_path / 'step1_cord', output_path / 'step1_cord', - image_suffix = '', interpolation = 'linear', overwrite=True, max_workers=max_workers, @@ -711,10 +724,9 @@ def main(): ) if not quiet: print('\n' 'Resampling step1_canal to the input images space:') transform_seg2image_mp( - input_path, + output_path / 'input_raw', output_path / 'step1_canal', output_path / 'step1_canal', - image_suffix = '', interpolation = 'linear', overwrite=True, max_workers=max_workers, @@ -722,10 +734,9 @@ def main(): ) if not quiet: print('\n' 'Resampling step1_levels to the input images space:') transform_seg2image_mp( - input_path, + output_path / 'input_raw', output_path / 'step1_levels', output_path / 'step1_levels', - image_suffix = '', interpolation = 'label', overwrite=True, max_workers=max_workers, @@ -734,14 +745,16 @@ def main(): if not step1_only: if not quiet: print('\n' 'Resampling step2_output to the input images space:') transform_seg2image_mp( - input_path, + output_path / 'input_raw', output_path / 'step2_output', output_path / 'step2_output', - image_suffix = '', overwrite=True, max_workers=max_workers, quiet=quiet, ) + # Remove the input_raw folder + shutil.rmtree(output_path / 'input_raw', ignore_errors=True) + if __name__ == '__main__': main() \ No newline at end of file From 98bd2445c405bbbfcf18a75be2366f1dfecc737d Mon Sep 17 00:00:00 2001 From: Yehuda Warszawer <36595323+yw7@users.noreply.github.com> Date: Sun, 6 Oct 2024 03:33:51 +0300 Subject: [PATCH 4/5] Update DOI badge in README The DOI link in the README has been updated to a new identifier. This ensures that users have access to the correct version of the research record through Zenodo, maintaining the accuracy and relevance of documentation. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a68a892..c41b861 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # TotalSpineSeg -[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.13891845.svg)](https://doi.org/10.5281/zenodo.13891845) +[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.13894354.svg)](https://doi.org/10.5281/zenodo.13894354) TotalSpineSeg is a tool for automatic instance segmentation of all vertebrae, intervertebral discs (IVDs), spinal cord, and spinal canal in MRI images. It is robust to various MRI contrasts, acquisition orientations, and resolutions. The model used in TotalSpineSeg is based on [nnU-Net](https://github.com/MIC-DKFZ/nnUNet) as the backbone for training and inference. From 116413f5f88f34378453a2255f73822bc2055daa Mon Sep 17 00:00:00 2001 From: Yehuda Warszawer <36595323+yw7@users.noreply.github.com> Date: Wed, 9 Oct 2024 18:09:06 +0300 Subject: [PATCH 5/5] Remove unused TODO comment Eliminated an outdated TODO note examining a potential edge case that is no longer relevant to the current implementation. This cleanup improves code clarity by removing unnecessary comments, thereby aiding maintainability and readability. --- totalspineseg/utils/iterative_label.py | 1 - 1 file changed, 1 deletion(-) diff --git a/totalspineseg/utils/iterative_label.py b/totalspineseg/utils/iterative_label.py index 62e3802..e2c13c0 100644 --- a/totalspineseg/utils/iterative_label.py +++ b/totalspineseg/utils/iterative_label.py @@ -1064,7 +1064,6 @@ def _get_landmark_output_labels( loc_data_masked = mask * loc_data # First we try to look for the landmarks in the localizer - # TODO Edge case if map_output_dict used for discs, but it is not used in the current implementation for output_label in np.array(landmark_output_labels)[np.isin(landmark_output_labels, loc_data_masked)].tolist(): # Map the label with the most voxels in the localizer landmark to the output label map_landmark_outputs[np.argmax(np.bincount(mask_labeled_masked[loc_data_masked == output_label].flat))] = output_label