|
|
import SimpleITK as sitk |
|
|
import numpy as np |
|
|
import pandas as pd |
|
|
import os |
|
|
import radiomics |
|
|
from radiomics import featureextractor |
|
|
import argparse |
|
|
import numpy as np |
|
|
from sklearn.cluster import KMeans |
|
|
import scipy.ndimage as ndimage |
|
|
from sklearn.mixture import GaussianMixture |
|
|
import skfuzzy as fuzz |
|
|
from skimage.filters import threshold_otsu |
|
|
from skimage.filters import threshold_otsu |
|
|
from skimage.segmentation import watershed |
|
|
from skimage.feature import peak_local_max |
|
|
from skimage import morphology |
|
|
from scipy.ndimage import distance_transform_edt, binary_erosion |
|
|
from matplotlib import colors |
|
|
import numpy as np |
|
|
import matplotlib.pyplot as plt |
|
|
import SimpleITK as sitk |
|
|
import numpy as np |
|
|
import pandas as pd |
|
|
from matplotlib import colors |
|
|
import cv2 |
|
|
from skimage import measure |
|
|
|
|
|
|
|
|
def make_bold(text): |
|
|
return f"\033[1m{text}\033[0m" |
|
|
|
|
|
def load_itk_image(filename): |
|
|
itkimage = sitk.ReadImage(filename) |
|
|
numpyImage = sitk.GetArrayFromImage(itkimage) |
|
|
numpyOrigin = itkimage.GetOrigin() |
|
|
numpySpacing = itkimage.GetSpacing() |
|
|
return numpyImage, numpyOrigin, numpySpacing |
|
|
|
|
|
def normalize_image_to_uint8(image, lower_bound=-1000, upper_bound=100): |
|
|
clipped_img = np.clip(image, lower_bound, upper_bound) |
|
|
normalized_img = ((clipped_img - lower_bound) / (upper_bound - lower_bound)) * 255.0 |
|
|
normalized_img = normalized_img.astype(np.uint8) |
|
|
return normalized_img |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def segment_nodule_kmeans(ct_image, bbox_center, bbox_whd, margin=5, n_clusters=2): |
|
|
""" |
|
|
Segments a nodule in a 3D CT image using k-means clustering with a margin around the bounding box. |
|
|
|
|
|
Parameters: |
|
|
- ct_image: 3D NumPy array representing the CT image. |
|
|
- bbox_center: Tuple of (x, y, z) coordinates for the center of the bounding box. |
|
|
- bbox_whd: Tuple of (w, h, d) representing the width, height, and depth of the bounding box. |
|
|
- margin: Margin to add around the bounding box (default is 5). |
|
|
- n_clusters: Number of clusters to use in k-means (default is 2). |
|
|
|
|
|
Returns: |
|
|
- segmented_image: 3D NumPy array with the segmented nodule. |
|
|
""" |
|
|
|
|
|
x_center, y_center, z_center = bbox_center |
|
|
w, h, d = bbox_whd |
|
|
|
|
|
|
|
|
x_start, x_end = max(0, x_center - w//2 - margin), min(ct_image.shape[0], x_center + w//2 + margin) |
|
|
y_start, y_end = max(0, y_center - h//2 - margin), min(ct_image.shape[1], y_center + h//2 + margin) |
|
|
z_start, z_end = max(0, z_center - d//2 - margin), min(ct_image.shape[2], z_center + d//2 + margin) |
|
|
|
|
|
bbox_region = ct_image[x_start:x_end, y_start:y_end, z_start:z_end] |
|
|
|
|
|
|
|
|
flat_region = bbox_region.reshape(-1, 1) |
|
|
|
|
|
|
|
|
kmeans = KMeans(n_clusters=n_clusters, random_state=0).fit(flat_region) |
|
|
labels = kmeans.labels_ |
|
|
|
|
|
|
|
|
clustered_region = labels.reshape(bbox_region.shape) |
|
|
|
|
|
|
|
|
nodule_cluster = np.argmax(kmeans.cluster_centers_) |
|
|
|
|
|
|
|
|
nodule_mask = (clustered_region == nodule_cluster) |
|
|
|
|
|
|
|
|
nodule_mask = ndimage.binary_closing(nodule_mask, structure=np.ones((3, 3, 3))) |
|
|
nodule_mask = ndimage.binary_opening(nodule_mask, structure=np.ones((2, 2, 2))) |
|
|
|
|
|
|
|
|
segmented_image = np.zeros_like(ct_image, dtype=np.uint8) |
|
|
|
|
|
|
|
|
segmented_image[x_start:x_end, y_start:y_end, z_start:z_end] = nodule_mask |
|
|
|
|
|
return segmented_image |
|
|
|
|
|
|
|
|
def segment_nodule_gmm(ct_image, bbox_center, bbox_whd, margin=5, n_components=2): |
|
|
""" |
|
|
Segments a nodule in a 3D CT image using a Gaussian Mixture Model with a margin around the bounding box. |
|
|
|
|
|
Parameters: |
|
|
- ct_image: 3D NumPy array representing the CT image. |
|
|
- bbox_center: Tuple of (x, y, z) coordinates for the center of the bounding box. |
|
|
- bbox_whd: Tuple of (w, h, d) representing the width, height, and depth of the bounding box. |
|
|
- margin: Margin to add around the bounding box (default is 5). |
|
|
- n_components: Number of components to use in the Gaussian Mixture Model (default is 2). |
|
|
|
|
|
Returns: |
|
|
- segmented_image: 3D NumPy array with the segmented nodule. |
|
|
""" |
|
|
|
|
|
x_center, y_center, z_center = bbox_center |
|
|
w, h, d = bbox_whd |
|
|
|
|
|
|
|
|
x_start, x_end = max(0, x_center - w//2 - margin), min(ct_image.shape[0], x_center + w//2 + margin) |
|
|
y_start, y_end = max(0, y_center - h//2 - margin), min(ct_image.shape[1], y_center + h//2 + margin) |
|
|
z_start, z_end = max(0, z_center - d//2 - margin), min(ct_image.shape[2], z_center + d//2 + margin) |
|
|
|
|
|
bbox_region = ct_image[x_start:x_end, y_start:y_end, z_start:z_end] |
|
|
|
|
|
|
|
|
flat_region = bbox_region.reshape(-1, 1) |
|
|
|
|
|
|
|
|
gmm = GaussianMixture(n_components=n_components, random_state=0).fit(flat_region) |
|
|
labels = gmm.predict(flat_region) |
|
|
|
|
|
|
|
|
clustered_region = labels.reshape(bbox_region.shape) |
|
|
|
|
|
|
|
|
nodule_component = np.argmax(gmm.means_) |
|
|
|
|
|
|
|
|
nodule_mask = (clustered_region == nodule_component) |
|
|
|
|
|
|
|
|
nodule_mask = ndimage.binary_closing(nodule_mask, structure=np.ones((3, 3, 3))) |
|
|
nodule_mask = ndimage.binary_opening(nodule_mask, structure=np.ones((3, 3, 3))) |
|
|
|
|
|
|
|
|
segmented_image = np.zeros_like(ct_image, dtype=np.uint8) |
|
|
|
|
|
|
|
|
segmented_image[x_start:x_end, y_start:y_end, z_start:z_end] = nodule_mask |
|
|
|
|
|
return segmented_image |
|
|
|
|
|
|
|
|
def segment_nodule_fcm(ct_image, bbox_center, bbox_whd, margin=5, n_clusters=2): |
|
|
""" |
|
|
Segments a nodule in a 3D CT image using Fuzzy C-means clustering with a margin around the bounding box. |
|
|
|
|
|
Parameters: |
|
|
- ct_image: 3D NumPy array representing the CT image. |
|
|
- bbox_center: Tuple of (x, y, z) coordinates for the center of the bounding box. |
|
|
- bbox_whd: Tuple of (w, h, d) representing the width, height, and depth of the bounding box. |
|
|
- margin: Margin to add around the bounding box (default is 5). |
|
|
- n_clusters: Number of clusters to use in Fuzzy C-means (default is 2). |
|
|
|
|
|
Returns: |
|
|
- segmented_image: 3D NumPy array with the segmented nodule. |
|
|
""" |
|
|
|
|
|
x_center, y_center, z_center = bbox_center |
|
|
w, h, d = bbox_whd |
|
|
|
|
|
|
|
|
x_start, x_end = max(0, x_center - w//2 - margin), min(ct_image.shape[0], x_center + w//2 + margin) |
|
|
y_start, y_end = max(0, y_center - h//2 - margin), min(ct_image.shape[1], y_center + h//2 + margin) |
|
|
z_start, z_end = max(0, z_center - d//2 - margin), min(ct_image.shape[2], z_center + d//2 + margin) |
|
|
|
|
|
bbox_region = ct_image[x_start:x_end, y_start:y_end, z_start:z_end] |
|
|
|
|
|
|
|
|
flat_region = bbox_region.reshape(-1, 1) |
|
|
|
|
|
|
|
|
cntr, u, u0, d, jm, p, fpc = fuzz.cluster.cmeans(flat_region.T, n_clusters, 2, error=0.005, maxiter=1000, init=None) |
|
|
|
|
|
|
|
|
labels = np.argmax(u, axis=0) |
|
|
|
|
|
|
|
|
clustered_region = labels.reshape(bbox_region.shape) |
|
|
|
|
|
|
|
|
nodule_cluster = np.argmax(cntr) |
|
|
|
|
|
|
|
|
nodule_mask = (clustered_region == nodule_cluster) |
|
|
|
|
|
|
|
|
nodule_mask = ndimage.binary_closing(nodule_mask, structure=np.ones((3, 3, 3))) |
|
|
nodule_mask = ndimage.binary_opening(nodule_mask, structure=np.ones((3, 3, 3))) |
|
|
|
|
|
|
|
|
segmented_image = np.zeros_like(ct_image, dtype=np.uint8) |
|
|
|
|
|
|
|
|
segmented_image[x_start:x_end, y_start:y_end, z_start:z_end] = nodule_mask |
|
|
|
|
|
return segmented_image |
|
|
|
|
|
|
|
|
|
|
|
def segment_nodule_otsu(ct_image, bbox_center, bbox_whd, margin=5): |
|
|
""" |
|
|
Segments a nodule in a 3D CT image using Otsu's thresholding with a margin around the bounding box. |
|
|
|
|
|
Parameters: |
|
|
- ct_image: 3D NumPy array representing the CT image. |
|
|
- bbox_center: Tuple of (x, y, z) coordinates for the center of the bounding box. |
|
|
- bbox_whd: Tuple of (w, h, d) representing the width, height, and depth of the bounding box. |
|
|
- margin: Margin to add around the bounding box (default is 5). |
|
|
|
|
|
Returns: |
|
|
- segmented_image: 3D NumPy array with the segmented nodule. |
|
|
""" |
|
|
|
|
|
x_center, y_center, z_center = bbox_center |
|
|
w, h, d = bbox_whd |
|
|
|
|
|
|
|
|
x_start, x_end = max(0, x_center - w//2 - margin), min(ct_image.shape[0], x_center + w//2 + margin) |
|
|
y_start, y_end = max(0, y_center - h//2 - margin), min(ct_image.shape[1], y_center + h//2 + margin) |
|
|
z_start, z_end = max(0, z_center - d//2 - margin), min(ct_image.shape[2], z_center + d//2 + margin) |
|
|
|
|
|
bbox_region = ct_image[x_start:x_end, y_start:y_end, z_start:z_end] |
|
|
|
|
|
|
|
|
flat_region = bbox_region.flatten() |
|
|
|
|
|
|
|
|
otsu_threshold = threshold_otsu(flat_region) |
|
|
|
|
|
|
|
|
nodule_mask = bbox_region >= otsu_threshold |
|
|
|
|
|
|
|
|
nodule_mask = ndimage.binary_closing(nodule_mask, structure=np.ones((3, 3, 3))) |
|
|
nodule_mask = ndimage.binary_opening(nodule_mask, structure=np.ones((3, 3, 3))) |
|
|
|
|
|
|
|
|
segmented_image = np.zeros_like(ct_image, dtype=np.uint8) |
|
|
|
|
|
|
|
|
segmented_image[x_start:x_end, y_start:y_end, z_start:z_end] = nodule_mask |
|
|
|
|
|
return segmented_image |
|
|
|
|
|
|
|
|
|
|
|
def expand_mask_by_distance(segmented_nodule_gmm, spacing, expansion_mm): |
|
|
""" |
|
|
Expands the segmentation mask by a given distance in mm in all directions by directly updating pixel values. |
|
|
|
|
|
Parameters: |
|
|
segmented_nodule_gmm (numpy array): 3D binary mask of the nodule (1 for nodule, 0 for background). |
|
|
spacing (tuple): Spacing of the image in mm for each voxel, given as (spacing_x, spacing_y, spacing_z). |
|
|
expansion_mm (float): Distance to expand the mask in millimeters. |
|
|
|
|
|
Returns: |
|
|
numpy array: Expanded segmentation mask. |
|
|
""" |
|
|
|
|
|
spacing_reordered = (spacing[2], spacing[1], spacing[0]) |
|
|
|
|
|
|
|
|
expand_pixels = np.array([int(np.round(expansion_mm / s)) for s in spacing_reordered]) |
|
|
|
|
|
|
|
|
expanded_mask = np.zeros_like(segmented_nodule_gmm) |
|
|
|
|
|
|
|
|
white_pixel_coords = np.argwhere(segmented_nodule_gmm == 1) |
|
|
|
|
|
|
|
|
for coord in white_pixel_coords: |
|
|
z, y, x = coord |
|
|
|
|
|
|
|
|
z_range = range(max(0, z - expand_pixels[0]), min(segmented_nodule_gmm.shape[0], z + expand_pixels[0] + 1)) |
|
|
y_range = range(max(0, y - expand_pixels[1]), min(segmented_nodule_gmm.shape[1], y + expand_pixels[1] + 1)) |
|
|
x_range = range(max(0, x - expand_pixels[2]), min(segmented_nodule_gmm.shape[2], x + expand_pixels[2] + 1)) |
|
|
|
|
|
|
|
|
for z_new in z_range: |
|
|
for y_new in y_range: |
|
|
for x_new in x_range: |
|
|
expanded_mask[z_new, y_new, x_new] = 1 |
|
|
|
|
|
return expanded_mask |
|
|
|
|
|
|
|
|
def find_nodule_lobe(cccwhd, lung_mask, class_map): |
|
|
""" |
|
|
Determine the lung lobe where a nodule is located based on a 3D mask and bounding box. |
|
|
|
|
|
Parameters: |
|
|
cccwhd (list or tuple): Bounding box in the format [center_x, center_y, center_z, width, height, depth]. |
|
|
lung_mask (numpy array): 3D array representing the lung mask with different lung regions. |
|
|
class_map (dict): Dictionary mapping lung region labels to their names. |
|
|
|
|
|
Returns: |
|
|
str: Name of the lung lobe where the nodule is located. |
|
|
""" |
|
|
center_x, center_y, center_z, width, height, depth = cccwhd |
|
|
|
|
|
|
|
|
start_x = int(center_x - width // 2) |
|
|
end_x = int(center_x + width // 2) |
|
|
start_y = int(center_y - height // 2) |
|
|
end_y = int(center_y + height // 2) |
|
|
start_z = int(center_z - depth // 2) |
|
|
end_z = int(center_z + depth // 2) |
|
|
|
|
|
|
|
|
start_x = max(0, start_x) |
|
|
end_x = min(lung_mask.shape[0], end_x) |
|
|
start_y = max(0, start_y) |
|
|
end_y = min(lung_mask.shape[1], end_y) |
|
|
start_z = max(0, start_z) |
|
|
end_z = min(lung_mask.shape[2], end_z) |
|
|
|
|
|
|
|
|
roi = lung_mask[start_x:end_x, start_y:end_y, start_z:end_z] |
|
|
|
|
|
|
|
|
unique, counts = np.unique(roi, return_counts=True) |
|
|
label_counts = dict(zip(unique, counts)) |
|
|
|
|
|
|
|
|
if 0 in label_counts: |
|
|
del label_counts[0] |
|
|
|
|
|
|
|
|
if label_counts: |
|
|
nodule_lobe = max(label_counts, key=label_counts.get) |
|
|
else: |
|
|
nodule_lobe = None |
|
|
|
|
|
|
|
|
if nodule_lobe is not None: |
|
|
nodule_lobe_name = class_map["lungs"][nodule_lobe] |
|
|
else: |
|
|
nodule_lobe_name = "Undefined" |
|
|
|
|
|
return nodule_lobe_name |
|
|
|
|
|
|
|
|
def find_nodule_lobe_and_distance(cccwhd, lung_mask, class_map,spacing): |
|
|
""" |
|
|
Determine the lung lobe where a nodule is located and measure its distance from the lung wall. |
|
|
|
|
|
Parameters: |
|
|
cccwhd (list or tuple): Bounding box in the format [center_x, center_y, center_z, width, height, depth]. |
|
|
lung_mask (numpy array): 3D array representing the lung mask with different lung regions. |
|
|
class_map (dict): Dictionary mapping lung region labels to their names. |
|
|
|
|
|
Returns: |
|
|
tuple: (Name of the lung lobe, Distance from the lung wall) |
|
|
""" |
|
|
center_x, center_y, center_z, width, height, depth = cccwhd |
|
|
|
|
|
|
|
|
start_x = int(center_x - width // 2) |
|
|
end_x = int(center_x + width // 2) |
|
|
start_y = int(center_y - height // 2) |
|
|
end_y = int(center_y + height // 2) |
|
|
start_z = int(center_z - depth // 2) |
|
|
end_z = int(center_z + depth // 2) |
|
|
|
|
|
|
|
|
start_x = max(0, start_x) |
|
|
end_x = min(lung_mask.shape[0], end_x) |
|
|
start_y = max(0, start_y) |
|
|
end_y = min(lung_mask.shape[1], end_y) |
|
|
start_z = max(0, start_z) |
|
|
end_z = min(lung_mask.shape[2], end_z) |
|
|
|
|
|
|
|
|
roi = lung_mask[start_x:end_x, start_y:end_y, start_z:end_z] |
|
|
|
|
|
|
|
|
unique, counts = np.unique(roi, return_counts=True) |
|
|
label_counts = dict(zip(unique, counts)) |
|
|
|
|
|
|
|
|
if 0 in label_counts: |
|
|
del label_counts[0] |
|
|
|
|
|
|
|
|
if label_counts: |
|
|
nodule_lobe = max(label_counts, key=label_counts.get) |
|
|
else: |
|
|
nodule_lobe = None |
|
|
|
|
|
|
|
|
if nodule_lobe is not None: |
|
|
nodule_lobe_name = class_map["lungs"][nodule_lobe] |
|
|
else: |
|
|
nodule_lobe_name = "Undefined" |
|
|
|
|
|
|
|
|
nodule_centroid = np.array([center_x, center_y, center_z]) |
|
|
|
|
|
|
|
|
lung_binary_mask = lung_mask > 0 |
|
|
|
|
|
|
|
|
|
|
|
lung_eroded = binary_erosion(lung_binary_mask) |
|
|
lung_wall_mask = lung_binary_mask & ~lung_eroded |
|
|
|
|
|
|
|
|
distance_transform = distance_transform_edt(~lung_wall_mask) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
voxel_distance_to_lung_wall = distance_transform[center_x, center_y, center_z] |
|
|
|
|
|
|
|
|
physical_distance_to_lung_wall = voxel_distance_to_lung_wall * np.sqrt( |
|
|
spacing[0]**2 + spacing[1]**2 + spacing[2]**2 |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
return nodule_lobe_name, voxel_distance_to_lung_wall,physical_distance_to_lung_wall |
|
|
|
|
|
|
|
|
|
|
|
def expand_mask_by_distance(segmented_nodule_gmm, spacing, expansion_mm): |
|
|
""" |
|
|
Expands the segmentation mask by a given distance in mm in all directions by directly updating pixel values. |
|
|
|
|
|
Parameters: |
|
|
segmented_nodule_gmm (numpy array): 3D binary mask of the nodule (1 for nodule, 0 for background). |
|
|
spacing (tuple): Spacing of the image in mm for each voxel, given as (spacing_x, spacing_y, spacing_z). |
|
|
expansion_mm (float): Distance to expand the mask in millimeters. |
|
|
|
|
|
Returns: |
|
|
numpy array: Expanded segmentation mask. |
|
|
""" |
|
|
|
|
|
spacing_reordered = (spacing[2], spacing[1], spacing[0]) |
|
|
|
|
|
|
|
|
expand_pixels = np.array([int(np.round(expansion_mm / s)) for s in spacing_reordered]) |
|
|
|
|
|
|
|
|
expanded_mask = np.zeros_like(segmented_nodule_gmm) |
|
|
|
|
|
|
|
|
white_pixel_coords = np.argwhere(segmented_nodule_gmm == 1) |
|
|
|
|
|
|
|
|
for coord in white_pixel_coords: |
|
|
z, y, x = coord |
|
|
|
|
|
|
|
|
z_range = range(max(0, z - expand_pixels[0]), min(segmented_nodule_gmm.shape[0], z + expand_pixels[0] + 1)) |
|
|
y_range = range(max(0, y - expand_pixels[1]), min(segmented_nodule_gmm.shape[1], y + expand_pixels[1] + 1)) |
|
|
x_range = range(max(0, x - expand_pixels[2]), min(segmented_nodule_gmm.shape[2], x + expand_pixels[2] + 1)) |
|
|
|
|
|
|
|
|
for z_new in z_range: |
|
|
for y_new in y_range: |
|
|
for x_new in x_range: |
|
|
expanded_mask[z_new, y_new, x_new] = 1 |
|
|
|
|
|
return expanded_mask |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def plot_contours(ax, mask, color, linewidth=1.5): |
|
|
contours = measure.find_contours(mask, level=0.5) |
|
|
for contour in contours: |
|
|
ax.plot(contour[:, 1], contour[:, 0], color=color, linewidth=linewidth) |
|
|
|
|
|
|