Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,29 @@ jobs:
path: ${{github.workspace}}/dist/raidionicsseg-*.whl
if-no-files-found: error

setup-test-data:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.9

- name: Download test resources
working-directory: tests
run: |
pip install requests
python -c "from download_resources import download_resources; download_resources('../test_data')"

- name: Upload test resources
uses: actions/upload-artifact@v4
with:
name: test-resources
path: ./test_data
test:
needs: build
needs: [build, setup-test-data]
runs-on: ${{ matrix.os }}
strategy:
matrix:
Expand Down Expand Up @@ -127,6 +148,12 @@ jobs:
- name: Clone repo
uses: actions/checkout@v4

- name: Download test resources
uses: actions/download-artifact@v4
with:
name: test-resources
path: ./tests/unit_tests_results_dir

- name: Integration tests
run: |
pip install pytest pytest-cov pytest-timeout requests
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/build_macos_arm_11.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ jobs:
run: cd ${{github.workspace}}/tests && python3 test_inference_segmentation_mediastinum.py

- name: Inference unit test with test-time augmentation
run: cd ${{github.workspace}}/tests && python3 test_inference_segmentation_test_time_augmentation.py
run: cd ${{github.workspace}}/tests && python3 test_inference_segmentation_advanced.py

# - name: Test with pytest
# run: |
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ dependencies = [
"nibabel",
"h5py",
"pandas",
"SimpleITK",
"SimpleITK<=2.4.1",
"aenum",
"scikit-image",
"tqdm",
Expand Down
46 changes: 13 additions & 33 deletions raidionicsseg/PreProcessing/brain_clipping.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import configparser
import logging
import os
import subprocess
from copy import deepcopy
from pathlib import PurePath
from typing import List
from typing import Tuple
from typing import Union

import numpy as np
from nibabel.processing import resample_to_output
Expand All @@ -18,12 +17,10 @@


def crop_MR_background(
filepath: str,
volume: np.ndarray,
new_spacing: Tuple[float],
storage_path: str,
parameters: ConfigResources,
crop_bbox=None,
crop_bbox: List[int] = None,
) -> Tuple[np.ndarray, List[int]]:
"""
Performs different background cropping inside an MRI volume, as defined by the 'crop_background' stored in a model
Expand Down Expand Up @@ -53,12 +50,14 @@ def crop_MR_background(
The bounding region is expressed as: [minx, miny, minz, maxx, maxy, maxz].
"""
if parameters.crop_background == "minimum":
return crop_MR(volume, parameters, crop_bbox)
return crop_background_minimum(volume=volume, crop_bbox=crop_bbox)
elif parameters.crop_background == "brain_clip" or parameters.crop_background == "brain_mask":
return skull_stripping_tf(filepath, volume, new_spacing, storage_path, parameters)
return skull_stripping(volume=volume, new_spacing=new_spacing, parameters=parameters)


def crop_MR(volume: np.ndarray, parameters, crop_bbox=None) -> Tuple[np.ndarray, List[int]]:
def crop_background_minimum(
volume: np.ndarray, crop_bbox: Union[None, List[int]] = None
) -> Tuple[np.ndarray, List[int]]:
"""
Performs background cropping inside an MRI volume in 'minimum' mode whereby the black space around
the head is removed.
Expand All @@ -67,8 +66,7 @@ def crop_MR(volume: np.ndarray, parameters, crop_bbox=None) -> Tuple[np.ndarray,
----------
volume : np.ndarray
Patient MRI volume to crop.
parameters : :obj:`ConfigResources`
UNUSED, to remove.
crop_bbox:
Returns
-------
np.ndarray
Expand Down Expand Up @@ -107,20 +105,18 @@ def crop_MR(volume: np.ndarray, parameters, crop_bbox=None) -> Tuple[np.ndarray,
return cropped_volume, crop_bbox


def skull_stripping_tf(
filepath, volume: np.ndarray, new_spacing: Tuple[float], storage_path: str, parameters: ConfigResources
def skull_stripping(
volume: np.ndarray, new_spacing: Tuple[float], parameters: ConfigResources
) -> Tuple[np.ndarray, List[int]]:
"""
Generates a brain segmentation mask by running model inference and performs skull stripping.
Performs skull stripping over the provided input volume using the brain mask manually provided.

Parameters
----------
volume : np.ndarray
Patient MRI volume to skull strip.
new_spacing : Tuple[float]
.
storage_path : str
Destination folder where the results will be stored.
parameters : :obj:`ConfigResources`
Loaded configuration specifying runtime parameters.
Returns
Expand All @@ -132,23 +128,7 @@ def skull_stripping_tf(
The bounding region is expressed as: [minx, miny, minz, maxx, maxy, maxz].
"""
if not os.path.exists(parameters.runtime_brain_mask_filepath):
brain_config_filename = os.path.join(os.path.dirname(parameters.config_filename), "brain_main_config.ini")
new_parameters = configparser.ConfigParser()
new_parameters.read(parameters.config_filename)
new_parameters.set(
"System", "model_folder", os.path.join(os.path.dirname(parameters.model_folder), "MRI_Brain")
)
new_parameters.set("Runtime", "reconstruction_method", "thresholding")
new_parameters.set("Runtime", "reconstruction_order", "resample_first")
with open(brain_config_filename, "w") as cf:
new_parameters.write(cf)
old_parameters = deepcopy(parameters)
from raidionicsseg.fit import run_model

run_model(brain_config_filename)
brain_mask_filename = os.path.join(storage_path, "labels_Brain.nii.gz")
os.remove(brain_config_filename)
parameters = old_parameters
raise ValueError("A brain segmentation mask must be provided inside ['Neuro']['brain_segmentation_filename']")
else:
brain_mask_filename = parameters.runtime_brain_mask_filepath

Expand Down Expand Up @@ -176,7 +156,7 @@ def advanced_crop_exclude_background(
volume: np.ndarray, crop_mode: str, brain_mask: np.ndarray
) -> Tuple[np.ndarray, List[int]]:
"""
Perfoms skull stripping either in mode 'brain_clip' or mode 'brain_mask'.
Performs skull stripping either in mode 'brain_clip' or mode 'brain_mask'.

Parameters
----------
Expand Down
172 changes: 94 additions & 78 deletions raidionicsseg/PreProcessing/mediastinum_clipping.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import configparser
import logging
import os
from copy import deepcopy
from typing import List
Expand All @@ -15,7 +15,7 @@


def crop_mediastinum_volume(
filepath: str, volume: np.ndarray, new_spacing: Tuple[float], storage_path: str, parameters: ConfigResources
volume: np.ndarray, new_spacing: Tuple[float], parameters: ConfigResources, crop_bbox: List[int] = None
) -> Tuple[np.ndarray, List[int]]:
"""
Performs different background cropping inside a mediastinal CT volume, as defined by the 'crop_background' stored
Expand All @@ -25,14 +25,10 @@ def crop_mediastinum_volume(

Parameters
----------
filepath : str
Filepath of the input volume (CT or MRI) to use.
volume : np.ndarray
.
new_spacing : Tuple[float]
.
storage_path : str
Destination folder where the results will be stored.
parameters : :obj:`ConfigResources`
Loaded configuration specifying runtime parameters.
Returns
Expand All @@ -45,90 +41,110 @@ def crop_mediastinum_volume(
The bounding region is expressed as: [minx, miny, minz, maxx, maxy, maxz].
"""
if parameters.crop_background == "minimum":
return mediastinum_clipping(volume, parameters)
elif parameters.crop_background == "brain_clip" or parameters.crop_background == "brain_mask":
return mediastinum_clipping_DL(filepath, volume, new_spacing, storage_path, parameters)


def mediastinum_clipping(volume, parameters):
intensity_threshold = -250
airmetal_mask = deepcopy(volume)
airmetal_mask[airmetal_mask > intensity_threshold] = 0
airmetal_mask[airmetal_mask <= intensity_threshold] = 1

airmetal_mask = smo.binary_closing(airmetal_mask, iterations=5)

labels, nb_components = smeas.label(airmetal_mask)
airmetal_pieces = smeas.find_objects(labels, min(nb_components, 1000))

nums = []
for p in enumerate(airmetal_pieces):
bb = p[1]
z = bb[2].stop - bb[2].start
y = bb[1].stop - bb[1].start
x = bb[0].stop - bb[0].start
nums.append(x * y * z)

# Should check if the first two or three elements are "as big". If two the following code is correct, if three
# something should be changed so that the lungs is the third (normally?) and background the first two.
ind_bg = nums.index(np.max(nums)) + 1
nums.remove(np.max(nums))
ind_lungs = (
nums.index(np.max(nums)) + 1 + 1
) # +1 because find_objects labels start at 1, and +1 because we remove one value above
# ind_lungs = nums.index(sorted(nums, reverse=True)[0])+1
# for l in range(nb_components):
# nums.append(np.count_nonzero(np.where(labels == l)))

background_mask = np.copy(labels)
background_mask[background_mask != ind_bg] = 0

lungstrachea_mask = np.copy(labels)
lungstrachea_mask[lungstrachea_mask != ind_lungs] = 0
lungstrachea_mask[lungstrachea_mask == ind_lungs] = 1

lungs_boundingbox = airmetal_pieces[ind_lungs - 1] # Because indexing starts at 0, so have to decrease by one
crop_bbox = [
lungs_boundingbox[0].start,
lungs_boundingbox[1].start,
lungs_boundingbox[2].start,
lungs_boundingbox[0].stop,
lungs_boundingbox[1].stop,
lungs_boundingbox[2].stop,
]

cropped_volume = volume[crop_bbox[0] : crop_bbox[3], crop_bbox[1] : crop_bbox[4], crop_bbox[2] : crop_bbox[5]]

print("Cropped mediastinum values: {}".format(lungs_boundingbox))
return mediastinum_clipping(volume=volume, parameters=parameters, crop_bbox=crop_bbox)
elif parameters.crop_background == "lungs_clip" or parameters.crop_background == "lungs_mask":
return mediastinum_clipping_advanced(
volume=volume, new_spacing=new_spacing, parameters=parameters
)


def mediastinum_clipping(
volume: np.ndarray, parameters: ConfigResources, crop_bbox=None
) -> Tuple[np.ndarray, List[int]]:
if crop_bbox is None:
intensity_threshold = -250
airmetal_mask = deepcopy(volume)
airmetal_mask[airmetal_mask > intensity_threshold] = 0
airmetal_mask[airmetal_mask <= intensity_threshold] = 1

airmetal_mask = smo.binary_closing(airmetal_mask, iterations=5)

labels, nb_components = smeas.label(airmetal_mask)
airmetal_pieces = smeas.find_objects(labels, min(nb_components, 1000))

nums = []
for p in enumerate(airmetal_pieces):
bb = p[1]
z = bb[2].stop - bb[2].start
y = bb[1].stop - bb[1].start
x = bb[0].stop - bb[0].start
nums.append(x * y * z)

# Should check if the first two or three elements are "as big". If two the following code is correct, if three
# something should be changed so that the lungs is the third (normally?) and background the first two.
ind_bg = nums.index(np.max(nums)) + 1
nums.remove(np.max(nums))
ind_lungs = (
nums.index(np.max(nums)) + 1 + 1
) # +1 because find_objects labels start at 1, and +1 because we remove one value above
# ind_lungs = nums.index(sorted(nums, reverse=True)[0])+1
# for l in range(nb_components):
# nums.append(np.count_nonzero(np.where(labels == l)))

background_mask = np.copy(labels)
background_mask[background_mask != ind_bg] = 0

lungstrachea_mask = np.copy(labels)
lungstrachea_mask[lungstrachea_mask != ind_lungs] = 0
lungstrachea_mask[lungstrachea_mask == ind_lungs] = 1

lungs_boundingbox = airmetal_pieces[ind_lungs - 1] # Because indexing starts at 0, so have to decrease by one
crop_bbox = [
lungs_boundingbox[0].start,
lungs_boundingbox[1].start,
lungs_boundingbox[2].start,
lungs_boundingbox[0].stop,
lungs_boundingbox[1].stop,
lungs_boundingbox[2].stop,
]

cropped_volume = volume[crop_bbox[0] : crop_bbox[3], crop_bbox[1] : crop_bbox[4], crop_bbox[2] : crop_bbox[5]]
else:
min_row, min_col, min_depth, max_row, max_col, max_depth = (
crop_bbox[0],
crop_bbox[1],
crop_bbox[2],
crop_bbox[3],
crop_bbox[4],
crop_bbox[5],
)
cropped_volume = volume[min_row:max_row, min_col:max_col, min_depth:max_depth]
logging.debug(
"Mediastinum background cropping with: [{}, {}, {}, {}, {}, {}].\n".format(
crop_bbox[0], crop_bbox[1], crop_bbox[2], crop_bbox[3], crop_bbox[4], crop_bbox[5]
)
)
return cropped_volume, crop_bbox


def mediastinum_clipping_DL(filepath, volume, new_spacing, storage_path, parameters):
def mediastinum_clipping_advanced(
volume: np.ndarray, new_spacing: Tuple[float], parameters: ConfigResources
) -> Tuple[np.ndarray, List[int]]:
"""

Parameters
----------
volume
new_spacing
parameters

Returns
-------

"""
if not parameters.runtime_lungs_mask_filepath and not os.path.exists(parameters.runtime_lungs_mask_filepath):
lung_config_filename = os.path.join(os.path.dirname(parameters.config_filename), "lungs_main_config.ini")
new_parameters = configparser.ConfigParser()
new_parameters.read(parameters.config_filename)
new_parameters.set("System", "model_folder", os.path.join(os.path.dirname(parameters.model_folder), "CT_Lungs"))
new_parameters.set("Runtime", "reconstruction_method", "thresholding")
new_parameters.set("Runtime", "reconstruction_order", "resample_first")
with open(lung_config_filename, "w") as cf:
new_parameters.write(cf)
old_parameters = deepcopy(parameters)
from raidionicsseg.fit import run_model

run_model(lung_config_filename)
lungs_mask_filename = os.path.join(storage_path, "labels_Lungs.nii.gz")
os.remove(lung_config_filename)
parameters = old_parameters
raise ValueError(
"A brain segmentation mask must be provided inside ['Mediastinum']['lungs_segmentation_filename']"
)
else:
lungs_mask_filename = parameters.runtime_lungs_mask_filepath

lungs_mask_ni = load_nifti_volume(lungs_mask_filename)
resampled_volume = resample_to_output(lungs_mask_ni, new_spacing, order=0)
lungs_mask = resampled_volume.get_fdata().astype("uint8")

# In case the lungs mask has a different label for each lung
lungs_mask[lungs_mask != 0] = 1

lung_region = regionprops(lungs_mask)
min_row, min_col, min_depth, max_row, max_col, max_depth = lung_region[0].bbox
if parameters.crop_background == "invert":
Expand Down
Loading