From 5c426d0cc1bd3328116dc96eac7afbf2a98c0b98 Mon Sep 17 00:00:00 2001 From: Qiyu Zhang <148504334+Qiyu-Zh@users.noreply.github.com> Date: Thu, 16 Jan 2025 10:29:40 -0800 Subject: [PATCH 01/20] Update tool.py --- ImageTool/tool.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/ImageTool/tool.py b/ImageTool/tool.py index 4242572..03fe246 100644 --- a/ImageTool/tool.py +++ b/ImageTool/tool.py @@ -280,3 +280,42 @@ def sitk2ant(img, reverse = False): delete_if_exist("tmp.nii") return dcm +def plot3d(CFR_crop, vmax = 3.5, sample_rate = 5): + matplotlib.use('module://ipympl.backend_nbagg') + fig = plt.figure(figsize=(8, 8)) + ax = fig.add_subplot(111, projection="3d") + + x, y, z = np.indices(CFR_crop.shape) + + # Filter the coordinates and CFR values using the mask + mask_indices = CFR_crop[:].nonzero() + x_masked = x[mask_indices] + y_masked = y[mask_indices] + z_masked = z[mask_indices] + CFR_masked = CFR_crop[mask_indices] + + x_masked = x_masked[::sample_rate] + y_masked = y_masked[::sample_rate] + z_masked = z_masked[::sample_rate] + CFR_masked = CFR_masked[::sample_rate] + + ax.set_xlabel("X-axis") + ax.set_ylabel("Y-axis") + ax.set_zlabel("Z-axis") + ax.set_title("Interactive 3D CFR Visualization") + # Plot only the masked values + scatter = ax.scatter( + x_masked, + y_masked, + z_masked, + c=CFR_masked, + cmap="jet", + vmin=0, + vmax=vmax, + s=1 + ) + + # Add a colorbar and adjust its position + colorbar = fig.colorbar(scatter, ax=ax, shrink=0.6, aspect=15, pad=0.1) + colorbar.set_label("CFR Intensity") + plt.show() From f1cd8a6f738a11354512bdaef34b1f28abde2f95 Mon Sep 17 00:00:00 2001 From: Qiyu Zhang <148504334+Qiyu-Zh@users.noreply.github.com> Date: Thu, 16 Jan 2025 10:30:06 -0800 Subject: [PATCH 02/20] Update tool.py --- ImageTool/tool.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ImageTool/tool.py b/ImageTool/tool.py index 03fe246..6297d5a 100644 --- a/ImageTool/tool.py +++ b/ImageTool/tool.py @@ -7,6 +7,7 @@ import matplotlib.pyplot as plt from scipy import ndimage import cv2 +import matplotlib import os import shutil from skimage.metrics import structural_similarity From dad481e617463110f6ab8dd7bca952f5f58b354d Mon Sep 17 00:00:00 2001 From: Qiyu Zhang <148504334+Qiyu-Zh@users.noreply.github.com> Date: Mon, 27 Jan 2025 11:12:20 -0800 Subject: [PATCH 03/20] Update tool.py --- ImageTool/tool.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ImageTool/tool.py b/ImageTool/tool.py index 6297d5a..264f938 100644 --- a/ImageTool/tool.py +++ b/ImageTool/tool.py @@ -12,6 +12,20 @@ import shutil from skimage.metrics import structural_similarity import ants +import monai +def get_HU_error(y_pred, y, percentile = None, onehot = False, n_classes = None): + def get_onehot(label, n_classes): + one_hot = np.eye(n_classes)[label] # Shape: (1, 32, 32, 32, 6) + one_hot = one_hot.transpose(3, 0, 1, 2) + one_hot = np.expand_dims(one_hot, axis=0) + + return one_hot + + if onehot is False: + y = get_onehot(y, n_classes) # Shape: (1, 32, 32, 32, 6) + y_pred = get_onehot(y_pred, n_classes) + return monai.metrics.compute_hausdorff_distance(y_pred, y, include_background=False, distance_metric='euclidean', percentile=percentile, directed=False, spacing=None) + def get_contour(binary_mask): # Multiply by 255 to convert it to the format expected by OpenCV (0s and 255s) From 112b64ea40e1ce579220024bcf4ffc39ca5c461f Mon Sep 17 00:00:00 2001 From: Qiyu Zhang <148504334+Qiyu-Zh@users.noreply.github.com> Date: Mon, 27 Jan 2025 11:14:38 -0800 Subject: [PATCH 04/20] Update setup.py --- setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c22449c..21409c0 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,9 @@ "matplotlib", "numpy", "opencv-python", - "scikit-image" + "scikit-image", + "antspyx", + "monai" ], classifiers=[ # Metadata "Programming Language :: Python :: 3", From 30b647a3d06fbbac448d4f5c5ed9dad05afaf5e6 Mon Sep 17 00:00:00 2001 From: Qiyu Zhang <148504334+Qiyu-Zh@users.noreply.github.com> Date: Tue, 4 Feb 2025 16:06:18 -0800 Subject: [PATCH 05/20] Update tool.py --- ImageTool/tool.py | 171 ++++++++++++++++++++++++---------------------- 1 file changed, 91 insertions(+), 80 deletions(-) diff --git a/ImageTool/tool.py b/ImageTool/tool.py index 264f938..cb704d0 100644 --- a/ImageTool/tool.py +++ b/ImageTool/tool.py @@ -5,15 +5,25 @@ from ipywidgets import interact from ipywidgets.widgets import IntSlider import matplotlib.pyplot as plt -from scipy import ndimage -import cv2 import matplotlib +import cv2 import os import shutil +from totalsegmentator.python_api import totalsegmentator from skimage.metrics import structural_similarity +from scipy import ndimage import ants import monai -def get_HU_error(y_pred, y, percentile = None, onehot = False, n_classes = None): +import monai.metrics + +def compute_hu_distance(): + for i in range(1, n_classes): + data.append(monai.metrics.compute_hausdorff_distance((y_pred == i), (y == i), include_background=False, distance_metric='euclidean', percentile=None, directed=False, spacing=None)) + return data + + + +def get_HU_error(y_pred, y, onehot = False, n_classes = None): def get_onehot(label, n_classes): one_hot = np.eye(n_classes)[label] # Shape: (1, 32, 32, 32, 6) one_hot = one_hot.transpose(3, 0, 1, 2) @@ -24,7 +34,68 @@ def get_onehot(label, n_classes): if onehot is False: y = get_onehot(y, n_classes) # Shape: (1, 32, 32, 32, 6) y_pred = get_onehot(y_pred, n_classes) - return monai.metrics.compute_hausdorff_distance(y_pred, y, include_background=False, distance_metric='euclidean', percentile=percentile, directed=False, spacing=None) + return monai.metrics.compute_hausdorff_distance(y_pred, y, include_background=False, distance_metric='euclidean', percentile=None, directed=False, spacing=None) + + +def erode3d(mask=np.ones((6, 6, 6)), size=2): + # Ensure mask is boolean + mask = mask[:] + + # Create a 3D structuring element + structure = np.ones((2 * size + 1, 2 * size + 1, 2 * size + 1), dtype=bool) + + # Perform binary erosion + eroded_mask = ndimage.binary_erosion(mask, structure=structure).astype(mask.dtype) + + return eroded_mask + +def plot3d(CFR_crop, mask = None, vmax = 2, sample_rate = 5): + matplotlib.use('module://ipympl.backend_nbagg') + fig = plt.figure(figsize=(8, 8)) + ax = fig.add_subplot(111, projection="3d") + + x, y, z = np.indices(CFR_crop.shape) + + # Filter the coordinates and CFR values using the mask + if mask: + mask = mask[:] + mask = mask - erode3d(mask, size = 1) + mask = mask.astype(bool) + mask_indices = np.where(mask) + + else: + mask_indices = CFR_crop[:].nonzero() + x_masked = x[mask_indices] + y_masked = y[mask_indices] + z_masked = z[mask_indices] + CFR_masked = CFR_crop[mask_indices] + + x_masked = x_masked[::sample_rate] + y_masked = y_masked[::sample_rate] + z_masked = z_masked[::sample_rate] + CFR_masked = CFR_masked[::sample_rate] + + ax.set_xlabel("X-axis") + ax.set_ylabel("Y-axis") + ax.set_zlabel("Z-axis") + ax.set_title("Interactive 3D CFR Visualization") + # Plot only the masked values + scatter = ax.scatter( + x_masked, + y_masked, + z_masked, + c=CFR_masked, + cmap="jet", + vmin=0, + vmax=vmax, + s=1 + ) + + # Add a colorbar and adjust its position + colorbar = fig.colorbar(scatter, ax=ax, shrink=0.6, aspect=15, pad=0.1) + colorbar.set_label("CFR Intensity") + plt.show() + def get_contour(binary_mask): @@ -35,21 +106,6 @@ def get_contour(binary_mask): cv2.drawContours(contour_only_mask, contours, -1, 1, 1) return contour_only_mask -def erode(mask = np.ones((6, 6)), size = 2): - structure = np.ones((2*size + 1, 2*size + 1)) - # Erode the mask - eroded_mask = ndimage.binary_erosion(mask, structure).astype(mask.dtype) - return eroded_mask - -def calculate_mean_hu(dcm_rest, dcm_mask_rest, bolus_rest_init): - idxes = [i for i in range(dcm_rest.shape[2]) if np.sum(dcm_mask_rest[:, :, i]) > 100] - slice_idx = max([(tool.ssim(dcm_rest[:,:,i], bolus_rest_init), i) for i in idxes])[1] - print(slice_idx) - reg_ss_rest = ants.registration(fixed = ants.from_numpy(dcm_rest[:, :, slice_idx]) , moving = ants.from_numpy(bolus_rest_init), type_of_transform ='SyNAggro')['warpedmovout'] - print(slice_idx, np.sum(dcm_mask_rest[:, :, slice_idx])) - mask = erode(dcm_mask_rest[:, :, slice_idx], size = 2).astype(bool) - HD_rest = np.mean(reg_ss_rest[:][mask]) - return HD_rest def resize(image_path, target_size, output_path = None): image = sitk.ReadImage(image_path) @@ -132,12 +188,12 @@ def plot_mask(mask_for_cv, ax, color = "Blues", alpha = 0.5, label = None): return masked_mask -def list_display(img_list, name = "", read_dcm = False): +def list_display(img_list, name = "", vmax = 300, cmap = 'jet', read_dcm = False): if read_dcm: img_list = [sitk.GetArrayFromImage(sitk.ReadImage(dcm_file)) for dcm_file in img_list] - max_slices_contrast = max(img_list, key = lambda x: x.shape[0]).shape[0] + max_slices_contrast = max(img_list, key = lambda x: x.shape[2]).shape[2] contrast_slice_slider = widgets.IntSlider(min=0, max=max_slices_contrast-1, step=1, value=0, description='Slice:') def display_slice(contrast_slice_index, img_list, name): @@ -146,11 +202,11 @@ def display_slice(contrast_slice_index, img_list, name): fig.suptitle(name + f' Slice {contrast_slice_index}') for i, img in enumerate(img_list): if img is not None: - axes[i].imshow(img[min(img.shape[0] - 1, contrast_slice_index),:,:], cmap='jet', vmax = 300) + axes[i].imshow(img[:,:,min(img.shape[2] - 1, contrast_slice_index)], cmap=cmap, vmin = 0, vmax = vmax) axes[i].axis('off') plt.show() + def update(contrast_slice_index): - display_slice(contrast_slice_index, img_list, name) widgets.interact(update, contrast_slice_index=contrast_slice_slider) @@ -158,7 +214,6 @@ def update(contrast_slice_index): def folders_display(img_list, name = "", read_dcm = False): - if read_dcm: img_list = [sitk.GetArrayFromImage(sitk.ReadImage(dcm_file)) for dcm_file in img_list] max_slices_contrast = max(img_list, key = lambda x: x.shape[0]).shape[0] @@ -181,7 +236,7 @@ def display_slice(contrast_slice_index, img_list, name): plt.show() -def lists_display(img_lists, name = None, titles = None): +def lists_display(img_lists, cmap = 'jet', name = None, titles = None): if name == None: name = "" if titles == None: @@ -208,7 +263,7 @@ def display_slice(contrast_slice_index, list_idx_slider): if imgs is not None: img = imgs[min(len(imgs),list_idx_slider)] - axes[i].imshow(img[min(img.shape[0] - 1, contrast_slice_index),:,:], cmap='jet', vmax = vmaxs[i]) + axes[i].imshow(img[min(img.shape[0] - 1, contrast_slice_index),:,:], cmap=cmap, vmax = vmaxs[i]) axes[i].axis('off') axes[i].set_title(titles[i]) plt.show() @@ -233,19 +288,7 @@ def make_if_dont_exist(folder_path,overwrite=False): os.makedirs(folder_path) else: os.makedirs(folder_path) -def make_if_dont_exist(folder_path,overwrite=False): - """ - creates a folder if it does not exists - input: - folder_path : relative path of the folder which needs to be created - over_write :(default: False) if True overwrite the existing folder - """ - if os.path.exists(folder_path): - if overwrite: - shutil.rmtree(folder_path) - os.makedirs(folder_path) - else: - os.makedirs(folder_path) + def load_3d_2d(dcm_file, output_path): make_if_dont_exist(output_path) # Read the 3D DICOM image using SimpleITK @@ -276,13 +319,13 @@ def ssim(np_image1, np_image2): data_range = np.max([np_image1.max(), np_image2.max()]) - np.min([np_image1.min(), np_image2.min()]) return structural_similarity(np_image1, np_image2, data_range=data_range) -def delete_is_exist(file_path): +def delete_if_exist(file_path): if os.path.exists(file_path): os.remove(file_path) print(f"File {file_path} has been deleted.") else: print(f"File {file_path} does not exist.") - + def sitk2ant(img, reverse = False): if reverse == True: ants.image_write(img, "tmp.nii") @@ -295,42 +338,10 @@ def sitk2ant(img, reverse = False): delete_if_exist("tmp.nii") return dcm -def plot3d(CFR_crop, vmax = 3.5, sample_rate = 5): - matplotlib.use('module://ipympl.backend_nbagg') - fig = plt.figure(figsize=(8, 8)) - ax = fig.add_subplot(111, projection="3d") - - x, y, z = np.indices(CFR_crop.shape) - - # Filter the coordinates and CFR values using the mask - mask_indices = CFR_crop[:].nonzero() - x_masked = x[mask_indices] - y_masked = y[mask_indices] - z_masked = z[mask_indices] - CFR_masked = CFR_crop[mask_indices] - - x_masked = x_masked[::sample_rate] - y_masked = y_masked[::sample_rate] - z_masked = z_masked[::sample_rate] - CFR_masked = CFR_masked[::sample_rate] - - ax.set_xlabel("X-axis") - ax.set_ylabel("Y-axis") - ax.set_zlabel("Z-axis") - ax.set_title("Interactive 3D CFR Visualization") - # Plot only the masked values - scatter = ax.scatter( - x_masked, - y_masked, - z_masked, - c=CFR_masked, - cmap="jet", - vmin=0, - vmax=vmax, - s=1 - ) - - # Add a colorbar and adjust its position - colorbar = fig.colorbar(scatter, ax=ax, shrink=0.6, aspect=15, pad=0.1) - colorbar.set_label("CFR Intensity") - plt.show() +def erode(mask = np.ones((6, 6)), size = 2): + mask = mask[:] + structure = np.ones((2*size + 1, 2*size + 1)) + # Erode the mask + eroded_mask = ndimage.binary_erosion(mask, structure).astype(mask.dtype) + return eroded_mask + From 5cbe80e154fa59a78b5f5bba95a4a6b6d4c01e60 Mon Sep 17 00:00:00 2001 From: Qiyu Zhang <148504334+Qiyu-Zh@users.noreply.github.com> Date: Wed, 5 Feb 2025 15:13:09 -0800 Subject: [PATCH 06/20] Update setup.py --- setup.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 21409c0..38a8d01 100644 --- a/setup.py +++ b/setup.py @@ -9,11 +9,12 @@ "SimpleITK", "ipywidgets", "matplotlib", - "numpy", + "numpy==1.25", "opencv-python", "scikit-image", - "antspyx", - "monai" + "antspyx==0.4.2", + "monai", + "scipy" ], classifiers=[ # Metadata "Programming Language :: Python :: 3", From 872a04a12fe249160d4ad0a6fd7f4cc174da9a40 Mon Sep 17 00:00:00 2001 From: Qiyu Zhang <148504334+Qiyu-Zh@users.noreply.github.com> Date: Thu, 6 Feb 2025 11:11:46 -0800 Subject: [PATCH 07/20] Update setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 38a8d01..96e0e7a 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ install_requires=[ # List of dependencies "SimpleITK", "ipywidgets", - "matplotlib", + "matplotlib==3.5.1", "numpy==1.25", "opencv-python", "scikit-image", From 083c1b130bfac0cb04530f70294e785ebf5bddc5 Mon Sep 17 00:00:00 2001 From: Qiyu Zhang <148504334+Qiyu-Zh@users.noreply.github.com> Date: Thu, 6 Feb 2025 11:35:14 -0800 Subject: [PATCH 08/20] Update setup.py --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 96e0e7a..677fc5e 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ from setuptools import setup, find_packages setup( - name='Image-Toolbox', + name='ImageTool', version="0.1.0", description="A short description of your package", packages=find_packages(), # Automatically find and include your package @@ -19,5 +19,5 @@ classifiers=[ # Metadata "Programming Language :: Python :: 3", ], - python_requires='>=3.8', + python_requires'>=3.8', ) From 07e272f33a6097a3a607b13874c2d308fdbd7886 Mon Sep 17 00:00:00 2001 From: Qiyu Zhang <148504334+Qiyu-Zh@users.noreply.github.com> Date: Thu, 6 Feb 2025 12:05:00 -0800 Subject: [PATCH 09/20] Update tool.py --- ImageTool/tool.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ImageTool/tool.py b/ImageTool/tool.py index cb704d0..186f30a 100644 --- a/ImageTool/tool.py +++ b/ImageTool/tool.py @@ -16,7 +16,8 @@ import monai import monai.metrics -def compute_hu_distance(): +def compute_hu_distance(n_classes, y_pred, y): + data = [] for i in range(1, n_classes): data.append(monai.metrics.compute_hausdorff_distance((y_pred == i), (y == i), include_background=False, distance_metric='euclidean', percentile=None, directed=False, spacing=None)) return data From 1fd45a23411e5b5bb320b8232332ba6ab1dcb352 Mon Sep 17 00:00:00 2001 From: Qiyu Zhang <148504334+Qiyu-Zh@users.noreply.github.com> Date: Thu, 6 Feb 2025 12:05:54 -0800 Subject: [PATCH 10/20] Update setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 677fc5e..ab78cd6 100644 --- a/setup.py +++ b/setup.py @@ -19,5 +19,5 @@ classifiers=[ # Metadata "Programming Language :: Python :: 3", ], - python_requires'>=3.8', + python_requires='>=3.8', ) From d6916986937af2c353b128412b06187228750d52 Mon Sep 17 00:00:00 2001 From: Qiyu Zhang <148504334+Qiyu-Zh@users.noreply.github.com> Date: Thu, 6 Feb 2025 14:06:59 -0800 Subject: [PATCH 11/20] Update tool.py --- ImageTool/tool.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ImageTool/tool.py b/ImageTool/tool.py index 186f30a..0753943 100644 --- a/ImageTool/tool.py +++ b/ImageTool/tool.py @@ -9,7 +9,6 @@ import cv2 import os import shutil -from totalsegmentator.python_api import totalsegmentator from skimage.metrics import structural_similarity from scipy import ndimage import ants From be9eaf2eca30c524f3f5e1c532c12b95614bbf7e Mon Sep 17 00:00:00 2001 From: Qiyu Zhang <148504334+Qiyu-Zh@users.noreply.github.com> Date: Wed, 19 Feb 2025 15:37:48 -0800 Subject: [PATCH 12/20] Update tool.py --- ImageTool/tool.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/ImageTool/tool.py b/ImageTool/tool.py index 0753943..e8e98db 100644 --- a/ImageTool/tool.py +++ b/ImageTool/tool.py @@ -14,7 +14,54 @@ import ants import monai import monai.metrics +import nibabel as nib +import nibabel.processing +def as_closest_canonical(img_in): + """ + Convert the given nifti file to the closest canonical nifti file. + """ + return nib.as_closest_canonical(img_in) + + +def as_closest_canonical_nifti(path_in, path_out): + """ + Convert the given nifti file to the closest canonical nifti file. + """ + img_in = nib.load(path_in) + img_out = nib.as_closest_canonical(img_in) + nib.save(img_out, path_out) + + +def undo_canonical(img_can, img_orig): + """ + Inverts nib.to_closest_canonical() + + img_can: the image we want to move back + img_orig: the original image because transforming to canonical + + returns image in original space + + https://github.com/nipy/nibabel/issues/1063 + """ + from nibabel.orientations import io_orientation, axcodes2ornt, ornt_transform + + img_ornt = io_orientation(img_orig.affine) + ras_ornt = axcodes2ornt("RAS") + + to_canonical = img_ornt # Same as ornt_transform(img_ornt, ras_ornt) + from_canonical = ornt_transform(ras_ornt, img_ornt) + + # Same as as_closest_canonical + # img_canonical = img_orig.as_reoriented(to_canonical) + + return img_can.as_reoriented(from_canonical) + +def undo_canonical_nifti(path_in_can, path_in_orig, path_out): + e = nib.load(path_in_can) + img_orig = nib.load(path_in_orig) + img_out = undo_canonical(img_can, img_orig) + nib.save(img_out, path_out) def compute_hu_distance(n_classes, y_pred, y): data = [] for i in range(1, n_classes): From d34a6ccf1d04e814e6d56856824a596029b217de Mon Sep 17 00:00:00 2001 From: Qiyu Zhang <148504334+Qiyu-Zh@users.noreply.github.com> Date: Wed, 19 Feb 2025 15:50:03 -0800 Subject: [PATCH 13/20] Update tool.py --- ImageTool/tool.py | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/ImageTool/tool.py b/ImageTool/tool.py index e8e98db..ca7774e 100644 --- a/ImageTool/tool.py +++ b/ImageTool/tool.py @@ -16,11 +16,7 @@ import monai.metrics import nibabel as nib import nibabel.processing -def as_closest_canonical(img_in): - """ - Convert the given nifti file to the closest canonical nifti file. - """ - return nib.as_closest_canonical(img_in) +from nibabel.orientations import io_orientation, axcodes2ornt, ornt_transform def as_closest_canonical_nifti(path_in, path_out): @@ -33,18 +29,6 @@ def as_closest_canonical_nifti(path_in, path_out): def undo_canonical(img_can, img_orig): - """ - Inverts nib.to_closest_canonical() - - img_can: the image we want to move back - img_orig: the original image because transforming to canonical - - returns image in original space - - https://github.com/nipy/nibabel/issues/1063 - """ - from nibabel.orientations import io_orientation, axcodes2ornt, ornt_transform - img_ornt = io_orientation(img_orig.affine) ras_ornt = axcodes2ornt("RAS") @@ -58,10 +42,11 @@ def undo_canonical(img_can, img_orig): def undo_canonical_nifti(path_in_can, path_in_orig, path_out): - e = nib.load(path_in_can) + img_can = nib.load(path_in_can) img_orig = nib.load(path_in_orig) img_out = undo_canonical(img_can, img_orig) nib.save(img_out, path_out) + def compute_hu_distance(n_classes, y_pred, y): data = [] for i in range(1, n_classes): From a9f82c7c81a24fa2d08101611bd9e393cc69dd50 Mon Sep 17 00:00:00 2001 From: Qiyu Zhang <148504334+Qiyu-Zh@users.noreply.github.com> Date: Thu, 20 Feb 2025 11:19:35 -0800 Subject: [PATCH 14/20] Update tool.py --- ImageTool/tool.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/ImageTool/tool.py b/ImageTool/tool.py index ca7774e..bfa3261 100644 --- a/ImageTool/tool.py +++ b/ImageTool/tool.py @@ -190,16 +190,37 @@ def crop_with_bbox(image_path, bbox, output_path = None): sitk.WriteImage(cropped_image, output_path) return cropped_image -def load_2d_3d(dicom_folder, output_path = None): - # Use SimpleITK to read the DICOM series +def load_2d_3d(dicom_input, output_path=None): + """ + Convert a folder of DICOM files or a list of DICOM file paths into a 3D NIfTI image. + + Parameters: + dicom_input (str or list): Either a folder containing DICOM files or a list of file paths. + output_path (str, optional): Path to save the output NIfTI file. + + Returns: + sitk.Image: The 3D image. + """ reader = sitk.ImageSeriesReader() - # Get the list of DICOM file names from the directory - dicom_files = reader.GetGDCMSeriesFileNames(dicom_folder) + + if isinstance(dicom_input, str) and os.path.isdir(dicom_input): + # If dicom_input is a folder, get the list of DICOM files + dicom_files = reader.GetGDCMSeriesFileNames(dicom_input) + elif isinstance(dicom_input, list) and all(os.path.isfile(f) for f in dicom_input): + # If dicom_input is a list of files, use it directly + dicom_files = dicom_input + else: + raise ValueError("dicom_input must be a valid folder path or a list of file paths.") + # Load the DICOM series reader.SetFileNames(dicom_files) image = reader.Execute() + + # Save as NIfTI if output_path is provided if output_path: sitk.WriteImage(image, output_path) + print(f"3D NIfTI saved at: {output_path}") + return image def plot_contour(mask_for_cv, ax, color = "red", label = None): From 07198e6a5ea9e085656587eebeb858ba648bf5b9 Mon Sep 17 00:00:00 2001 From: Qiyu Zhang <148504334+Qiyu-Zh@users.noreply.github.com> Date: Thu, 20 Feb 2025 15:41:07 -0800 Subject: [PATCH 15/20] Update tool.py --- ImageTool/tool.py | 87 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/ImageTool/tool.py b/ImageTool/tool.py index bfa3261..0e41721 100644 --- a/ImageTool/tool.py +++ b/ImageTool/tool.py @@ -18,6 +18,93 @@ import nibabel.processing from nibabel.orientations import io_orientation, axcodes2ornt, ornt_transform +import SimpleITK as sitk + +def demons_registration( + fixed_image, moving_image, fixed_mask=None, moving_mask=None, fixed_points=None, moving_points=None +): + registration_method = sitk.ImageRegistrationMethod() + + # Create initial identity transformation. + transform_to_displacement_field_filter = sitk.TransformToDisplacementFieldFilter() + transform_to_displacement_field_filter.SetReferenceImage(fixed_image) + + initial_transform = sitk.DisplacementFieldTransform( + transform_to_displacement_field_filter.Execute(sitk.Transform()) + ) + + # Regularization (update field - viscous, total field - elastic). + initial_transform.SetSmoothingGaussianOnUpdate( + varianceForUpdateField=0.0, varianceForTotalField=2.0 + ) + + registration_method.SetInitialTransform(initial_transform) + + # Set Demons metric + registration_method.SetMetricAsDemons(10) # intensities are equal if diff < 10HU + + # Provide masks if available + if fixed_mask is not None: + registration_method.SetMetricFixedMask(fixed_mask) + if moving_mask is not None: + registration_method.SetMetricMovingMask(moving_mask) + + # Multi-resolution framework. + registration_method.SetShrinkFactorsPerLevel(shrinkFactors=[4, 2, 1]) + registration_method.SetSmoothingSigmasPerLevel(smoothingSigmas=[8, 4, 0]) + + registration_method.SetInterpolator(sitk.sitkLinear) + + # Optimizer + registration_method.SetOptimizerAsGradientDescent( + learningRate=1.0, + numberOfIterations=20, + convergenceMinimumValue=1e-6, + convergenceWindowSize=10, + ) + registration_method.SetOptimizerScalesFromPhysicalShift() + + # If corresponding points are given, display similarity metric and TRE + if fixed_points and moving_points: + registration_method.AddCommand( + sitk.sitkStartEvent, rc.metric_and_reference_start_plot + ) + registration_method.AddCommand( + sitk.sitkEndEvent, rc.metric_and_reference_end_plot + ) + registration_method.AddCommand( + sitk.sitkIterationEvent, + lambda: rc.metric_and_reference_plot_values( + registration_method, fixed_points, moving_points + ), + ) + + # Execute the registration + final_transform = registration_method.Execute(fixed_image, moving_image) + + # Apply the transformation to the moving image + registered_moving_image = sitk.Resample( + moving_image, + fixed_image, # Reference space + final_transform, + sitk.sitkLinear, # Interpolation method for images + 0.0, # Default pixel value for regions outside original image + moving_image.GetPixelID() + ) + + # Apply the transformation to the multi-label moving mask if given + registered_moving_mask = None + if moving_mask is not None: + registered_moving_mask = sitk.Resample( + moving_mask, + fixed_image, # Reference space + final_transform, + sitk.sitkNearestNeighbor, # Nearest neighbor to preserve labels + 0, # Background value remains 0 + moving_mask.GetPixelID() + ) + + return final_transform, registered_moving_image, registered_moving_mask def as_closest_canonical_nifti(path_in, path_out): """ From cb188805823ec36e7f0d36e95ee32e8c0b54f7ba Mon Sep 17 00:00:00 2001 From: Yumeng Zhang <62467045+YumengggZhang@users.noreply.github.com> Date: Thu, 15 May 2025 08:37:32 -0700 Subject: [PATCH 16/20] Update tool.py --- ImageTool/tool.py | 90 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 77 insertions(+), 13 deletions(-) diff --git a/ImageTool/tool.py b/ImageTool/tool.py index 0e41721..19c8640 100644 --- a/ImageTool/tool.py +++ b/ImageTool/tool.py @@ -1,24 +1,29 @@ -import SimpleITK as sitk +# Core imaging and display libraries +import SimpleITK as sitk # For medical image I/O and registration +import ants # Advanced Normalization Tools for image registration +import nibabel as nib # For working with NIfTI format +import nibabel.processing +from nibabel.orientations import io_orientation, axcodes2ornt, ornt_transform + +# Visualization and interactivity import ipywidgets as widgets from IPython.display import display -import numpy as np from ipywidgets import interact from ipywidgets.widgets import IntSlider import matplotlib.pyplot as plt import matplotlib + +# General utilities +import numpy as np import cv2 import os import shutil -from skimage.metrics import structural_similarity +from skimage.metrics import structural_similarity # For SSIM calculation from scipy import ndimage -import ants + +# MONAI: Medical imaging deep learning utilities import monai import monai.metrics -import nibabel as nib -import nibabel.processing -from nibabel.orientations import io_orientation, axcodes2ornt, ornt_transform - -import SimpleITK as sitk def demons_registration( fixed_image, moving_image, fixed_mask=None, moving_mask=None, fixed_points=None, moving_points=None @@ -105,16 +110,22 @@ def demons_registration( ) return final_transform, registered_moving_image, registered_moving_mask +# ============================ +# Orientation and NIfTI Tools +# ============================ def as_closest_canonical_nifti(path_in, path_out): """ - Convert the given nifti file to the closest canonical nifti file. + Converts a NIfTI image to the closest canonical orientation (RAS). + + Parameters: + path_in (str): Input NIfTI file path. + path_out (str): Output file path to save the reoriented image. """ img_in = nib.load(path_in) img_out = nib.as_closest_canonical(img_in) nib.save(img_out, path_out) - def undo_canonical(img_can, img_orig): img_ornt = io_orientation(img_orig.affine) ras_ornt = axcodes2ornt("RAS") @@ -129,12 +140,31 @@ def undo_canonical(img_can, img_orig): def undo_canonical_nifti(path_in_can, path_in_orig, path_out): + """ + Reverts a canonicalized NIfTI image back to the original orientation. + + Parameters: + path_in_can (str): Canonicalized NIfTI path. + path_in_orig (str): Original NIfTI path (provides orientation reference). + path_out (str): Output file path for reverted image. + """ img_can = nib.load(path_in_can) img_orig = nib.load(path_in_orig) img_out = undo_canonical(img_can, img_orig) nib.save(img_out, path_out) def compute_hu_distance(n_classes, y_pred, y): + """ + Computes Hausdorff distance between predicted and ground truth labels. + + Parameters: + n_classes (int): Number of label classes (excluding background). + y_pred (ndarray): Predicted label volume. + y (ndarray): Ground truth label volume. + + Returns: + List of Hausdorff distances for each class. + """ data = [] for i in range(1, n_classes): data.append(monai.metrics.compute_hausdorff_distance((y_pred == i), (y == i), include_background=False, distance_metric='euclidean', percentile=None, directed=False, spacing=None)) @@ -143,6 +173,18 @@ def compute_hu_distance(n_classes, y_pred, y): def get_HU_error(y_pred, y, onehot = False, n_classes = None): + """ + Computes Hausdorff distance with optional one-hot encoding support. + + Parameters: + y_pred (ndarray): Predicted labels or one-hot encoded array. + y (ndarray): Ground truth labels or one-hot encoded array. + onehot (bool): Whether input arrays are already one-hot encoded. + n_classes (int): Number of classes, required if onehot=False. + + Returns: + Hausdorff distance (tensor). + """ def get_onehot(label, n_classes): one_hot = np.eye(n_classes)[label] # Shape: (1, 32, 32, 32, 6) one_hot = one_hot.transpose(3, 0, 1, 2) @@ -153,10 +195,23 @@ def get_onehot(label, n_classes): if onehot is False: y = get_onehot(y, n_classes) # Shape: (1, 32, 32, 32, 6) y_pred = get_onehot(y_pred, n_classes) - return monai.metrics.compute_hausdorff_distance(y_pred, y, include_background=False, distance_metric='euclidean', percentile=None, directed=False, spacing=None) - + return monai.metrics.compute_hausdorff_distance(y_pred, y, include_background=False, distance_metric='euclidean', percentile=None, directed=False, spacing=None) + +# ============================ +# Morphological Operations +# ============================ def erode3d(mask=np.ones((6, 6, 6)), size=2): + """ + Perform 3D binary erosion on a mask. + + Parameters: + mask (ndarray): 3D binary mask. + size (int): Radius of erosion kernel. + + Returns: + ndarray: Eroded mask. + """ # Ensure mask is boolean mask = mask[:] @@ -169,6 +224,15 @@ def erode3d(mask=np.ones((6, 6, 6)), size=2): return eroded_mask def plot3d(CFR_crop, mask = None, vmax = 2, sample_rate = 5): + """ + Plot 3D scatter of volume data optionally filtered by a binary mask. + + Parameters: + volume (ndarray): 3D scalar volume. + mask (ndarray): Optional binary mask. + vmax (float): Color scale maximum. + sample_rate (int): Sampling rate for plotting. + """ matplotlib.use('module://ipympl.backend_nbagg') fig = plt.figure(figsize=(8, 8)) ax = fig.add_subplot(111, projection="3d") From c1f6f3f0053ca9d91b1a78716ec1ac42dc99347b Mon Sep 17 00:00:00 2001 From: Yumeng Zhang <62467045+YumengggZhang@users.noreply.github.com> Date: Thu, 15 May 2025 08:39:01 -0700 Subject: [PATCH 17/20] Update tool.py --- ImageTool/tool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ImageTool/tool.py b/ImageTool/tool.py index 19c8640..659bc2d 100644 --- a/ImageTool/tool.py +++ b/ImageTool/tool.py @@ -393,8 +393,6 @@ def plot_mask(mask_for_cv, ax, color = "Blues", alpha = 0.5, label = None): def list_display(img_list, name = "", vmax = 300, cmap = 'jet', read_dcm = False): - - if read_dcm: img_list = [sitk.GetArrayFromImage(sitk.ReadImage(dcm_file)) for dcm_file in img_list] max_slices_contrast = max(img_list, key = lambda x: x.shape[2]).shape[2] @@ -429,6 +427,7 @@ def update(contrast_slice_index): display_slice(contrast_slice_index, img_list, name) widgets.interact(update, contrast_slice_index=contrast_slice_slider) + def display_slice(contrast_slice_index, img_list, name): n = len(img_list) fig, axes = plt.subplots(1, n, figsize=(6*n, 6)) @@ -459,6 +458,7 @@ def lists_display(img_lists, cmap = 'jet', name = None, titles = None): contrast_slice_slider = widgets.IntSlider(min=0, max=max_slices_contrast-1, step=1, value=0, description='Slice:') list_idx_slider = widgets.IntSlider(min=0, max=max_num-1, step=1, value=0, description='img_idx:') + def display_slice(contrast_slice_index, list_idx_slider): n = max(len(img_lists), 2) fig, axes = plt.subplots(1, n, figsize=(6*n, 6)) From b33cfc23f1cc5e89551f93c34dab949eecc5b263 Mon Sep 17 00:00:00 2001 From: Yumeng Zhang <62467045+YumengggZhang@users.noreply.github.com> Date: Thu, 15 May 2025 08:47:43 -0700 Subject: [PATCH 18/20] Update tool.py --- ImageTool/tool.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ImageTool/tool.py b/ImageTool/tool.py index 659bc2d..872595a 100644 --- a/ImageTool/tool.py +++ b/ImageTool/tool.py @@ -393,6 +393,16 @@ def plot_mask(mask_for_cv, ax, color = "Blues", alpha = 0.5, label = None): def list_display(img_list, name = "", vmax = 300, cmap = 'jet', read_dcm = False): + """ + Display multiple 3D volumes side by side in a Jupyter widget with slice slider. + + Parameters: + img_list (list): List of either file paths (if read_dcm=True) or numpy arrays. + name (str): Title prefix for displayed figures. + vmax (int): Max intensity for color scaling. + cmap (str): Matplotlib colormap. + read_dcm (bool): Whether img_list contains paths to DICOM/NIfTI files. + """ if read_dcm: img_list = [sitk.GetArrayFromImage(sitk.ReadImage(dcm_file)) for dcm_file in img_list] max_slices_contrast = max(img_list, key = lambda x: x.shape[2]).shape[2] From 59d5e992f935f1b2a8658fb4182d88ccf95e43ed Mon Sep 17 00:00:00 2001 From: Yumeng Zhang <62467045+YumengggZhang@users.noreply.github.com> Date: Thu, 15 May 2025 09:03:29 -0700 Subject: [PATCH 19/20] Update tool.py --- ImageTool/tool.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/ImageTool/tool.py b/ImageTool/tool.py index 872595a..5939609 100644 --- a/ImageTool/tool.py +++ b/ImageTool/tool.py @@ -425,7 +425,14 @@ def update(contrast_slice_index): def folders_display(img_list, name = "", read_dcm = False): + """ + Display a list of 3D volumes from folders or numpy arrays using axial slice navigation. + Parameters: + img_list (list): List of volume paths or numpy arrays. + name (str): Figure title prefix. + read_dcm (bool): If True, will read images from DICOM/NIfTI files. + """ if read_dcm: img_list = [sitk.GetArrayFromImage(sitk.ReadImage(dcm_file)) for dcm_file in img_list] max_slices_contrast = max(img_list, key = lambda x: x.shape[0]).shape[0] @@ -439,6 +446,14 @@ def update(contrast_slice_index): widgets.interact(update, contrast_slice_index=contrast_slice_slider) def display_slice(contrast_slice_index, img_list, name): + """ + Helper function to plot one slice across multiple volumes. + + Parameters: + contrast_slice_index (int): Index of the slice to display. + img_list (list): List of 3D numpy arrays. + name (str): Figure title prefix. + """ n = len(img_list) fig, axes = plt.subplots(1, n, figsize=(6*n, 6)) fig.suptitle(name + f' Slice {contrast_slice_index}') @@ -450,6 +465,15 @@ def display_slice(contrast_slice_index, img_list, name): def lists_display(img_lists, cmap = 'jet', name = None, titles = None): + """ + Display interactive visualization for multiple lists of image volumes. + + Parameters: + img_lists (list): Nested list of images or paths. Outer list is category/group. + cmap (str): Matplotlib colormap. + name (str): Optional overall title. + titles (list): Optional list of titles per group. + """ if name == None: name = "" if titles == None: From e53700347317a909d702a0bc7c87fa5402bbdf68 Mon Sep 17 00:00:00 2001 From: Yumeng Zhang <62467045+YumengggZhang@users.noreply.github.com> Date: Thu, 15 May 2025 09:13:43 -0700 Subject: [PATCH 20/20] Update README.md --- README.md | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e7aa5a7..56eee12 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,28 @@ plot_mask(mask_for_cv, ax, color = "Blues", alpha = 0.5, label = None): ``` plot the mask inside the mask_for_cv to matplotlib ax. The mask should be binary. ``` -interactive_display(img_list, name = ""): +list_display(img_list, name="", ...) ``` -Visualization for all 3d images in img_list by slice. Notice all image should have the same number of slices. Name is the title +Visualize corresponding slices across multiple 3D volumes side-by-side in an interactive slider-based widget, supporting DICOM and NIfTI formats. +``` +folders_display(img_list, name="", ...) +``` +Similar to list_display, but assumes each item is a full 3D volume and displays axial slices interactively for folder-based batch inspection. +``` +plot3d(volume, mask=None, ...) +``` +Plots a 3D scatter of intensity values from a volume, optionally masked and downsampled, providing quick insight into spatial distribution. +``` +load_2d_3d(dicom_input, output_path=None) +``` +Converts a DICOM series or list of slices into a single 3D volume, enabling standardized volumetric analysis. +``` +load_3d_2d(dcm_file, output_path) +``` +Slices a 3D DICOM volume into 2D NIfTI slices while preserving correct spatial metadata (origin, spacing). +``` +erode3d(mask, size=2) +``` +Performs 3D binary erosion on segmentation masks to refine region boundaries or isolate structures. + +