Skip to content

Commit c2c9a85

Browse files
committed
Add spatial regularization functions and tests for Tikhonov and structured sparsity
- Implement spatial Tikhonov regularization with proper handling of input shapes and FFT operations. - Introduce spatial structured sparsity function with appropriate masking and clipping. - Enhance test coverage for delta generation, clipping, and spatial regularization functions. - Ensure integration tests validate the functionality with real NiftiMasker.
1 parent f0d6421 commit c2c9a85

2 files changed

Lines changed: 427 additions & 34 deletions

File tree

pySPFM/deconvolution/spatial_regularization.py

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Spatial regularization functions as developed in Total Activation."""
22

3+
import nibabel as nib
34
import numpy as np
45
from nilearn.masking import apply_mask, unmask
56

@@ -39,8 +40,11 @@ def spatial_tikhonov(estimates, data, masker, niter, dim, lambda_, mu):
3940
Estimates of activity-inducing or innovation signal after spatial regularization.
4041
"""
4142
# Transform data from 2D into 4D
42-
estimates_vol = masker.inverse_transform(estimates)
43-
data_vol = masker.inverse_trasnform(data)
43+
# NiftiMasker expects (n_samples, n_features), but input is (n_features, n_samples)
44+
estimates_img = masker.inverse_transform(estimates.T)
45+
data_img = masker.inverse_transform(data.T)
46+
estimates_vol = estimates_img.get_fdata()
47+
data_vol = data_img.get_fdata()
4448

4549
if dim == 2:
4650
h = generate_delta(dim=dim)
@@ -61,13 +65,13 @@ def spatial_tikhonov(estimates, data, masker, niter, dim, lambda_, mu):
6165
estimates_vol[:, :, slice_idx, time_idx],
6266
(estimates_vol.shape[0], estimates_vol.shape[1]),
6367
)
64-
)
68+
).real
6569
)
6670

6771
elif dim == 3:
6872
h = generate_delta(dim=dim)
6973

70-
h = np.fft.fftn(h, estimates_vol.shape[:2])
74+
h = np.fft.fftn(h, estimates_vol.shape[:3])
7175

7276
for iter_idx in range(niter):
7377
for time_idx in range(estimates_vol.shape[-1]):
@@ -79,11 +83,17 @@ def spatial_tikhonov(estimates, data, masker, niter, dim, lambda_, mu):
7983
* np.fft.ifftn(
8084
h
8185
* np.conj(h)
82-
* np.fft.fftn(estimates_vol[:, :, :, time_idx], estimates_vol.shape[:2])
83-
)
86+
* np.fft.fftn(estimates_vol[:, :, :, time_idx], estimates_vol.shape[:3])
87+
).real
8488
)
8589

86-
final_estimates = masker.fit_trasform(estimates_vol)
90+
# Take real part (FFT operations can introduce small imaginary components)
91+
estimates_vol = np.real(estimates_vol)
92+
93+
# Create image from array and transform back to 2D
94+
estimates_img_out = nib.Nifti1Image(estimates_vol, estimates_img.affine)
95+
# transform returns (n_samples, n_features), we need (n_features, n_samples)
96+
final_estimates = masker.transform(estimates_img_out).T
8797

8898
return final_estimates
8999

@@ -121,16 +131,21 @@ def spatial_structured_sparsity(estimates, data, mask, niter, dims, lambda_):
121131
Estimates of activity-inducing or innovation signal after spatial regularization.
122132
"""
123133
# Transform data from 2D into 4D
124-
estimates_vol = unmask(estimates, mask)
125-
data_vol = unmask(data, mask)
134+
# unmask expects (samples, features) so we transpose (n_features, n_samples) -> (n_samples, n_features)
135+
# unmask returns nibabel image, need to get data array
136+
estimates_vol = unmask(estimates.T, mask).get_fdata()
137+
data_vol = unmask(data.T, mask).get_fdata()
138+
139+
# Get mask array for clip function
140+
mask_data = mask.get_fdata().astype(int)
126141

127142
z = np.zeros(estimates_vol.shape)
128143

129144
h = generate_delta(dim=3)
130145

131146
max_eig = 144
132147

133-
h = np.fft.fftn(h, estimates_vol.shape[:2])
148+
h = np.fft.fftn(h, estimates_vol.shape[:3])
134149

135150
# Perform structured sparsity regularization
136151
for time_idx in range(estimates_vol.shape[-1]):
@@ -139,17 +154,21 @@ def spatial_structured_sparsity(estimates, data, mask, niter, dims, lambda_):
139154
z[:, :, :, time_idx]
140155
+ 1
141156
/ (lambda_ * max_eig)
142-
* np.fft.ifftn(h * np.fft.fftn(data_vol[:, :, :, time_idx], dims[:2]))
143-
- np.fft.ifftn(h * np.conj(h) * np.fft.fftn(z[:, :, :, time_idx], dims[:2]))
157+
* np.fft.ifftn(h * np.fft.fftn(data_vol[:, :, :, time_idx], dims[:3])).real
158+
- np.fft.ifftn(h * np.conj(h) * np.fft.fftn(z[:, :, :, time_idx], dims[:3])).real
144159
/ max_eig,
145-
mask,
160+
mask_data,
146161
)
147-
estimates_vol[:, :, :, time_idx] = data_vol[:, :, :, time_idx] - lambda_ * np.fft.ifftn(
148-
np.conj(h) * np.fft.fttn(z[:, :, :, time_idx], dims[:2])
162+
estimates_vol[:, :, :, time_idx] = (
163+
data_vol[:, :, :, time_idx]
164+
- lambda_ * np.fft.ifftn(np.conj(h) * np.fft.fftn(z[:, :, :, time_idx], dims[:3])).real
149165
)
150166

151167
# Transform data from 4D into 2D
152-
final_estimates = apply_mask(estimates_vol, mask)
168+
# Create nibabel image from array, then apply_mask
169+
# apply_mask returns (samples, features), we need (features, samples) so transpose
170+
estimates_img_out = nib.Nifti1Image(estimates_vol, mask.affine)
171+
final_estimates = apply_mask(estimates_img_out, mask).T
153172

154173
return final_estimates
155174

0 commit comments

Comments
 (0)