Unifying CT Images Geometry and Cropping to Common ROI

Hi everyone,

I am currently working on a medical image processing task that involves the registration of five CT image series for a single patient. My dataset consists of:

One CT scan acquired during an External Radiotherapy (EBRT) session, which presents a significantly different Field of View compared to the others and four subsequent CT scans from Brachytherapy for the cervix.

To continue my work, a crucial pre-processing step is required:

  • Homogenizing the spatial information (Origin, Image Spacing, and FOV) across all five images to ensure they reside in a consistent physical space.
  • Extracting the largest common anatomical region that is present in all scans, to serve as the input for subsequent registration and analysis steps.

I have been exploring the SimpleITK-Notebooks for guidance, particularly the “Create reference domain” section in:
(SimpleITK-Notebooks/Python/70_Data_Augmentation.ipynb at main · InsightSoftwareConsortium/SimpleITK-Notebooks · GitHub) to define a unified image space.

Despite this, I am consistently encountering persistent and undesirable padding in various anatomical views (axial, sagittal, and coronal) of the processed images. Furthermore, some of my attempts have resulted in excessive cropping, inadvertently removing valuable anatomical structures required for downstream analysis.

Could you please provide guidance on how to robustly achieve a clean, common anatomical region across all these diverse CT scans, ensuring no residual padding remains while preserving essential anatomical features (like applicators or the bladder) and avoiding over-cropping?

Thanks a lot

my code:

