Skip to content

Commit 5abb079

Browse files
Odd/Even matrix size fix, DC fix, new padding features, test suite (#41)
* Checkpoint * Refactor: use numpy padding, add edge as default, 50 pixels per side by default * Update readme * Remove local files * Customize for ISMRM * Update gitignore * Incomplete checkpoint - fixing fft * Fix * Decide on nan replacement implementtion * Fix bug * Add savefig * Create some initial integration tests - contains some failures to be resolved later (#38) (#42) (#43) * Refactor analytical comparison function to have more explicit args (#36) * Refactor function args for geometry examples * Reorgnize test folder * Write tests for compare_to_analytical and create internal python function (non-cli) * Add gitignore cases * Add integration tests * Change on push * Add name * Change name * Try coveralls setup * Try coveralls setup * Try coveralls setup * Add expected fails * Add xfails * Add coverage report * Add coverage report * Add coverage report * Add badges * Fix fftshift bug by using ifftshift * Fix floating-point precision issues in compute_fieldmap tests * Remove xfail test tag for test resolved by ifftshift fix * Add boundary exclusion mask to explude Gibbs ringing when testing against analytical solution * Fix buffer=0 edge case in compute_bz function * Test 50 px buffer * Fix meshgrid indexing for non-square matrices in Spherical class * Add indexing='ij' to Cylindrical class meshgrid calls * Add comprehensive Cylindrical geometry test coverage * Fix incorrect DC component adjustment and add validation tests * Cleanup * Add tests for neighbouring voxel fields (all three directions) of a single voxel of water * Update coverage badge in README.md * Update coverage badge to reflect custom branch * Update README.md to reflect custom branch status * Fix badge link in README for test workflow
1 parent 9a60de9 commit 5abb079

11 files changed

Lines changed: 801 additions & 91 deletions

File tree

.github/workflows/run_tests.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,15 @@ name: Run tests
55

66
on:
77
push:
8-
branches: [ "main" ]
8+
branches: [ "main", '*/*' ]
99
pull_request:
10-
branches: [ "main" ]
10+
branches: [ "main", '*/*' ]
1111

1212
permissions:
1313
contents: read
1414

1515
jobs:
1616
build:
17-
1817
runs-on: ubuntu-latest
1918

2019
steps:
@@ -30,4 +29,7 @@ jobs:
3029
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
3130
- name: Test with pytest
3231
run: |
33-
pytest -v
32+
pytest -v --cov functions/ --cov-report=lcov
33+
# Step 4: Submit to coveralls
34+
- name: Submit to coveralls
35+
uses: coverallsapp/github-action@v2.3.6

.gitignore

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,13 @@
55
.pytest_cache/
66
.ipynb_checkpoints/
77
.vscode/
8-
build/
8+
build/
9+
data/
10+
trash/
11+
.DS_Store
12+
*.nii.gz
13+
*.nii
14+
run.py
15+
tests.py
16+
spherical_analytical_vs_simulated.png
17+
cylindrical_analytical_vs_simulated.png

.pytest.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[pytest]
2+
markers =
3+
unit: small functions

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
# susceptibility-to-fieldmap-fft
2+
[![Run tests](https://github.com/shimming-toolbox/susceptibility-to-fieldmap-fft/actions/workflows/run_tests.yml/badge.svg?branch=mb%2Fcustom_pad)](https://github.com/shimming-toolbox/susceptibility-to-fieldmap-fft/actions/workflows/run_tests.yml)
3+
[![Coverage Status](https://coveralls.io/repos/github/shimming-toolbox/susceptibility-to-fieldmap-fft/badge.svg?branch=mb/custom_pad)](https://coveralls.io/github/shimming-toolbox/susceptibility-to-fieldmap-fft?branch=mb/custom_pad)
4+
25

36
# Table of contents
47
1. [Theory](#theory)
@@ -113,6 +116,8 @@ The `compute_fieldmap` command allows computation of a $B_0$ fieldmap based on a
113116
**Inputs**
114117
- input_file : path to the susceptibility distribution (NIfTI file)
115118
- output_file : path for the fieldmap (NIfTI file)
119+
- -b, buffer (optional, default=50 voxels): Value-padding the edges of the volume
120+
- -m, method (optional, default=edge): Method used for value-padding, see np.pad options
116121

117122
**Output**
118123
The calculated fieldmap at the specified path.

functions/analytical_cases.py

Lines changed: 79 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from matplotlib import pyplot as plt
44
from scipy.ndimage import rotate
55
import click
6+
import copy
67

78
class Visualization:
89
"""
@@ -90,24 +91,26 @@ def plot_comparaison_analytical(self, Bz_analytical, simulated_Bz, geometry_type
9091
axes[0].plot(np.linspace(-dimensions[0]//2, dimensions[0]//2, dimensions[0]), simulated_Bz[:, dimensions[0]//2, dimensions[0]//2],'--', label='Simulated')
9192
axes[0].set_xlabel('x position [mm]')
9293
axes[0].set_ylabel('Field variation [ppm]')
94+
9395
axes[0].set_ylim(vmin, vmax)
9496
axes[0].legend()
9597

96-
axes[1].plot(np.linspace(-dimensions[0]//2, dimensions[0]//2, dimensions[0]), Bz_analytical[dimensions[0]//2, :, dimensions[0]//2], label='Theory')
97-
axes[1].plot(np.linspace(-dimensions[0]//2, dimensions[0]//2, dimensions[0]), simulated_Bz[dimensions[0]//2, :, dimensions[0]//2],'--', label='Simulated')
98+
axes[1].plot(np.linspace(-dimensions[1]//2, dimensions[1]//2, dimensions[1]), Bz_analytical[dimensions[0]//2, :, dimensions[2]//2], label='Theory')
99+
axes[1].plot(np.linspace(-dimensions[1]//2, dimensions[1]//2, dimensions[1]), simulated_Bz[dimensions[0]//2, :, dimensions[2]//2],'--', label='Simulated')
98100
axes[1].set_xlabel('y position [mm]')
99101
axes[1].set_ylabel('Field variation [ppm]')
100102
axes[1].set_ylim(vmin, vmax)
101103
axes[1].legend()
102104

103-
axes[2].plot(np.linspace(-dimensions[0]//2, dimensions[0]//2, dimensions[0]), Bz_analytical[dimensions[0]//2, dimensions[0]//2, :], label='Theory')
104-
axes[2].plot(np.linspace(-dimensions[0]//2, dimensions[0]//2, dimensions[0]), simulated_Bz[dimensions[0]//2, dimensions[0]//2, :],'--', label='Simulated')
105+
axes[2].plot(np.linspace(-dimensions[2]//2, dimensions[2]//2, dimensions[2]), Bz_analytical[dimensions[0]//2, dimensions[1]//2, :], label='Theory')
106+
axes[2].plot(np.linspace(-dimensions[2]//2, dimensions[2]//2, dimensions[2]), simulated_Bz[dimensions[0]//2, dimensions[1]//2, :],'--', label='Simulated')
105107
axes[2].set_xlabel('z position [mm]')
106108
axes[2].set_ylabel('Field variation [ppm]')
107109
axes[2].set_ylim(vmin, vmax)
108110
axes[2].legend()
109111

110112
plt.tight_layout()
113+
plt.savefig(f'{geometry_type}_analytical_vs_simulated.png', dpi=300)
111114
plt.show()
112115

113116
class Spherical(Visualization):
@@ -142,7 +145,7 @@ def mask(self):
142145
"""
143146
[x, y, z] = np.meshgrid(np.linspace(-(self.matrix[0]-1)/2, (self.matrix[0]-1)/2, self.matrix[0]),
144147
np.linspace(-(self.matrix[1]-1)/2, (self.matrix[1]-1)/2, self.matrix[1]),
145-
np.linspace(-(self.matrix[2]-1)/2, (self.matrix[2]-1)/2, self.matrix[2]))
148+
np.linspace(-(self.matrix[2]-1)/2, (self.matrix[2]-1)/2, self.matrix[2]), indexing='ij')
146149

147150
r = np.sqrt(x**2 + y**2 + z**2)
148151

@@ -168,7 +171,7 @@ def analytical_sol(self):
168171

169172
[x, y, z] = np.meshgrid(np.linspace(-(self.matrix[0]-1)/2, (self.matrix[0]-1)/2, self.matrix[0]),
170173
np.linspace(-(self.matrix[1]-1)/2, (self.matrix[1]-1)/2, self.matrix[1]),
171-
np.linspace(-(self.matrix[2]-1)/2, (self.matrix[2]-1)/2, self.matrix[2]))
174+
np.linspace(-(self.matrix[2]-1)/2, (self.matrix[2]-1)/2, self.matrix[2]), indexing='ij')
172175

173176

174177
r = np.sqrt(x**2 + y**2 + z**2)
@@ -213,9 +216,9 @@ def mask(self):
213216
"""
214217
[x, y, z] = np.meshgrid(np.linspace(-(self.matrix[0]-1)/2, (self.matrix[0]-1)/2, self.matrix[0]),
215218
np.linspace(-(self.matrix[1]-1)/2, (self.matrix[1]-1)/2, self.matrix[1]),
216-
np.linspace(-(self.matrix[2]-1)/2, (self.matrix[2]-1)/2, self.matrix[2]))
219+
np.linspace(-(self.matrix[2]-1)/2, (self.matrix[2]-1)/2, self.matrix[2]), indexing='ij')
217220

218-
r = x**2 + y**2
221+
r = x**2 + y**2
219222

220223
mask = r <= self.R**2
221224

@@ -247,7 +250,7 @@ def analytical_sol(self):
247250

248251
[x, y, z] = np.meshgrid(np.linspace(-(self.matrix[0]-1)/2, (self.matrix[0]-1)/2, self.matrix[0]),
249252
np.linspace(-(self.matrix[1]-1)/2, (self.matrix[1]-1)/2, self.matrix[1]),
250-
np.linspace(-(self.matrix[2]-1)/2, (self.matrix[2]-1)/2, self.matrix[2]))
253+
np.linspace(-(self.matrix[2]-1)/2, (self.matrix[2]-1)/2, self.matrix[2]), indexing='ij')
251254

252255
r = np.sqrt(x**2 + y**2 + z**2)
253256

@@ -273,19 +276,52 @@ def analytical_sol(self):
273276

274277
return Bz_analytical
275278

279+
def analytical_sphere_external_field(dx, dy, dz, chi_internal, chi_external, radius):
280+
"""
281+
Calculate analytical external field for a sphere at relative position (dx, dy, dz).
282+
283+
Formula: Bz/B0 = 1/3 * (chi_i - chi_e) * a^3/r^3 * (3*cos^2(theta) - 1) + 1/3 * chi_e
284+
285+
Args:
286+
dx, dy, dz: Position relative to sphere center (in voxels)
287+
chi_internal: Susceptibility inside sphere (ppm)
288+
chi_external: Susceptibility outside sphere (ppm)
289+
radius: Sphere radius (in voxels)
290+
291+
Returns:
292+
Field value at position (dx, dy, dz) in ppm
293+
"""
294+
r = np.sqrt(dx**2 + dy**2 + dz**2)
295+
296+
if r == 0:
297+
# At center
298+
return chi_external / 3.0
299+
300+
# cos(theta) where theta is angle from z-axis
301+
cos_theta = dz / r
302+
303+
# External field formula
304+
field = (1.0/3.0) * (chi_internal - chi_external) * (radius**3 / r**3) * (3 * cos_theta**2 - 1) + chi_external / 3.0
305+
306+
return field
307+
276308
@click.command(help="Compare the analytical solution to the simulated solution for a spherical or cylindrical geometry.")
277309
@click.option('-t', '--geometry-type',required=True,
278310
type=click.Choice(['spherical', 'cylindrical']),
279311
help='Type of geometry for the simulation')
280-
@click.option('-b', '--buffer', default=2,
312+
@click.option('-b', '--buffer', default=50,
281313
help='Buffer value for zero-padding.')
282-
def compare_to_analytical(geometry_type, buffer):
314+
def compare_to_analytical(geometry_type, buffer, matrix=[128,128,128], image_res=[1,1,1], radius=15, chi=9):
283315
"""
284316
Main function to compare simulated fields to analytical solutions.
285317
286318
Parameters:
287319
- geometry_type (str): The type of geometry to simulate ('spherical' or 'cylindrical').
288320
- buffer (float): The buffer size for the simulation.
321+
- matrix ([int, int, int]): Volume dimensions in terms of number of pixels.
322+
- image_res ([float, float, float]): Image resolution in mm ([x,y,z]).
323+
- radius (float): Radius (mm) of the geometrical object.
324+
- chi (float): Susceptibility difference in ppm.
289325
290326
Returns:
291327
- None
@@ -301,10 +337,35 @@ def compare_to_analytical(geometry_type, buffer):
301337
and a susceptibility difference of 9 ppm for the spherical and cylindrical geometries.
302338
"""
303339

304-
matrix = np.array([128,128,128])
305-
image_res = np.array([1,1,1]) # mm
306-
R = 15 # mm
307-
sus_diff = 9 # ppm
340+
compare_to_analytical_internal(geometry_type, buffer, matrix, image_res, radius, chi)
341+
342+
343+
def compare_to_analytical_internal(geometry_type, buffer, matrix=[128,128,128], image_res=[1,1,1], radius=15, chi=9):
344+
345+
# Type check the matrix argument
346+
if not isinstance(matrix, (list, tuple, np.ndarray)):
347+
raise TypeError("matrix must be a list, tuple, or numpy array.")
348+
if len(matrix) != 3:
349+
raise ValueError("matrix must have 3 elements (x, y, z dimensions).")
350+
if not all(isinstance(dim, int) for dim in matrix):
351+
raise TypeError("All elements of matrix must be integers.")
352+
if not all(dim > 0 for dim in matrix):
353+
raise ValueError("All matrix dimensions must be positive.")
354+
matrix = np.array(matrix) # Convert to numpy array for easier use later
355+
356+
# Type check the image_res argument
357+
if not isinstance(image_res, (list, tuple, np.ndarray)):
358+
raise TypeError("image_res must be a list, tuple, or numpy array.")
359+
if len(image_res) != 3:
360+
raise ValueError("image_res must have 3 elements (x, y, z resolutions).")
361+
if not all(isinstance(res, (int, float)) for res in image_res):
362+
raise TypeError("All elements of image_res must be numbers (int or float).")
363+
if not all(res > 0 for res in image_res):
364+
raise ValueError("All image_res values must be positive.")
365+
image_res = np.array(image_res) # Convert to numpy array
366+
367+
R = radius # mm
368+
sus_diff = chi # ppm
308369

309370
dicto = {'spherical': Spherical(matrix, image_res, R, sus_diff),
310371
'cylindrical': Cylindrical(matrix, image_res, R, sus_diff)}
@@ -315,9 +376,12 @@ def compare_to_analytical(geometry_type, buffer):
315376

316377
# compute Bz variation
317378
calculated_Bz = compute_bz(sus_dist, image_res, buffer)
379+
318380
# analytical solution
319381
Bz_analytical = geometry.analytical_sol()
320382

321383
# plot the results
322384
geometry.plot_susceptibility_and_fieldmap(sus_dist, calculated_Bz, geometry_type)
323385
geometry.plot_comparaison_analytical(Bz_analytical, calculated_Bz, geometry_type)
386+
387+
return calculated_Bz, Bz_analytical

functions/compute_fieldmap.py

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import nibabel as nib
33
import click
44
from time import perf_counter
5+
from pathlib import Path
56

67
def is_nifti(filepath):
78
"""
@@ -39,7 +40,7 @@ def load_sus_dist(filepath):
3940
return susceptibility_distribution, image_resolution, affine_matrix
4041

4142

42-
def compute_bz(susceptibility_distribution, image_resolution=np.array([1,1,1]), buffer=1):
43+
def compute_bz(susceptibility_distribution, image_resolution=np.array([1,1,1]), buffer=50, mode='edge'):
4344
"""
4445
Compute the Bz field variation in ppm based on a susceptibility distribution
4546
using a Fourier-based method.
@@ -49,39 +50,67 @@ def compute_bz(susceptibility_distribution, image_resolution=np.array([1,1,1]),
4950
5051
image_resolution (numpy.ndarray, optional): The resolution of the image in each dimension. Defaults to [1, 1, 1].
5152
52-
buffer (int, optional): The buffer size for the k-space grid. Defaults to 1.
53+
buffer (int, optional): The buffer size (voxels) for the k-space grid on each size. Defaults to 50.
54+
55+
mode (str, optional): The padding mode for the susceptibility distribution. Defaults to 'edge'.
5356
5457
Returns:
5558
volume_without_buffer (numpy.ndarray): The computed magnetic field Bz in ppm.
5659
5760
"""
5861

62+
# Pad the susceptibility distribution
63+
64+
if mode == 'b0SimISMRM':
65+
susceptibility_distribution=np.pad(susceptibility_distribution, ((buffer, buffer), (0,buffer), (buffer,buffer)), mode='edge')
66+
susceptibility_distribution=np.pad(susceptibility_distribution, ((0,0),(buffer,0),(0,0)), mode='constant', constant_values=0.35)
67+
else:
68+
susceptibility_distribution=np.pad(susceptibility_distribution, buffer, mode=mode)
69+
5970
# dimensions needs to be a numpy.array
6071
dimensions = np.array(susceptibility_distribution.shape)
6172

62-
# creating the k-space grid with the buffer
63-
new_dimensions = buffer*np.array(dimensions)
6473
kmax = 1/(2*image_resolution)
6574

66-
[kx, ky, kz] = np.meshgrid(np.linspace(-kmax[0], kmax[0], new_dimensions[0]),
67-
np.linspace(-kmax[1], kmax[1], new_dimensions[1]),
68-
np.linspace(-kmax[2], kmax[2], new_dimensions[2]), indexing='ij')
75+
interval = 2 * kmax / dimensions
76+
77+
78+
kx_min_shift = (dimensions[0]%2)*interval[0]/2
79+
ky_min_shift = (dimensions[1]%2)*interval[1]/2
80+
kz_min_shift = (dimensions[2]%2)*interval[2]/2
81+
82+
kx_max_shift = -interval[0] + (dimensions[0]%2)*interval[0]/2
83+
ky_max_shift = -interval[1] + (dimensions[1]%2)*interval[1]/2
84+
kz_max_shift = -interval[2] + (dimensions[2]%2)*interval[2]/2
85+
86+
87+
[kx, ky, kz] = np.meshgrid(np.linspace(-kmax[0] + kx_min_shift, kmax[0] + kx_max_shift, dimensions[0]),
88+
np.linspace(-kmax[1] + ky_min_shift, kmax[1] + ky_max_shift, dimensions[1]),
89+
np.linspace(-kmax[2] + kz_min_shift, kmax[2] + kz_max_shift, dimensions[2]), indexing='ij')
6990

7091
# FFT procedure
7192
# undetermined at the center of k-space
7293
k2 = kx**2 + ky**2 + kz**2
7394

7495
with np.errstate(divide='ignore', invalid='ignore'):
75-
kernel = np.fft.fftshift(1/3 - kz**2/k2)
76-
kernel[0,0,0] = 1/3
96+
x_kernel = 1/3 - kz**2/k2
97+
98+
x_kernel[int(dimensions[0]/2-1/2*(dimensions[0]%2)), int(dimensions[1]/2-1/2*(dimensions[1]%2)), int(dimensions[2]/2-1/2*(dimensions[2]%2))] = 1/3
99+
100+
kernel = np.fft.ifftshift(x_kernel)
101+
102+
FFT_chi = np.fft.fftn(susceptibility_distribution, dimensions)
77103

78-
FFT_chi = np.fft.fftn(susceptibility_distribution, new_dimensions)
79-
FFT_chi[0,0,0] = FFT_chi[0,0,0] + np.prod(new_dimensions)*susceptibility_distribution[0,0,0]
80104
Bz_fft = kernel*FFT_chi
81105

82106
# retrive the inital FOV
107+
83108
volume_with_buffer = np.real(np.fft.ifftn(Bz_fft))
84-
volume_without_buffer = volume_with_buffer[0:dimensions[0], 0:dimensions[1], 0:dimensions[2]]
109+
110+
if buffer == 0:
111+
volume_without_buffer = volume_with_buffer
112+
else:
113+
volume_without_buffer = volume_with_buffer[buffer:-buffer, buffer:-buffer, buffer:-buffer]
85114

86115
return volume_without_buffer
87116

@@ -97,6 +126,7 @@ def save_to_nifti(data, affine_matrix, output_path):
97126
Returns:
98127
None
99128
"""
129+
data=data.astype(np.float32)
100130
nifti_image = nib.Nifti1Image(data, affine_matrix)
101131
nib.save(nifti_image, output_path)
102132

@@ -107,7 +137,11 @@ def save_to_nifti(data, affine_matrix, output_path):
107137
help="Input susceptibility distribution, supported extensions: .nii, .nii.gz")
108138
@click.option('-o', '--output', 'output_file', type=click.Path(), default='fieldmap.nii.gz',
109139
help="Output fieldmap, supported extensions: .nii, .nii.gz")
110-
def compute_fieldmap(input_file, output_file):
140+
@click.option('-b', '--buffer', 'buffer', type=int, default=50, required=False,
141+
help="Buffer size (voxels) for the k-space grid on each side")
142+
@click.option('-m', '--mode', 'mode', type=str, default='edge', required=False,
143+
help="Padding mode for the susceptibility distribution")
144+
def compute_fieldmap(input_file, output_file, buffer, mode):
111145
"""
112146
Main procedure for performing the simulation.
113147
@@ -123,13 +157,18 @@ def compute_fieldmap(input_file, output_file):
123157
print('Start')
124158
susceptibility_distribution, image_resolution, affine_matrix = load_sus_dist(input_file)
125159
print('Susceptibility distribution loaded')
126-
fieldmap = compute_bz(susceptibility_distribution, image_resolution)
160+
fieldmap = compute_bz(susceptibility_distribution, image_resolution, buffer, mode)
127161
print('Fieldmap simulated')
162+
163+
# Check if all subdirectories exist and create them if not
164+
output_path = Path(output_file)
165+
output_path.parent.mkdir(parents=True, exist_ok=True)
166+
128167
save_to_nifti(fieldmap, affine_matrix, output_file)
129168
print('Saving to NIfTI format')
130169
end_time = perf_counter()
131170
print(f'End. Runtime: {end_time-start_time:.2f} seconds')
132171
else:
133172
print("The input file must be NIfTI.")
134173

135-
174+

requirements.txt

-256 Bytes
Binary file not shown.

tests/functions/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

0 commit comments

Comments
 (0)