Skip to content

Commit 3e45f6c

Browse files
committed
Add LPS -> RAS conversion for affine in dicom -> nifti pipeline (#396)
1 parent 32964a7 commit 3e45f6c

2 files changed

Lines changed: 71 additions & 31 deletions

File tree

src/openlifu/util/volume_conversion.py

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,13 @@ def is_dicom_file_or_directory(path: PathLike) -> bool:
4949
def extract_affine_from_dicom(dicom_slices: list) -> np.ndarray:
5050
"""
5151
Extract the affine transformation matrix from DICOM header information.
52+
Converts from DICOM LPS (Left-Posterior-Superior) to NIfTI RAS.
5253
5354
Args:
5455
dicom_slices: List of tuples (instance_number, pixel_array, dicom_dataset)
5556
5657
Returns:
57-
4x4 affine transformation matrix mapping voxel coordinates to world coordinates
58+
4x4 affine transformation matrix mapping voxel coordinates to RAS world coordinates
5859
5960
Raises:
6061
RuntimeError: If required DICOM tags are missing
@@ -71,47 +72,42 @@ def extract_affine_from_dicom(dicom_slices: list) -> np.ndarray:
7172
# ImagePositionPatient (0020,0032): position of the upper-left voxel
7273
position = np.array(first_ds.ImagePositionPatient, dtype=float)
7374

74-
# PixelSpacing (0028,0030): spacing between pixels [row_spacing, col_spacing]
75-
pixel_spacing = np.array(first_ds.PixelSpacing, dtype=float)
76-
row_spacing = pixel_spacing[0]
77-
col_spacing = pixel_spacing[1]
75+
# PixelSpacing is [row_spacing, col_spacing], so map to dy, dx
76+
dy, dx = np.array(first_ds.PixelSpacing, dtype=float)
7877

7978
except AttributeError as e:
8079
raise RuntimeError(
8180
f"Missing required DICOM tag for affine calculation: {e}"
8281
) from e
8382

84-
# calculate slice direction as cross product of row and column directions
83+
# Compute Z direction and spacing (handling potential gantry tilt)
8584
slice_cosine = np.cross(row_cosine, col_cosine)
8685

8786
# calculate slice spacing
8887
if len(dicom_slices) > 1:
8988
# calculate from the distance between first two slices
9089
first_pos = np.array(dicom_slices[0][2].ImagePositionPatient, dtype=float)
9190
second_pos = np.array(dicom_slices[1][2].ImagePositionPatient, dtype=float)
92-
slice_spacing = np.linalg.norm(second_pos - first_pos)
91+
dz = np.dot(second_pos - first_pos, slice_cosine)
9392
else:
9493
# single slice - try to get from SliceThickness or default to 1.0
95-
slice_spacing = float(getattr(first_ds, 'SliceThickness', 1.0))
94+
dz = float(getattr(first_ds, 'SliceThickness', 1.0))
9695

97-
# construct the affine matrix
98-
# The affine maps from voxel indices (i, j, k) to physical coordinates (x, y, z)
96+
# Construct affine in DICOM LPS space
9997
affine = np.eye(4)
100-
affine[:3, 0] = row_cosine * row_spacing
101-
affine[:3, 1] = col_cosine * col_spacing
102-
affine[:3, 2] = slice_cosine * slice_spacing
98+
affine[:3, 0] = row_cosine * dx
99+
affine[:3, 1] = col_cosine * dy
100+
affine[:3, 2] = slice_cosine * dz
103101
affine[:3, 3] = position
104102

105-
return affine
103+
# Convert LPS to RAS by flipping X and Y axes
104+
return np.diag([-1, -1, 1, 1]) @ affine
106105

107106

108107
def convert_dicom_to_nifti(input_path: PathLike, output_filepath: PathLike) -> None:
109108
"""
110109
Convert DICOM file(s) to NIfTI format using pydicom and nibabel.
111110
112-
The affine transformation matrix is extracted from DICOM headers to properly
113-
map voxel coordinates to scanner coordinates in the NIfTI output.
114-
115111
Args:
116112
input_path: Path to either a DICOM file or directory containing DICOM files
117113
output_filepath: Path where the output NIfTI file should be saved
@@ -136,8 +132,8 @@ def convert_dicom_to_nifti(input_path: PathLike, output_filepath: PathLike) -> N
136132
for dcm_file in dicom_files:
137133
try:
138134
ds = pydicom.dcmread(dcm_file)
139-
# store instance number, pixel array, and dataset for affine extraction
140-
slices.append((ds.get('InstanceNumber', 0), ds.pixel_array, ds))
135+
# Transpose to swap (Row, Col) -> (X, Y) for NIfTI
136+
slices.append((ds.get('InstanceNumber', 0), ds.pixel_array.T, ds))
141137
except Exception:
142138
# skip files that aren't valid dicom
143139
continue
@@ -155,8 +151,7 @@ def convert_dicom_to_nifti(input_path: PathLike, output_filepath: PathLike) -> N
155151
# extract affine from DICOM headers
156152
affine = extract_affine_from_dicom(slices)
157153

158-
nifti_img = nib.Nifti1Image(volume, affine)
159-
nib.save(nifti_img, str(output_filepath))
154+
nib.save(nib.Nifti1Image(volume, affine), str(output_filepath))
160155

161156
except Exception as e:
162157
raise RuntimeError(f"DICOM to NIfTI conversion failed: {e}") from e

tests/test_volume_conversion.py

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,10 @@ def test_convert_dicom_to_nifti_single_file(tmp_path: Path):
4545

4646
assert output_nifti.exists()
4747
img = nib.load(output_nifti)
48-
assert img.shape[2] == 1 # single slice
49-
assert not np.allclose(img.affine, np.eye(4)) # affine should not be identity
48+
# Ensure 3D shape even for single slice
49+
assert img.shape[2] == 1
50+
# Affine should definitely not be identity for a valid medical image
51+
assert not np.allclose(img.affine, np.eye(4))
5052

5153

5254
def test_convert_dicom_to_nifti_directory(tmp_path: Path):
@@ -57,24 +59,67 @@ def test_convert_dicom_to_nifti_directory(tmp_path: Path):
5759

5860
assert output_nifti.exists()
5961
img = nib.load(output_nifti)
60-
assert img.shape[2] == 2 # two slices in test series
61-
assert not np.allclose(img.affine, np.eye(4)) # affine should not be identity
62+
assert img.shape[2] == 2
63+
assert not np.allclose(img.affine, np.eye(4))
6264

6365

64-
def test_extract_affine_from_dicom():
65-
# mock DICOM dataset with minimal required tags
66+
def test_extract_affine_standard_orientation():
67+
"""
68+
Test affine extraction for a standard axial slice.
69+
Verifies proper mapping of Row/Col spacing and LPS->RAS conversion.
70+
"""
6671
mock_ds = Mock()
72+
# Standard Axial: Row is X, Col is Y
6773
mock_ds.ImageOrientationPatient = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]
74+
# Position in LPS (Left, Posterior, Superior)
6875
mock_ds.ImagePositionPatient = [10.0, 20.0, 30.0]
69-
mock_ds.PixelSpacing = [0.5, 0.5]
76+
# PixelSpacing: [RowSpacing (dy), ColSpacing (dx)]
77+
mock_ds.PixelSpacing = [0.5, 0.8]
7078
mock_ds.SliceThickness = 2.0
7179