import SimpleITK as sitk
import numpy as np
import os
def read_dicom_series(directory):
“”"
Reads a DICOM series from a directory and returns a SimpleITK Image object.
“”"
if not os.path.isdir(directory):
raise FileNotFoundError(f"Directory not found: {directory}“)
series_IDs = sitk.ImageSeriesReader.GetGDCMSeriesIDs(directory)
if not series_IDs:
raise ValueError(f"No DICOM series found in directory: {directory}”)
series_files = sitk.ImageSeriesReader.GetGDCMSeriesFileNames(directory, series_IDs[0])
reader = sitk.ImageSeriesReader()
reader.SetFileNames(series_files)
image = reader.Execute()
print(f"Loaded DICOM series from {directory} with size {image.GetSize()}, origin {image.GetOrigin()}, spacing {image.GetSpacing()}“)
return image
def compute_physical_bounds(img):
“””
Computes the physical bounds (min and max coordinates) of an image in physical space.
Returns a tuple of (min_bounds, max_bounds) for each dimension.
“”"
size = np.array(img.GetSize())
spacing = np.array(img.GetSpacing())
origin = np.array(img.GetOrigin())
max_bounds = origin + (size - 1) * spacing
return origin, max_bounds
def crop_to_common_region(images):
“”"
Crops all images to the common anatomical region (intersection of physical bounds).
Returns a list of cropped images.
“”"
if not images:
raise ValueError(“No images provided for cropping.”)
# Computes physical bounds for all images
bounds = [compute_physical_bounds(img) for img in images]
min_bounds_all = [b[0] for b in bounds] # List of origins
max_bounds_all = [b[1] for b in bounds] # List of max coordinates
# FindS the common region (max of min bounds, min of max bounds)
common_min = np.max(min_bounds_all, axis=0)
common_max = np.min(max_bounds_all, axis=0)
if np.any(common_min >= common_max):
print(“Warning: No common region found among the images. Returning original images.”)
return images
print(f"Common physical region for cropping: Min bounds {common_min}, Max bounds {common_max}“)
# Crops each image to the common region
cropped_images = [ ]
for i, img in enumerate(images):
origin = np.array(img.GetOrigin())
spacing = np.array(img.GetSpacing())
size = np.array(img.GetSize())
# Compute index bounds for cropping
start_idx = np.maximum(0, np.ceil((common_min - origin) / spacing)).astype(int)
end_idx = np.minimum(size, np.floor((common_max - origin) / spacing) + 1).astype(int)
if np.any(end_idx <= start_idx):
print(f"Warning: Cannot crop image {i+1} (invalid bounds {start_idx} to {end_idx}). Keeping original.”)
cropped_images.append(img)
continue
# Extract region
cropped_img = img[start_idx[0]:end_idx[0], start_idx[1]:end_idx[1], start_idx[2]:end_idx[2]]
print(f"Cropped image {i+1} to size {cropped_img.GetSize()}“)
cropped_images.append(cropped_img)
return cropped_images
def create_reference_domain(images, pixel_size_per_dimension=128):
“””
Uses the largest physical size among images.
“”"
dimension = images[0].GetDimension()
# Computes the largest physical size among all images
reference_physical_size = np.zeros(dimension)
for img in images:
phys_size = np.array([(sz - 1) * spc for sz, spc in zip(img.GetSize(), img.GetSpacing())])
reference_physical_size = np.maximum(reference_physical_size, phys_size)
print(f"Reference physical size (largest FOV): {reference_physical_size}“)
# Sets origin and direction to standard values (as in the notebook)
reference_origin = np.zeros(dimension)
reference_direction = np.identity(dimension).flatten()
# Sets arbitrary pixel size
reference_size = [pixel_size_per_dimension] * dimension
reference_spacing = [phys_sz / (sz - 1) for sz, phys_sz in zip(reference_size, reference_physical_size)]
print(f"Reference size: {reference_size}”)
print(f"Reference spacing: {reference_spacing}“)
# Creates reference image
reference_image = sitk.Image(reference_size, images[0].GetPixelIDValue())
reference_image.SetOrigin(reference_origin)
reference_image.SetSpacing(reference_spacing)
reference_image.SetDirection(reference_direction)
# Computes reference center (as in the notebook)
reference_center = np.array(
reference_image.TransformContinuousIndexToPhysicalPoint(
np.array(reference_image.GetSize()) / 2.0
)
)
print(f"Reference center: {reference_center}”)
return reference_image
def resample_to_reference(images, reference_image):
“”"
Resamples all images to the reference domain, centering them.
“”"
resampled_images = [ ]
for i, img in enumerate(images):
# Creates a transform to center the image in the reference domain
transform = sitk.CenteredTransformInitializer(
reference_image,
img,
sitk.Euler3DTransform(),
sitk.CenteredTransformInitializerFilter.GEOMETRY
)
resampler = sitk.ResampleImageFilter()
resampler.SetReferenceImage(reference_image)
resampler.SetInterpolator(sitk.sitkBSpline)
resampler.SetTransform(transform)
resampler.SetDefaultPixelValue(0)
resampled_img = resampler.Execute(img)
print(f"Resampled image {i+1} to reference domain with size {resampled_img.GetSize()}“)
resampled_images.append(resampled_img)
return resampled_images
dicom_dirs = [
r’C:\Users\Desktop\extrenal’,
r’C:\Users\Desktop\fr1’,
r’C:\Users\Desktop\fr2’,
r’C:\Users\Desktop\fr3’,
r’C:\Users\Desktop\fr4’
]
output_dir = r’C:\Users\Desktop\resampled’
if not os.path.exists(output_dir):
os.makedirs(output_dir)
# Step 1: Loads DICOM series
images = [read_dicom_series(dir_path) for dir_path in dicom_dirs]
# Step 2: Crops to common region (to preserve only the intersection of physical regions)
do_crop_to_common = True
if do_crop_to_common:
processed_images = crop_to_common_region(images)
else:
processed_images = images
print(“Skipping cropping to common region; using original images directly.”)
# Step 3: Creates reference domain (using largest physical size)
reference_image = create_reference_domain(processed_images, pixel_size_per_dimension=128)
# Step 4: Resamples all images to reference domain with centering
resampled_images = resample_to_reference(processed_images, reference_image)
# Step 5: Saves resampled images (as NIfTI)
for i, resampled_img in enumerate(resampled_images):
output_path = os.path.join(output_dir, f’resampled_ct{i+1}.nii’)
sitk.WriteImage(resampled_img, output_path)
print(f"Saved resampled image to {output_path}”)

Hello @blue_sky,

Based on your goal, registering multiple subsequent CT scans to an initial scan, the data modification step that you are attempting to perform is best avoided. It introduces a resampling operation before any registration that modifies the original data unnecessarily.

Also, based on your description “One CT scan acquired during an External Radiotherapy (EBRT) session, which presents a significantly different Field of View compared to the others” the physical size of the reference domain returned by create_reference_domain is potentially very large and thus the resulting resampled images likely have a significant amount of empty space, which is what you are seeing. In the extreme, the first scan is a whole body scan and the consecutive scans are of the pelvic region. After this processing the pelvic region scans are embedded in a much larger image which is mostly empty.

Given the problem description, a more relevant workflow is the one illustrated in the x-ray panorma notebook. Please take a look at that flow, registration followed by computing the shared physical domain and resampling onto that. The relevant function which is called after registration is create_images_in_shared_coordinate_system.

If this does not address your needs, please provide additional details.

1 Like

hello, Many thanks for your guidance. I have implemented a workflow that:

-Rigidly registers CT images.

-Resamples the corresponding RTDOSE images to match the registered CT geometry.

-Modifies RTSTRUCT contours according to the CT transformation, ensuring they match perfectly with the updated CT.

Currently, the code saves the output CT, Dose, and Mask images in NIfTI format. When I load these results into 3D Slicer, they display correctly — spacing, origin, geometry, and registration are all preserved.

However, for the next steps in my workflow, I need to save these outputs in standard DICOM format:

CT images should be stored as a proper DICOM series.

Dose images should preserve geometry, DoseGridScaling, and match exactly to the CT’s spacing, origin, and size.

RTSTRUCT should remain spatially aligned with the transformed CT.

When I try saving as DICOM directly from the resampled SimpleITK images, I encounter issues:

Dose files don’t always preserve metadata correctly.

In some cases, resulting DICOMs are not readable or appear distorted in 3D Slicer.

Could you guide me on the best practices for converting these registered NIfTI outputs into fully compliant DICOM CT, DOSE, and RTSTRUCT files while preserving all geometry and metadata?

thanks a lot

My Code:

import SimpleITK as sitk
import numpy as np
import os
import pydicom
from pydicom.uid import generate_uid
import logging
from datetime import datetime
import time
def setup_logging():
“”“Setup logging configuration”“”
logging.basicConfig(
level=logging.INFO,
format=‘%(asctime)s - %(levelname)s - %(message)s’,
handlers=[
logging.FileHandler(‘registration.log’),
logging.StreamHandler()
]
)
return logging.getLogger(name)
def read_dicom_series(directory, is_dose=False):
“”“Reads DICOM series with proper dose scaling”“”
if not os.path.isdir(directory):
raise FileNotFoundError(f"Directory not found: {directory}“)
if is_dose:
dicom_files = [os.path.join(directory, f) for f in os.listdir(directory)
if os.path.isfile(os.path.join(directory, f)) and
not f.lower().endswith((‘.txt’, ‘.xml’, ‘.log’, ‘.zip’))]
if len(dicom_files) != 1:
raise ValueError(f"Expected ONE DICOM file in dose directory, but found {len(dicom_files)} in: {directory}”)
reader = sitk.ImageFileReader()
reader.SetFileName(dicom_files[0])
image = reader.Execute()
# Gets dose scaling factor
dose_scaling = 1.0
try:
dose_scaling = float(reader.GetMetaData(“3004|000e”))
print(f"Found and applied DoseGridScaling: {dose_scaling}“)
except (RuntimeError, ValueError):
print(“Warning: Could not read DoseGridScaling tag. Assuming 1.0.”)
return sitk.Cast(image, sitk.sitkFloat64) * dose_scaling, dicom_files[0]
else:
series_IDs = sitk.ImageSeriesReader.GetGDCMSeriesIDs(directory)
if not series_IDs:
raise ValueError(f"No DICOM series found in: {directory}”)
series_files = sitk.ImageSeriesReader.GetGDCMSeriesFileNames(directory, series_IDs[0])
reader = sitk.ImageSeriesReader()
reader.SetFileNames(series_files)
image = reader.Execute()
return image, series_files[0]
def select_reference_image(images, image_names):
“”“Selects reference image with smallest Z FOV”“”
fovs_z = [img.GetSize()[2] * img.GetSpacing()[2] for img in images]
min_z_fov_index = np.argmin(fovs_z)
ref_image = images[min_z_fov_index]
print(f"\nReference image selected: ‘{image_names[min_z_fov_index]}’ (Image {min_z_fov_index + 1})“)
return ref_image, min_z_fov_index
def robust_rigid_registration(fixed_image, moving_image):
“”“rigid registration””"
# Converts to float32 for registration
fixed_image_float = sitk.Cast(fixed_image, sitk.sitkFloat32)
moving_image_float = sitk.Cast(moving_image, sitk.sitkFloat32)
# Initialize transform
initial_transform = sitk.CenteredTransformInitializer(
fixed_image_float, moving_image_float,
sitk.Euler3DTransform(),
sitk.CenteredTransformInitializerFilter.GEOMETRY
)
# Setups registration method
R = sitk.ImageRegistrationMethod()
R.SetMetricAsMattesMutualInformation(numberOfHistogramBins=50)
R.SetMetricSamplingStrategy(R.RANDOM)
R.SetMetricSamplingPercentage(0.01)
R.SetInterpolator(sitk.sitkLinear)
R.SetOptimizerAsGradientDescent(
learningRate=1.0,
numberOfIterations=200,
convergenceMinimumValue=1e-6,
convergenceWindowSize=10
)
R.SetOptimizerScalesFromPhysicalShift()
R.SetShrinkFactorsPerLevel(shrinkFactors=[4, 2, 1])
R.SetSmoothingSigmasPerLevel(smoothingSigmas=[2, 1, 0])
R.SetSmoothingSigmasAreSpecifiedInPhysicalUnits(True)
R.SetInitialTransform(initial_transform, inPlace=False)
try:
final_transform = R.Execute(fixed_image_float, moving_image_float)
print(f" Registration successful. Final metric: {R.GetMetricValue():.6f}“)
return final_transform
except Exception as e:
print(f” Registration failed: {e}. Returning initial alignment.“)
return initial_transform
def create_body_mask(image, threshold=-500):
“”“Creates body mask””"
mask = sitk.BinaryThreshold(image, lowerThreshold=threshold, upperThreshold=10000,
insideValue=1, outsideValue=0)
return sitk.Cast(mask, sitk.sitkUInt8)
def transform_rt_structure(rt_file_path, transform, reference_image, output_dir, dataset_name):
“”“Transforms RT Structure”“”
try:
# Reads RT Structure
ds = pydicom.dcmread(rt_file_path)
if not hasattr(ds, ‘ROIContourSequence’):
logging.warning(f"No ROI contours found in {os.path.basename(rt_file_path)}“)
return False
# Gets inverse transform for structure transformation
inverse_transform = transform.GetInverse()
roi_count = 0
contour_count = 0
# Transforms each ROI contour
for roi_contour in ds.ROIContourSequence:
if hasattr(roi_contour, ‘ContourSequence’):
roi_count += 1
for contour in roi_contour.ContourSequence:
if hasattr(contour, ‘ContourData’):
points = np.array(contour.ContourData).reshape(-1, 3)
# Transforms points using inverse transform
transformed_points = [ ]
for point in points:
transformed_point = inverse_transform.TransformPoint(point.tolist())
transformed_points.extend(transformed_point)
contour.ContourData = transformed_points
contour_count += 1
# Updates Frame of Reference UID to match reference image
try:
if hasattr(ds, ‘ReferencedFrameOfReferenceSequence’) and ds.ReferencedFrameOfReferenceSequence:
ds.ReferencedFrameOfReferenceSequence[0].FrameOfReferenceUID = generate_uid()
except:
pass
# Generates new SOP Instance UID
ds.SOPInstanceUID = generate_uid()
# Saves transformed RT Structure
output_path = os.path.join(output_dir, f”{dataset_name}_RT_Structure.dcm")
ds.save_as(output_path)
print(f" Successfully transformed RT Structure: {roi_count} ROIs, {contour_count} contours")
print(f" Saved to: {os.path.basename(output_path)}“)
return True
except Exception as e:
print(f” ERROR transforming RT Structure {os.path.basename(rt_file_path)}: {str(e)}“)
return False
def main():
“”“Main processing function””"
logger = setup_logging()
# Directory paths
base_dir = r’C:\Users\resampled’
ct_dicom_dirs = [
os.path.join(base_dir, r’ct\extr’),
os.path.join(base_dir, r’ct\fr1’),
os.path.join(base_dir, r’ct\fr2’),
os.path.join(base_dir, r’ct\fr3’),
os.path.join(base_dir, r’ct\fr4’)
]
dose_dicom_dirs = [
os.path.join(base_dir, r’dose\extr’),
os.path.join(base_dir, r’dose\fr1’),
os.path.join(base_dir, r’doses\fr2’),
os.path.join(base_dir, r’doses\fr3’),
os.path.join(base_dir, r’doses\fr4’)
]
rt_struct_dirs = [
os.path.join(base_dir, r’rts\extr’),
os.path.join(base_dir, r’rts\fr1’),
os.path.join(base_dir, r’rts\fr2’),
os.path.join(base_dir, r’rts\fr3’),
os.path.join(base_dir, r’rts\fr4’)
]
output_dir = r’C:\Users\resampled_final’
os.makedirs(output_dir, exist_ok=True)
# Dataset names
ct_names = [‘extr’, ‘fr1’, ‘fr2’, ‘fr3’, ‘fr4’]
print(“=== COMBINED REGISTRATION WORKFLOW ===”)
print(“— Loading All Data —”)
# Loads CT images
ct_images = [ ]
ct_ref_files = [ ]
for i, ct_dir in enumerate(ct_dicom_dirs):
print(f"Loading CT from: {os.path.basename(ct_dir)}“)
ct_image, ref_file = read_dicom_series(ct_dir)
ct_images.append(ct_image)
ct_ref_files.append(ref_file)
print(f” CT size: {ct_image.GetSize()}“)
# Loads dose images
dose_images = [ ]
dose_ref_files = [ ]
for i, dose_dir in enumerate(dose_dicom_dirs):
print(f"Loading Dose from: {os.path.basename(dose_dir)}”)
dose_image, ref_file = read_dicom_series(dose_dir, is_dose=True)
dose_images.append(dose_image)
dose_ref_files.append(ref_file)
print(f" Dose size: {dose_image.GetSize()}“)
# Selects reference image
reference_ct, ref_index = select_reference_image(ct_images, ct_names)
print(”\n— Performing Registration & Resampling —“)
# Process each dataset
for i in range(len(ct_images)):
print(f”\n{‘-’*50}“)
print(f"Processing dataset {i+1}/{len(ct_images)}: {ct_names[i]}”)
# Gets transform
if i == ref_index:
transform = sitk.Euler3DTransform() # Identity transform for reference
print(" Using identity transform (reference image)“)
else:
print(f” Registering to reference image…“)
transform = robust_rigid_registration(reference_ct, ct_images[i])
# Setups resampler
resampler = sitk.ResampleImageFilter()
resampler.SetReferenceImage(reference_ct)
resampler.SetInterpolator(sitk.sitkLinear)
resampler.SetTransform(transform)
# Resamples CT
print(” Resampling CT image…“)
resampler.SetDefaultPixelValue(-1024.0)
resampled_ct = resampler.Execute(sitk.Cast(ct_images[i], sitk.sitkFloat32))
# Resamples dose
print(” Resampling Dose image…“)
resampler.SetDefaultPixelValue(0.0)
resampled_dose = resampler.Execute(sitk.Cast(dose_images[i], sitk.sitkFloat64))
# Creates body mask
print(” Creating body mask…“)
body_mask = create_body_mask(resampled_ct)
# Creates output directory for this dataset
dataset_output_dir = os.path.join(output_dir, ct_names[i])
os.makedirs(dataset_output_dir, exist_ok=True)
# Saves as NIfTI
print(” Saving NIfTI files…“)
sitk.WriteImage(resampled_ct, os.path.join(dataset_output_dir, f’{ct_names[i]}_CT.nii.gz’))
sitk.WriteImage(resampled_dose, os.path.join(dataset_output_dir, f’{ct_names[i]}_dose.nii.gz’))
sitk.WriteImage(body_mask, os.path.join(dataset_output_dir, f’{ct_names[i]}_mask.nii.gz’))
print(f” Saved: {ct_names[i]}_CT.nii.gz")
print(f" Saved: {ct_names[i]}_dose.nii.gz")
print(f" Saved: {ct_names[i]}_mask.nii.gz")
# Process RT Structure
rt_struct_dir = rt_struct_dirs[i]
if os.path.exists(rt_struct_dir):
print(f" Processing RT Structure from: {os.path.basename(rt_struct_dir)}“)
rt_files = [f for f in os.listdir(rt_struct_dir) if f.lower().endswith(‘.dcm’)]
if rt_files:
rt_file_path = os.path.join(rt_struct_dir, rt_files[0])
success = transform_rt_structure(rt_file_path, transform, reference_ct,
dataset_output_dir, ct_names[i])
if success:
print(f” Saved: {ct_names[i]}_RT_Structure.dcm")
else:
print(f" Warning: No DICOM files found in {rt_struct_dir}“)
else:
print(f” Warning: RT Structure directory not found: {rt_struct_dir}“)
print(f”\n{‘-’*50}“)
print(”=== WORKFLOW COMPLETED SUCCESSFULLY ===“)
print(f"Output directory: {output_dir}”)
print(“All files saved in NIfTI format (.nii.gz)”)
if name == “main”:
main()

1 Like

Hello @blue_sky,

Based on the description it appears that the problem is with saving the non-image, RTStruct, DICOM data. SimpleITK supports operations and IO for images and you should be able to correctly save the results in DICOM format (see this example).

Saving the RTStruct is not supported by SimpleITK, so possibly use pydicom (see this discussion), dedicated tools such as RT-Utils or Slicer’s Slicer RT extension which enables this form of DICOM export.

1 Like