diff --git a/models/bamf_mr_brain_tumor/config/default.yml b/models/bamf_mr_brain_tumor/config/default.yml new file mode 100644 index 00000000..c967fcef --- /dev/null +++ b/models/bamf_mr_brain_tumor/config/default.yml @@ -0,0 +1,87 @@ +general: + data_base_dir: /app/data + version: 1.0 + description: Default configuration for Bamf NNUnet Brain tumor segmentation on MR scans (dicom to dicom) + +execute: +- FileStructureImporter +- NiftiConverter +- ReOrientationRunner +- BiasCorrectionRunner +- FLIRTRegistrationRunner +- SkullStripRunner +- StdRegistrationRunner +- NNUnetRunnerV2 +- InverseStdRegistrationRunner +- InverseRegistrationRunner +- module: DsegConverter + target_dicom: dicom:mod=mr:type=t1 + source_segs: nifti:mod=mr:type=t1:task=inverse +- module: DsegConverter + target_dicom: dicom:mod=mr:type=t1ce + source_segs: nifti:mod=mr:type=t1ce:task=inverse +- module: DsegConverter + target_dicom: dicom:mod=mr:type=t2 + source_segs: nifti:mod=mr:type=t2:task=inverse +- module: DsegConverter + target_dicom: dicom:mod=mr:type=flair + source_segs: nifti:mod=mr:type=flair:task=inverse +- DataOrganizer + +modules: + FileStructureImporter: + input_dir: 'input_data' + structures: + - $patientID@instance/t1@dicom:mod=mr:type=t1 + - $patientID/t1ce@dicom:mod=mr:type=t1ce + - $patientID/t2@dicom:mod=mr:type=t2 + - $patientID/flair@dicom:mod=mr:type=flair + import_id: patientID + + NiftiConverter: + allow_multi_input: True + out_datas: nifti:mod=mr:task=conversion + engine: dcm2niix + + ReOrientationRunner: + in_datas: nifti:mod=mr + + BiasCorrectionRunner: + in_datas: nifti:mod=mr:task=reorientation + + FLIRTRegistrationRunner: + in_datas: nifti:mod=mr:task=bias_corrected + reference_data: nifti:mod=mr:task=bias_corrected:type=t1ce + + SkullStripRunner: + in_datas: nifti:mod=mr:task=registration + + StdRegistrationRunner: + in_datas: nifti:mod=mr:task=skull_stripped + + NNUnetRunnerV2: + in_t1_data: nifti:mod=mr:task=std_registration:type=t1 + in_t1ce_data: nifti:mod=mr:task=std_registration:type=t1ce + in_t2_data: nifti:mod=mr:task=std_registration:type=t2 + in_flair_data: nifti:mod=mr:task=std_registration:type=flair + + InverseStdRegistrationRunner: + in_seg_data: nifti:mod=seg:model=nnunet:nnunet_dataset=Dataset002_BRATS19 + in_mat_datas: txt:task=std_registration_transform_mat + in_registration_datas: nifti:mod=mr:task=registration + + InverseRegistrationRunner: + in_seg_datas: nifti:mod=mr:task=std_inverse + in_mat_datas: txt:task=registration_transform_mat + in_registration_datas: nifti:mod=mr:task=bias_corrected + + DsegConverter: + model_name: BAMF Brain Tumor AI Segmentation + skip_empty_slices: True + + DataOrganizer: + targets: + - dicomseg:type=t1ce-->[i:patientID]/t1ce.seg.dcm + - dicomseg:type=t1-->[i:patientID]/t1.seg.dcm + - dicomseg:type=t2-->[i:patientID]/t2.seg.dcm + - dicomseg:type=flair-->[i:patientID]/flair.seg.dcm \ No newline at end of file diff --git a/models/bamf_mr_brain_tumor/dockerfiles/Dockerfile b/models/bamf_mr_brain_tumor/dockerfiles/Dockerfile new file mode 100644 index 00000000..89db7cf1 --- /dev/null +++ b/models/bamf_mr_brain_tumor/dockerfiles/Dockerfile @@ -0,0 +1,78 @@ +FROM mhubai/base:latest + +ENV DEBIAN_FRONTEND "noninteractive" +ENV LANG "en_GB.UTF-8" + +# 1. Install gcc-14 and build ants +RUN apt-get update && apt-get install -y cmake make ninja-build git bzip2 flex manpages-dev g++ wget unzip file +RUN wget https://github.com/gcc-mirror/gcc/archive/refs/tags/releases/gcc-11.4.0.zip \ + && unzip gcc-11.4.0.zip && cd gcc-releases-gcc-11.4.0 && ./contrib/download_prerequisites \ + && mkdir /app/gcc-build && cd /app/gcc-build \ + && ../gcc-releases-gcc-11.4.0/configure -v --target=x86_64-linux-gnu --prefix=/usr/local/gcc-11.4.0 --enable-checking=release --enable-languages=c,c++ --disable-multilib --program-suffix=-11.4 \ + && make -j$(nproc) && make install-strip \ + && update-alternatives --install /usr/bin/gcc gcc /usr/local/gcc-11.4.0/bin/gcc-11.4 11 --slave /usr/bin/g++ g++ /usr/local/gcc-11.4.0/bin/g++-11.4 \ + && rm -rf /app/gcc-* + +ENV PATH=/usr/local/gcc-11.4.0/bin:$PATH +ENV LD_LIBRARY_PATH="/usr/local/gcc-11.4.0/lib64:$LD_LIBRARY_PATH" + +RUN git clone https://github.com/ANTsX/ANTs.git /usr/local/src/ants \ + && mkdir /app/build && cd /app/build && cmake -DBUILD_TESTING=ON \ + -DRUN_LONG_TESTS=OFF \ + -DRUN_SHORT_TESTS=ON \ + -DBUILD_SHARED_LIBS=OFF \ + -DCMAKE_INSTALL_PREFIX=/opt/ants \ + /usr/local/src/ants \ + && cmake --build . --parallel \ + && cd /app/build/ANTS-build && cmake --build . --target test \ + && cmake --install . && rm -rf /app/build + +# 2. Install mri_convert +ENV FREESURFER_HOME="/freesurfer" +RUN git clone https://github.com/freesurfer/freesurfer.git && cd freesurfer/mri_synthstrip \ + git+https://github.com/freesurfer/surfa.git@0d83332351083b33c4da221e9d10a63a93ae7f52 \ + && mkdir -p $FREESURFER_HOME/models/ \ + && git remote add datasrc https://surfer.nmr.mgh.harvard.edu/pub/dist/freesurfer/repo/annex.git \ + && apt-get update -y && apt-get install -y git-annex \ + && git fetch datasrc && git-annex get . \ + && cp mri_synthstrip $FREESURFER_HOME \ + && cp synthstrip.*.pt $FREESURFER_HOME/models/ + +# 3. Install fsl +RUN wget https://fsl.fmrib.ox.ac.uk/fsldownloads/fslconda/releases/fslinstaller.py && \ + python3 ./fslinstaller.py -V 6.0.7.11 -d /usr/local/fsl/ && rm fslinstaller.py + +ENV PATH="/opt/ants/bin:$PATH:/$FREESURFER_HOME:/usr/local/fsl/bin" \ +LD_LIBRARY_PATH="/opt/ants/lib:$LD_LIBRARY_PATH" + +# 3. Install nnunet +# FIXME: set this environment variable as a shortcut to avoid nnunet crashing the build +# by pulling sklearn instead of scikit-learn +# N.B. this is a known issue: +# https://github.com/MIC-DKFZ/nnUNet/issues/1281 +# https://github.com/MIC-DKFZ/nnUNet/pull/1209 +ENV SKLEARN_ALLOW_DEPRECATED_SKLEARN_PACKAGE_INSTALL=True + +# 4. Install nnunet and surfa +RUN uv pip install torch==2.4.1 nnunetv2==2.4.1 surfa==0.6.1 +RUN apt-get update && apt-get install -y bc + +# Clone the main branch of MHubAI/models +ARG MHUB_MODELS_REPO +RUN buildutils/import_mhub_model.sh bamf_mr_brain_tumor ${MHUB_MODELS_REPO} + +# Pull nnUNet model weights into the container +ENV WEIGHTS_DIR=/root/.nnunet/nnUNet_models/ +RUN mkdir -p $WEIGHTS_DIR +ENV WEIGHTS_FN=Dataset002_BRATS19.zip +ENV WEIGHTS_URL=https://zenodo.org/records/11582627/files/$WEIGHTS_FN +RUN wget --directory-prefix ${WEIGHTS_DIR} ${WEIGHTS_URL} +RUN unzip ${WEIGHTS_DIR}${WEIGHTS_FN} -d ${WEIGHTS_DIR} +RUN rm ${WEIGHTS_DIR}${WEIGHTS_FN} + +# specify nnunet specific environment variables +ENV WEIGHTS_FOLDER=$WEIGHTS_DIR + +# Default run script +ENTRYPOINT ["mhub.run"] +CMD ["--config", "/app/models/bamf_mr_brain_tumor/config/default.yml"] diff --git a/models/bamf_mr_brain_tumor/meta.json b/models/bamf_mr_brain_tumor/meta.json new file mode 100644 index 00000000..d0844a92 --- /dev/null +++ b/models/bamf_mr_brain_tumor/meta.json @@ -0,0 +1,211 @@ +{ + "id": "", + "name": "bamf_mr_brain_tumor", + "title": "BAMF MR Brain Tumor", + "summary": { + "description": "An nnU-Net based model to segment brain tumors using four MR modalities Fluid Attenuated Inversion Recovery (FLAIR), native (T1), post-contrast T1-weighted (T1Gd), and T2-weighted (T2)", + "inputs": [ + { + "label": "Input Image", + "description": "native (T1) MRI scan of a patient.", + "format": "DICOM", + "modality": "MRI", + "bodypartexamined": "BRAIN", + "slicethickness": "1mm", + "non-contrast": true, + "contrast": false + }, + { + "label": "Input Image", + "description": "post-contrast T1-weighted MRI scan of a patient.", + "format": "DICOM", + "modality": "MRI", + "bodypartexamined": "BRAIN", + "slicethickness": "1mm", + "non-contrast": false, + "contrast": true + }, + { + "label": "Input Image", + "description": "T2-weighted MRI scan of a patient.", + "format": "DICOM", + "modality": "MRI", + "bodypartexamined": "BRAIN", + "slicethickness": "1mm", + "non-contrast": true, + "contrast": false + }, + { + "label": "Input Image", + "description": "T2 Fluid Attenuated Inversion Recovery (T2-FLAIR) MRI scan of a patient.", + "format": "DICOM", + "modality": "MRI", + "bodypartexamined": "BRAIN", + "slicethickness": "1mm", + "non-contrast": true, + "contrast": false + } + ], + "outputs": [ + { + "label": "T1 Segmentation", + "type": "Segmentation", + "description": "Tumor Segmentation of T1 MRI scan", + "classes": [ + "NECROSIS", + "EDEMA", + "ENHANCING_LESION" + ] + }, + { + "label": "T1ce Segmentation", + "type": "Segmentation", + "description": "Tumor Segmentation of T1ce MRI scan", + "classes": [ + "NECROSIS", + "EDEMA", + "ENHANCING_LESION" + ] + }, + { + "label": "T2 Segmentation", + "type": "Segmentation", + "description": "Tumor Segmentation of T2 MRI scan", + "classes": [ + "NECROSIS", + "EDEMA", + "ENHANCING_LESION" + ] + }, + { + "label": "FLAIR Segmentation", + "type": "Segmentation", + "description": "Tumor Segmentation of FLAIR MRI scan", + "classes": [ + "NECROSIS", + "EDEMA", + "ENHANCING_LESION" + ] + } + ], + "model": { + "architecture": "U-net", + "training": "supervised", + "cmpapproach": "3D" + }, + "data": { + "training": { + "vol_samples": 1251 + }, + "evaluation": { + "vol_samples": 45 + }, + "public": true, + "external": true + } + }, + "details": { + "name": "AIMI MR Brain Tumor", + "version": "1.0.0", + "devteam": "BAMF Health", + "authors": [ + "Soni, Rahul", + "McCrumb, Diana", + "Murugesan, Gowtham Krishnan", + "Van Oss, Jeff", + "Jithendra Kumar" + ], + "type": "nnU-Net (U-Net structure, optimized by data-driven heuristics)", + "date": { + "code": "30.08.2024", + "weights": "11.06.2024", + "pub": "30.08.2024" + }, + "cite": "Gowtham Krishnan Murugesan, Diana McCrumb, Rahul Soni, Jithendra Kumar, Leonard Nuernberg, Linmin Pei, Ulrike Wagner, Sutton Granger, Andrey Y. Fedorov, Stephen Moore, Jeff Van Oss. AI generated annotations for Breast, Brain, Liver, Lungs and Prostate cancer collections in National Cancer Institute Imaging Data Commons. arXiv:2409.20342 (2024).", + "license": { + "code": "MIT", + "weights": "CC BY-NC 4.0" + }, + "publications": [ + { + "title": "AI generated annotations for Breast, Brain, Liver, Lungs and Prostate cancer collections in National Cancer Institute Imaging Data Commons", + "uri": "https://arxiv.org/abs/2409.20342" + } + ], + "github": "https://github.com/bamf-health/aimi-brain-mr" + }, + "info": { + "use": { + "title": "Intended Use", + "text": "To perform tumor segmentation in Brain MRI scans, you need four MRI modalities—T1-weighted, T1 with contrast enhancement (T1ce), T2-weighted, and FLAIR—each providing critical information about brain anatomy and tumor characteristics. The data should be organized in separated directories per instance (e.g., named by a unique sample or patient identifier) with one subfolder for each of the four input modalities, containing the respective DICOM files (namely 't1', 't1ce', 't2', and 'flair'). The model processes these inputs to produce three segmentation maps for necrosis, edema, and enhancing tumor regions. These outputs are vital for clinical applications like treatment planning, monitoring tumor progression, and prognosis, as well as for research projects focused on developing new algorithms, identifying imaging biomarkers, and integrating automated segmentation into clinical workflows" + }, + "analyses": { + "title": "Quantitative Analyses", + "text": "The model's performance was assessed using the Dice Coefficient and Normalized Surface Distance (NSD) with tolerance 7mm. The analysis is published here [2]", + "tables": [ + { + "label": "Label-wise metrics Dice score between AI derived and manually corrected annotations", + "entries": { + "Whole Tumor": "0.98±0.07", + "Enhancing Tumor": "0.95±0.13", + "Non enhancing Tumor": "0.97±0.08" + } + }, + { + "label": "Label-wise metrics 95% Hausdorff Distance between AI derived and manually corrected annotations", + "entries": { + "Whole Tumor": "6.88±0.34", + "Enhancing Tumor": "6.57±0.24", + "Non enhancing Tumor": "0.42±1.05" + } + }, + { + "label": "Label-wise metrics NSD score between AI derived and manually corrected annotations", + "entries": { + "Whole Tumor": "0.98±0.04", + "Enhancing Tumor": "0.99±0.03", + "Non enhancing Tumor": "0.97±0.09" + } + } + ], + "references": [ + { + "label": "UPENN-GBM", + "uri": "https://www.cancerimagingarchive.net/collection/upenn-gbm/" + }, + { + "label": "AI generated annotations for Breast, Brain, Liver, Lungs and Prostate cancer collections in National Cancer Institute Imaging Data Commons", + "uri": "https://arxiv.org/abs/2409.20342" + } + ] + }, + "evaluation": { + "title": "Evaluation Data", + "text": "The model was used to segment 541 cases from the IDC [1] collection UPENN-GBM [1]. Approximately 10% of these annotations, 45 cases were randomly selected to be reviewed and corrected by a board-certified radiologist. Quality metrics such as Dice coefficient, normalized surface distance (NSD), and detection accuracy were reported", + "references": [ + { + "label": "Imaging Data Collections (IDC)", + "uri": "https://datacommons.cancer.gov/repository/imaging-data-commons" + }, + { + "label": "UPENN-GBM", + "uri": "https://www.cancerimagingarchive.net/collection/upenn-gbm/" + } + ] + }, + "training": { + "title": "Training Data", + "text": "The training dataset used from the BraTS21 challenge consists of 1,251 brain mpMRI scans along with segmentation annotations of tumorous regions. The 3D volumes were skull-stripped and resampled to 1 mm isotropic resolution, with dimensions of (240, 240, 155) voxels. For each example, four modalities were given: Fluid Attenuated Inversion Recovery (FLAIR), native (T1), post-contrast T1-weighted (T1Gd), and T2-weighted (T2). Annotations consisted of four classes: 1 for necrotic tumor core (NCR), 2 for peritumoral edematous tissue (ED), 4 for enhancing tumor (ET), and 0 for background (voxels that are not part of the tumor).", + "references": [ + { + "label": "Brain Tumor Segmentation (BraTS)", + "uri": "http://braintumorsegmentation.org" + } + ] + }, + "limitations": { + "title": "Limitations", + "text": "The model has been trained and tested on scans acquired during clinical care of patients, so it might not be suited for a healthy population. The generalization capabilities of the model on a range of ages, genders, and ethnicities are unknown." + } + } +} \ No newline at end of file diff --git a/models/bamf_mr_brain_tumor/mhub.toml b/models/bamf_mr_brain_tumor/mhub.toml new file mode 100644 index 00000000..ee6afc32 --- /dev/null +++ b/models/bamf_mr_brain_tumor/mhub.toml @@ -0,0 +1,3 @@ + +[model.deployment] +test = "https://zenodo.org/records/13880663/files/bamf_mr_brain_tumor.test.zip?download=1" \ No newline at end of file diff --git a/models/bamf_mr_brain_tumor/src/templates/T1_brain.nii b/models/bamf_mr_brain_tumor/src/templates/T1_brain.nii new file mode 100644 index 00000000..d05c2b07 Binary files /dev/null and b/models/bamf_mr_brain_tumor/src/templates/T1_brain.nii differ diff --git a/models/bamf_mr_brain_tumor/utils/BiasCorrectionRunner.py b/models/bamf_mr_brain_tumor/utils/BiasCorrectionRunner.py new file mode 100755 index 00000000..042d3877 --- /dev/null +++ b/models/bamf_mr_brain_tumor/utils/BiasCorrectionRunner.py @@ -0,0 +1,52 @@ +""" +------------------------------------------------- +MHub - Bias Correction Module +------------------------------------------------- +------------------------------------------------- +Author: Jithendra +Email: jithendra.kumar@bamfhealth.com +------------------------------------------------- +""" + +from mhubio.core import Module, Instance, InstanceDataCollection, InstanceData, DataType, FileType +from mhubio.core.IO import IO + +@IO.ConfigInput('in_datas', 'nifti:mod=mr', the="target data that will be bias corrected") +class BiasCorrectionRunner(Module): + """ + The N4 bias field correction algorithm is a popular method for correcting low frequency intensity + non-uniformity present in MRI image data known as a bias or gain field. + """ + + @IO.Instance() + @IO.Inputs('in_datas', the="data to be converted") + @IO.Outputs('out_datas', path='[filename].nii.gz', dtype='nifti:task=bias_corrected', data='in_datas', bundle='bias_correction', auto_increment=True, the="converted data") + def task(self, instance: Instance, in_datas: InstanceDataCollection, out_datas: InstanceDataCollection, **kwargs) -> None: + + # some sanity checks + assert isinstance(in_datas, InstanceDataCollection) + assert isinstance(out_datas, InstanceDataCollection) + assert len(in_datas) == len(out_datas) + + # filtered collection must not be empty + if len(in_datas) == 0: + self.v(f"CONVERT ERROR: no data found in instance {str(instance)}.") + return None + + # conversion step + for i, in_data in enumerate(in_datas): + out_data = out_datas.get(i) + + # N4 Bias Field Correction command + n4_correction_command = [ + "N4BiasFieldCorrection", + "-d", + "3", # 3D image + "-i", + str(in_data.abspath), + "-o", + str(out_data.abspath), + ] + self.v("running N4BiasFieldCorrection....", n4_correction_command) + # Run the N4 Bias Field Correction + self.subprocess(n4_correction_command, text=True) diff --git a/models/bamf_mr_brain_tumor/utils/FLIRTRegistrationRunner.py b/models/bamf_mr_brain_tumor/utils/FLIRTRegistrationRunner.py new file mode 100755 index 00000000..ae64f796 --- /dev/null +++ b/models/bamf_mr_brain_tumor/utils/FLIRTRegistrationRunner.py @@ -0,0 +1,74 @@ +""" +------------------------------------------------- +MHub - FLIRT Registration Module +------------------------------------------------- + +------------------------------------------------- +Author: Jithendra +Email: jithendra.kumar@bamfhealth.com +------------------------------------------------- +""" + +from typing import List +from pathlib import Path +from mhubio.core import Module, Instance, InstanceDataCollection, InstanceData, DataType, FileType +from mhubio.core.IO import IO + +@IO.ConfigInput('in_datas', 'nifti:mod=mr', the="target data that will be registered") +@IO.ConfigInput('reference_data', 'nifti:mod=mr', the="reference data all segmentations register to") +@IO.Config('degrees_of_freedom', str, '6', the='degrees of freedom for registration') +class FLIRTRegistrationRunner(Module): + """ + # Rigid registration using FLIRT + """ + in_datas: List[DataType] + reference_data: DataType + degrees_of_freedom: str + + @IO.Instance() + @IO.Inputs('in_datas', the="data to be converted") + @IO.Input('reference_data', the="reference data all segmentations register to") + @IO.Outputs('out_datas', path='[filename].nii.gz', dtype='nifti:task=registration', + data='in_datas', bundle='t1c_registration', auto_increment=True, the="converted data") + @IO.Outputs('out_mat_datas', path='[filename]_transform_mat.txt', dtype='txt:task=registration_transform_mat', + data='in_datas', bundle='t1c_registration', auto_increment=True, the="transformation matrix data") + def task(self, instance: Instance, in_datas: InstanceDataCollection, reference_data : InstanceData, out_datas: InstanceDataCollection, out_mat_datas: InstanceDataCollection, **kwargs) -> None: + """ + 6 Degrees of Freedom (Rigid Registration) + Description: Allows for translation and rotation. + Parameters Controlled: + 3 for translation (x, y, z) + 3 for rotation (pitch, yaw, roll) + """ + # some sanity checks + assert isinstance(in_datas, InstanceDataCollection) + assert isinstance(out_datas, InstanceDataCollection) + assert len(in_datas) == len(out_datas) + + # filtered collection must not be empty + if len(in_datas) == 0: + self.v(f"CONVERT ERROR: no data found in instance {str(instance)}.") + return None + + # conversion step + for i, in_data in enumerate(in_datas): + out_data = out_datas.get(i) + out_mat_data = out_mat_datas.get(i) + + reference_path = reference_data.abspath + + cmd = [ + "flirt", + "-in", + str(in_data.abspath), + "-ref", + str(reference_path), + "-out", + str(out_data.abspath), + "-omat", + str(out_mat_data.abspath), # Save the transformation matrix + "-dof", + self.degrees_of_freedom, # 6 degrees of freedom for rigid registration + ] + self.v("running FLIRT....", cmd) + self.subprocess(cmd, text=True) diff --git a/models/bamf_mr_brain_tumor/utils/InverseRegistrationRunner.py b/models/bamf_mr_brain_tumor/utils/InverseRegistrationRunner.py new file mode 100755 index 00000000..c856d347 --- /dev/null +++ b/models/bamf_mr_brain_tumor/utils/InverseRegistrationRunner.py @@ -0,0 +1,105 @@ +""" +------------------------------------------------- +MHub - Inverse Registration Module to t1c mr brain +------------------------------------------------- + +------------------------------------------------- +Author: Jithendra +Email: jithendra.kumar@bamfhealth.com +------------------------------------------------- +""" + +from typing import List +from mhubio.core import Module, Instance, InstanceDataCollection, InstanceData, DataType, FileType +from mhubio.core.IO import IO +import SimpleITK as sitk + +import os, subprocess, uuid + +@IO.ConfigInput('in_seg_datas', 'nifti', the="data to be converted") +@IO.ConfigInput('in_mat_datas', 'txt', the="transformation matrix data") +@IO.ConfigInput('in_registration_datas', 'nifti:mod=mr', the="registered data") +class InverseRegistrationRunner(Module): + """ + # Inverse registration using FLIRT + """ + in_seg_datas: List[DataType] + in_mat_datas: List[DataType] + in_registration_datas: List[DataType] + + @IO.Instance() + @IO.Inputs('in_seg_datas', the="data to be converted") + @IO.Inputs('in_mat_datas', the="transformation matrix data") + @IO.Inputs('in_registration_datas', the="registered data") + @IO.Outputs('out_datas', path='[filename].nii.gz', dtype='nifti:task=inverse:roi=NECROSIS,EDEMA,ENHANCING_LESION', + data='in_registration_datas', bundle='inverse_t1c_registration', auto_increment=True, the="converted data") + def task(self, instance: Instance, in_seg_datas : InstanceDataCollection, in_mat_datas: InstanceDataCollection, in_registration_datas: InstanceDataCollection, out_datas: InstanceDataCollection, **kwargs) -> None: + + # some sanity checks + assert isinstance(in_registration_datas, InstanceDataCollection) + assert isinstance(out_datas, InstanceDataCollection) + assert len(in_registration_datas) == len(out_datas) + + # filtered collection must not be empty + if len(in_registration_datas) == 0: + self.v(f"CONVERT ERROR: no data found in instance {str(instance)}.") + return None + + process_dir = self.config.data.requestTempDir(label="inverse-processor") + + # conversion step + for i, in_data in enumerate(in_registration_datas): + in_mat = in_mat_datas.get(i) + out_data = out_datas.get(i) + in_seg_data = in_seg_datas.get(i) + + # check datatype + reverse_transformation_matrix = os.path.join(process_dir, f'{str(uuid.uuid4())}_reverse.mat') + reverse_transformation_file = os.path.join(process_dir, f'{str(uuid.uuid4())}.nii.gz') + # Command to convert the transformation matrices + convert_command = [ + "convert_xfm", + "-omat", + str(reverse_transformation_matrix), + "-inverse", + str(in_mat.abspath), + ] + + try: + self.v("Converting transformation matrices...",convert_command) + self.subprocess(convert_command, text=True) + self.v("Transformation matrices converted successfully.") + except subprocess.CalledProcessError as e: + self.v("Error converting transformation matrices:", e) + # Inverse registration using FLIRT with the saved transformation matrix + cmd = [ + "flirt", + "-in", + str(in_seg_data.abspath), + "-ref", + str(in_data.abspath), + "-out", + str(reverse_transformation_file), + "-init", + str( + reverse_transformation_matrix + ), # Use the saved transformation matrix for inverse registration + "-cost", + "normmi", + "-dof", + "12", # 6 degrees of freedom for rigid registration + "-interp", + "nearestneighbour", # Interpolation method (adjust as needed) + "-applyxfm", + ] + self.v("inverse transformation flirt...",cmd) + self.subprocess(cmd, text=True) + # Load your image + image = sitk.ReadImage(reverse_transformation_file) + + # Change the label from 4 to 3 + label_map = {4: 3} + changed_image = sitk.ChangeLabel(image, changeMap=label_map) + + # Add the converted data to the output collection + sitk.WriteImage(changed_image, out_data.abspath) \ No newline at end of file diff --git a/models/bamf_mr_brain_tumor/utils/InverseStdRegistrationRunner.py b/models/bamf_mr_brain_tumor/utils/InverseStdRegistrationRunner.py new file mode 100755 index 00000000..8c5e0b82 --- /dev/null +++ b/models/bamf_mr_brain_tumor/utils/InverseStdRegistrationRunner.py @@ -0,0 +1,92 @@ +""" +------------------------------------------------- +MHub - Inverse Registration Module to atlas t1 brain +------------------------------------------------- + +------------------------------------------------- +Author: Jithendra +Email: jithendra.kumar@bamfhealth.com +------------------------------------------------- +""" + +from typing import List +from mhubio.core import Module, Instance, InstanceDataCollection, InstanceData, DataType, FileType +from mhubio.core.IO import IO + +import os, subprocess, uuid + + +@IO.ConfigInput('in_seg_data', 'nifti:mod=mr', the="data to be converted") +@IO.ConfigInput('in_mat_datas', 'txt', the="transformation matrix data") +@IO.ConfigInput('in_registration_datas', 'nifti:mod=mr', the="registered data") +class InverseStdRegistrationRunner(Module): + """ + # Inverse registration using FLIRT + """ + in_seg_data: DataType + in_mat_datas: List[DataType] + in_registration_datas: List[DataType] + + @IO.Instance() + @IO.Input('in_seg_data', the="data to be converted") + @IO.Inputs('in_mat_datas', the="transformation matrix data") + @IO.Inputs('in_registration_datas', the="registered data") + @IO.Outputs('out_datas', path='[filename].nii.gz', dtype='nifti:task=std_inverse', data='in_mat_datas', bundle='inverse_atlas_registration', auto_increment=True, the="converted data") + def task(self, instance: Instance, in_seg_data : InstanceData,in_mat_datas: InstanceDataCollection, in_registration_datas: InstanceDataCollection, out_datas: InstanceDataCollection, **kwargs) -> None: + + # some sanity checks + assert isinstance(in_registration_datas, InstanceDataCollection) + assert isinstance(out_datas, InstanceDataCollection) + assert len(in_registration_datas) == len(out_datas) + + # filtered collection must not be empty + if len(in_registration_datas) == 0: + self.v(f"CONVERT ERROR: no data found in instance {str(instance)}.") + return None + + process_dir = self.config.data.requestTempDir(label="inverse-processor") + + # conversion step + for i, in_data in enumerate(in_registration_datas): + in_mat = in_mat_datas.get(i) + out_data = out_datas.get(i) + + reverse_transformation_matrix = os.path.join(process_dir, f'{str(uuid.uuid4())}_reverse.mat') + # Command to convert the transformation matrices + convert_command = [ + "convert_xfm", + "-omat", + str(reverse_transformation_matrix), + "-inverse", + str(in_mat.abspath), + ] + + try: + self.v("Converting transformation matrices...",convert_command) + self.subprocess(convert_command, text=True) + self.v("Transformation matrices converted successfully.") + except subprocess.CalledProcessError as e: + self.v("Error converting transformation matrices:", e) + # Inverse registration using FLIRT with the saved transformation matrix + cmd = [ + "flirt", + "-in", + str(in_seg_data.abspath), + "-ref", + str(in_data.abspath), + "-out", + str(out_data.abspath), + "-init", + str( + reverse_transformation_matrix + ), # Use the saved transformation matrix for inverse registration + "-cost", + "normmi", + "-dof", + "12", # 6 degrees of freedom for rigid registration + "-interp", + "nearestneighbour", # Interpolation method (adjust as needed) + "-applyxfm", + ] + self.v("inverse transformation flirt...",cmd) + self.subprocess(cmd, text=True) diff --git a/models/bamf_mr_brain_tumor/utils/NNUnetRunnerV2.py b/models/bamf_mr_brain_tumor/utils/NNUnetRunnerV2.py new file mode 100644 index 00000000..08d5ce9d --- /dev/null +++ b/models/bamf_mr_brain_tumor/utils/NNUnetRunnerV2.py @@ -0,0 +1,97 @@ +""" +------------------------------------------------- +MHub - NNU-Net Runner v2 + Custom Runner for pre-trained nnunet v2 models. +------------------------------------------------- + +------------------------------------------------- +Author: Jithendra +Email: jithendra.kumar@bamfhealth.com +------------------------------------------------- +""" + +import os, shutil +from mhubio.core import Module, Instance, InstanceData, DataType, FileType, IO + + +@IO.ConfigInput('in_t1_data', 'nifti:mod=mr', the="input ct data to run nnunet on") +@IO.ConfigInput('in_t1ce_data', 'nifti:mod=mr', the="input pt data to run nnunet on") +@IO.ConfigInput('in_t2_data', 'nifti:mod=mr', the="input pt data to run nnunet on") +@IO.ConfigInput('in_flair_data', 'nifti:mod=mr', the="input pt data to run nnunet on") +class NNUnetRunnerV2(Module): + + nnunet_dataset: str = 'Dataset002_BRATS19' + nnunet_config: str = '3d_fullres' + input_data_type: DataType + + @IO.Instance() + @IO.Input("in_t1_data", the="input data to run nnunet on") + @IO.Input("in_t1ce_data", the="input data to run nnunet on") + @IO.Input("in_t2_data", the="input data to run nnunet on") + @IO.Input("in_flair_data", the="input data to run nnunet on") + @IO.Output("out_data", 'VOLUME_001.nii.gz', 'nifti:mod=seg:model=nnunet', data='in_t1_data', the="output data from nnunet") + def task(self, instance: Instance, in_t1ce_data: InstanceData, in_t1_data: InstanceData, + in_t2_data: InstanceData, in_flair_data: InstanceData, out_data: InstanceData) -> None: + + # get the nnunet model to run + self.v("Running nnUNetv2_predict.") + self.v(f" > dataset: {self.nnunet_dataset}") + self.v(f" > config: {self.nnunet_config}") + self.v(f" > output data: {out_data.abspath}") + + # download weights if not found + # NOTE: only for testing / debugging. For productiio always provide the weights in the Docker container. + if not os.path.isdir(os.path.join(os.environ["WEIGHTS_FOLDER"], '')): + print("Downloading nnUNet model weights...") + bash_command = ["nnUNet_download_pretrained_model", self.nnunet_dataset] + self.subprocess(bash_command, text=True) + + inp_dir = self.config.data.requestTempDir(label="nnunet-model-inp") + inp_file = f'VOLUME_001_0000.nii.gz' + shutil.copyfile(in_t1ce_data.abspath, os.path.join(inp_dir, inp_file)) + + inp_file = f'VOLUME_001_0001.nii.gz' + shutil.copyfile(in_t1_data.abspath, os.path.join(inp_dir, inp_file)) + + inp_file = f'VOLUME_001_0002.nii.gz' + shutil.copyfile(in_t2_data.abspath, os.path.join(inp_dir, inp_file)) + + inp_file = f'VOLUME_001_0003.nii.gz' + shutil.copyfile(in_flair_data.abspath, os.path.join(inp_dir, inp_file)) + + # define output folder (temp dir) and also override environment variable for nnunet + out_dir = self.config.data.requestTempDir(label="nnunet-model-out") + os.environ['nnUNet_results'] = out_dir + + # create symlink in python + # NOTE: this is a workaround for the nnunet bash script that expects the model data to be in a output folder + # structure. This is not the case for the mhub data structure. + os.symlink(os.path.join(os.environ['WEIGHTS_FOLDER'], self.nnunet_dataset), os.path.join(out_dir, self.nnunet_dataset)) + + # construct nnunet inference command + bash_command = ["nnUNetv2_predict"] + bash_command += ["-i", str(inp_dir)] + bash_command += ["-o", str(out_dir)] + bash_command += ["-d", self.nnunet_dataset] + bash_command += ["-c", self.nnunet_config] + + self.v(f" > bash_command: {bash_command}") + # run command + self.subprocess(bash_command, text=True) + + # output meta + meta = { + "model": "nnunet", + "nnunet_dataset": self.nnunet_dataset, + "nnunet_config": self.nnunet_config + } + + # get output data + out_file = f'VOLUME_001.nii.gz' + out_path = os.path.join(out_dir, out_file) + + # copy output data to instance + shutil.copyfile(out_path, out_data.abspath) + + # update meta dynamically + out_data.type.meta += meta diff --git a/models/bamf_mr_brain_tumor/utils/ReOrientationRunner.py b/models/bamf_mr_brain_tumor/utils/ReOrientationRunner.py new file mode 100755 index 00000000..c7a7f0b5 --- /dev/null +++ b/models/bamf_mr_brain_tumor/utils/ReOrientationRunner.py @@ -0,0 +1,64 @@ +""" +------------------------------------------------- +MHub - Reorientation Module +------------------------------------------------- + +------------------------------------------------- +Author: Jithendra +Email: jithendra.kumar@bamfhealth.com +------------------------------------------------- +""" + +from pathlib import Path +from mhubio.core import Module, Instance, InstanceDataCollection, InstanceData, DataType, FileType +from mhubio.core.IO import IO + +import os + +@IO.ConfigInput('in_datas', 'nifti:mod=mr', the="target data that will be reoriented") +class ReOrientationRunner(Module): + """ + Reorient images to RAI (RIGHT, ANTERIOR, POSTERIOR) + """ + + def find_fsl(self, default_path="/usr/local/fsl/"): + if "FSLDIR" not in os.environ: + os.environ["FSLDIR"]="/usr/local/fsl" + # The fsl.sh shell setup script adds the FSL binaries to the PATH + self.fsl_path = Path(os.environ["FSLDIR"]) + os.environ["FSLOUTPUTTYPE"]="NIFTI_GZ" + os.environ["FSLTCLSH"]=f"{self.fsl_path}/bin/fsltclsh" + os.environ["FSLWISH"]=f"${self.fsl_path}/bin/fslwish" + os.environ["FSL_SKIP_GLOBA"]="0" + os.environ["FSLMULTIFILEQUIT"]="TRUE" + assert Path(self.fsl_path/"bin/flirt").exists(), "FSL installation not found" + + @IO.Instance() + @IO.Inputs('in_datas', the="data to be converted") + @IO.Outputs('out_datas', path='[filename].nii.gz', dtype='nifti:task=reorientation', data='in_datas', bundle='reorientation', auto_increment=True, the="converted data") + def task(self, instance: Instance, in_datas: InstanceDataCollection, out_datas: InstanceDataCollection, **kwargs) -> None: + + # some sanity checks + assert isinstance(in_datas, InstanceDataCollection) + assert isinstance(out_datas, InstanceDataCollection) + assert len(in_datas) == len(out_datas) + + # filtered collection must not be empty + if len(in_datas) == 0: + self.v(f"CONVERT ERROR: no data found in instance {str(instance)}.") + return None + + # conversion step + for i, in_data in enumerate(in_datas): + out_data = out_datas.get(i) + + # for nrrd files use plastimatch + self.find_fsl() + reorient_command = [ + str(self.fsl_path / "bin" / "fslreorient2std"), + str(in_data.abspath), + str(out_data.abspath), + "-s", + ] + self.v("reorienting....", reorient_command) + self.subprocess(reorient_command, text=True) diff --git a/models/bamf_mr_brain_tumor/utils/SkullStripRunner.py b/models/bamf_mr_brain_tumor/utils/SkullStripRunner.py new file mode 100755 index 00000000..b94ac25d --- /dev/null +++ b/models/bamf_mr_brain_tumor/utils/SkullStripRunner.py @@ -0,0 +1,51 @@ +""" +------------------------------------------------- +MHub - Skull Stripping Module +------------------------------------------------- + +------------------------------------------------- +Author: Jithendra +Email: jithendra.kumar@bamfhealth.com +------------------------------------------------- +""" + +from enum import Enum +from typing import List, Dict, Any +from pathlib import Path +from mhubio.core import Module, Instance, InstanceDataCollection, InstanceData, DataType, FileType +from mhubio.core.IO import IO + +import os, subprocess + +@IO.ConfigInput('in_datas', 'nifti:mod=mr', the="target data that will be skull stripped") +class SkullStripRunner(Module): + """ + Skull Strip images + """ + + @IO.Instance() + @IO.Inputs('in_datas', the="data to be converted") + @IO.Outputs('out_datas', path='[filename].nii.gz', dtype='nifti:task=skull_stripped', data='in_datas', bundle='skull_stripping', auto_increment=True, the="converted data") + def task(self, instance: Instance, in_datas: InstanceDataCollection, out_datas: InstanceDataCollection, **kwargs) -> None: + + # some sanity checks + assert isinstance(in_datas, InstanceDataCollection) + assert isinstance(out_datas, InstanceDataCollection) + assert len(in_datas) == len(out_datas) + + # filtered collection must not be empty + if len(in_datas) == 0: + self.v(f"CONVERT ERROR: no data found in instance {str(instance)}.") + return None + + # conversion step + for i, in_data in enumerate(in_datas): + out_data = out_datas.get(i) + synth_cmd = [ + "mri_synthstrip", + "-i", + str(in_data.abspath), + "-o", + str(out_data.abspath), + ] + self.subprocess(synth_cmd, text=True) diff --git a/models/bamf_mr_brain_tumor/utils/StdRegistrationRunner.py b/models/bamf_mr_brain_tumor/utils/StdRegistrationRunner.py new file mode 100755 index 00000000..aa2b0c09 --- /dev/null +++ b/models/bamf_mr_brain_tumor/utils/StdRegistrationRunner.py @@ -0,0 +1,77 @@ +""" +------------------------------------------------- +MHub - FLIRT Registration Module to standard atlas t1 brain +------------------------------------------------- + +------------------------------------------------- +Author: Jithendra +Email: jithendra.kumar@bamfhealth.com +------------------------------------------------- + +""" + +from enum import Enum +from typing import List, Dict, Any +from pathlib import Path +from mhubio.core import Module, Instance, InstanceDataCollection, InstanceData, DataType, FileType +from mhubio.core.IO import IO + +import os, subprocess + +REFERENCE_PATH = Path(__file__).parent.parent / "src" / "templates" / "T1_brain.nii" + +@IO.ConfigInput('in_datas', 'nifti:mod=mr', the="target data that will be registered") +class StdRegistrationRunner(Module): + """ + # Rigid registration using FLIRT + """ + in_datas: List[DataType] + + @IO.Instance() + @IO.Inputs('in_datas', the="data to be converted") + @IO.Outputs('out_datas', path='[filename].nii.gz', dtype='nifti:task=std_registration', + data='in_datas', bundle='atlas_registration', auto_increment=True, the="converted data") + @IO.Outputs('out_mat_datas', path='[filename]_transform_mat.txt', dtype='txt:task=std_registration_transform_mat', + data='in_datas', bundle='atlas_registration', auto_increment=True, the="transformation matrix data") + def task(self, instance: Instance, in_datas: InstanceDataCollection, out_datas: InstanceDataCollection, + out_mat_datas: InstanceDataCollection, **kwargs) -> None: + """ + 12 Degrees of Freedom (Affine Registration) + Description: Allows for translation, rotation, scaling, and shearing. + Parameters Controlled: + 3 for translation (x, y, z) + 3 for rotation (pitch, yaw, roll) + 3 for scaling (scale in x, y, z directions) + 3 for shearing (xy, xz, yz) + """ + + # some sanity checks + assert isinstance(in_datas, InstanceDataCollection) + assert isinstance(out_datas, InstanceDataCollection) + assert len(in_datas) == len(out_datas) + + # filtered collection must not be empty + if len(in_datas) == 0: + self.v(f"CONVERT ERROR: no data found in instance {str(instance)}.") + return None + + # conversion step + for i, in_data in enumerate(in_datas): + out_data = out_datas.get(i) + out_mat_data = out_mat_datas.get(i) + + cmd = [ + "flirt", + "-in", + str(in_data.abspath), + "-ref", + str(REFERENCE_PATH), + "-out", + str(out_data.abspath), + "-omat", + str(out_mat_data.abspath), # Save the transformation matrix + "-dof", + "12", + ] + self.v(f" > bash_command: {cmd}") + self.subprocess(cmd, text=True)