7280
slices = [(1, None, mock_ds)]
7381
affine = extract_affine_from_dicom(slices)
7482

75-
assert affine.shape == (4, 4)
76-
assert np.allclose(affine[3, :], [0, 0, 0, 1]) # homogeneous coordinate
77-
assert np.allclose(affine[:3, 3], [10.0, 20.0, 30.0]) # position
83+
# Expected RAS Matrix:
84+
# 1. Spacing: X=0.8, Y=0.5, Z=2.0
85+
# 2. Orientation: Standard Axial means X aligns with Right, Y with Anterior.
86+
# However, DICOM is LPS.
87+
# LPS X+ (Left) converts to RAS X- (Left).
88+
# LPS Y+ (Posterior) converts to RAS Y- (Posterior).
89+
# 3. Origin: [10, 20, 30] LPS -> [-10, -20, 30] RAS
90+
91+
expected_affine = np.array([
92+
[-0.8, 0.0, 0.0, -10.0],
93+
[ 0.0, -0.5, 0.0, -20.0],
94+
[ 0.0, 0.0, 2.0, 30.0],
95+
[ 0.0, 0.0, 0.0, 1.0]
96+
])
97+
98+
np.testing.assert_allclose(affine, expected_affine)
99+
100+
101+
def test_extract_affine_multi_slice_spacing():
102+
"""Test that Z-spacing is calculated from slice positions, not SliceThickness."""
103+
ds1 = Mock()
104+
ds1.ImageOrientationPatient = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]
105+
ds1.PixelSpacing = [1.0, 1.0]
106+
ds1.ImagePositionPatient = [0.0, 0.0, 10.0] # z = 10
107+
108+
ds2 = Mock()
109+
ds2.ImagePositionPatient = [0.0, 0.0, 12.5] # z = 12.5
110+
111+
# Create slice list with dummy pixel arrays
112+
slices = [
113+
(1, None, ds1),
114+
(2, None, ds2)
115+
]
116+
117+
affine = extract_affine_from_dicom(slices)
118+
119+
# Z spacing should be 12.5 - 10.0 = 2.5
120+
assert np.isclose(affine[2, 2], 2.5)
121+
# Origin should be from the first slice, converted to RAS (Z is unchanged)
122+
assert np.isclose(affine[2, 3], 10.0)
78123

79124

80125
def test_extract_affine_missing_tags():

0 commit comments

Comments
 (0)