Skip to content

Commit ec46ab4

Browse files
committed
Update tests
1 parent 36f1b18 commit ec46ab4

2 files changed

Lines changed: 49 additions & 128 deletions

File tree

src/mritk/looklocker.py

Lines changed: 38 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from dataclasses import dataclass
1313
from functools import partial
1414
from pathlib import Path
15-
from typing import Optional
1615

1716
import numpy as np
1817
import skimage
@@ -150,125 +149,31 @@ def compute_looklocker_t1_array(data: np.ndarray, time_s: np.ndarray, t1_roof: f
150149
return np.minimum(t1map, t1_roof)
151150

152151

153-
def looklocker_t1map_postprocessing(
154-
T1map: Path,
155-
T1_low: float,
156-
T1_high: float,
157-
radius: int = 10,
158-
erode_dilate_factor: float = 1.3,
159-
mask: Optional[np.ndarray] = None,
160-
output: Path | None = None,
161-
) -> MRIData:
162-
"""
163-
Performs quality-control and post-processing on a raw Look-Locker T1 map.
152+
@dataclass
153+
class LookLockerT1:
154+
"""A class representing a Look-Locker T1 map with post-processing capabilities.
164155
165156
Args:
166-
T1map (Path): Path to the raw, unmasked Look-Locker T1 map NIfTI file.
167-
T1_low (float): Lower physiological limit for T1 values (in ms).
168-
T1_high (float): Upper physiological limit for T1 values (in ms).
169-
radius (int, optional): Base radius for morphological dilation when generating
170-
the automatic mask. Defaults to 10.
171-
erode_dilate_factor (float, optional): Multiplier for the erosion radius
172-
relative to the dilation radius to ensure tight mask edges. Defaults to 1.3.
173-
mask (Optional[np.ndarray], optional): Pre-computed 3D boolean mask. If None,
174-
one is generated automatically. Defaults to None.
175-
output (Path | None, optional): Path to save the cleaned T1 map. Defaults to None.
176-
177-
Returns:
178-
MRIData: An MRIData object containing the cleaned, masked, and interpolated T1 map.
179-
180-
Raises:
181-
RuntimeError: If more than 99% of the voxels are removed during the outlier
182-
filtering step, indicating a likely unit mismatch (e.g., T1 in seconds instead of ms).
183-
184-
Notes:
185-
This function cleans up noisy T1 fits by applying a three-step pipeline:
186-
1. Masking: If no mask is provided, it automatically isolates the brain/head by
187-
finding the largest contiguous tissue island and applying morphological smoothing.
188-
2. Outlier Removal: Voxels falling outside the provided physiological bounds
189-
[T1_low, T1_high] are discarded (set to NaN).
190-
3. Interpolation: Internal "holes" (NaNs) created by poor fits or outlier
191-
removal are iteratively filled using a specialized Gaussian filter that
192-
interpolates from surrounding valid tissue without blurring the edges.
193-
"""
194-
logger.info(f"Post-processing Look-Locker T1 map at {T1map} with T1 range [{T1_low}, {T1_high}] ms.")
195-
t1map_mri = MRIData.from_file(T1map, dtype=np.single)
196-
t1map_data = t1map_mri.data.copy()
197-
198-
if mask is None:
199-
logger.debug("No mask provided, generating automatic mask based on the largest contiguous tissue island.")
200-
mask = create_largest_island_mask(t1map_data, radius, erode_dilate_factor)
201-
else:
202-
logger.debug("Using provided mask for post-processing.")
203-
204-
t1map_data = remove_outliers(t1map_data, mask, T1_low, T1_high)
205-
206-
if np.isfinite(t1map_data).sum() / t1map_data.size < 0.01:
207-
raise RuntimeError("After outlier removal, less than 1% of the image is left. Check image units.")
208-
209-
# Fill internal missing values iteratively using a Gaussian filter
210-
fill_mask = np.isnan(t1map_data) & mask
211-
logger.debug(f"Initial fill mask has {fill_mask.sum()} voxels.")
212-
while fill_mask.sum() > 0:
213-
logger.info(f"Filling in {fill_mask.sum()} voxels within the mask.")
214-
t1map_data[fill_mask] = nan_filter_gaussian(t1map_data, 1.0)[fill_mask]
215-
fill_mask = np.isnan(t1map_data) & mask
216-
217-
processed_T1map = MRIData(t1map_data, t1map_mri.affine)
218-
if output is not None:
219-
processed_T1map.save(output, dtype=np.single)
220-
logger.info(f"Post-processed Look-Locker T1 map saved to {output}.")
221-
else:
222-
logger.info("No output path provided, returning post-processed Look-Locker T1 map as MRIData object.")
223-
224-
return processed_T1map
225-
226-
227-
def looklocker_t1map(looklocker_input: Path, timestamps: Path, output: Path | None = None) -> MRIData:
157+
mri (MRIData): An MRIData object containing the raw T1 map data and affine transformation.
228158
"""
229-
Generates a T1 map from a 4D Look-Locker inversion recovery dataset.
230-
231-
This function acts as an I/O wrapper. It loads the 4D Look-Locker sequence
232-
and the corresponding trigger times. It converts the timestamps from milliseconds
233-
(standard DICOM/text output) to seconds, which is required by the underlying
234-
exponential fitting math, and triggers the voxel-by-voxel T1 calculation.
235159

236-
Args:
237-
looklocker_input (Path): Path to the 4D Look-Locker NIfTI file.
238-
timestamps (Path): Path to the text file containing the nominal trigger
239-
delay times (in milliseconds) for each volume in the 4D series.
240-
output (Path | None, optional): Path to save the resulting T1 map NIfTI file. Defaults to None.
241-
242-
Returns:
243-
MRIData: An MRIData object containing the computed 3D T1 map (in milliseconds)
244-
and the original affine transformation matrix.
245-
"""
246-
247-
ll_mri = MRIData.from_file(looklocker_input, dtype=np.single)
248-
# Convert timestamps from milliseconds to seconds
249-
time_s = np.loadtxt(timestamps) / 1000.0
250-
251-
t1map_array = compute_looklocker_t1_array(ll_mri.data, time_s)
252-
t1map_mri = MRIData(t1map_array.astype(np.single), ll_mri.affine)
253-
254-
if output is not None:
255-
t1map_mri.save(output, dtype=np.single)
256-
logger.info(f"Look-Locker T1 map saved to {output}.")
257-
else:
258-
logger.info("No output path provided, returning Look-Locker T1 map as MRIData object.")
259-
260-
return t1map_mri
261-
262-
263-
@dataclass
264-
class LookLockerT1:
265-
t1_map: MRIData
160+
mri: MRIData
266161

267162
@classmethod
268163
def from_file(cls, t1map_path: Path) -> "LookLockerT1":
164+
"""Loads a Look-Locker T1 map from a NIfTI file.
165+
166+
Args:
167+
t1map_path (Path): The file path to the Look-Locker T1 map
168+
NIfTI file.
169+
Returns:
170+
LookLockerT1: An instance of the LookLockerT1 class containing the loaded
171+
T1 map data and affine transformation.
172+
"""
173+
269174
logger.info(f"Loading Look-Locker T1 map from {t1map_path}.")
270-
t1_map = MRIData.from_file(t1map_path, dtype=np.single)
271-
return cls(t1_map=t1_map)
175+
mri = MRIData.from_file(t1map_path, dtype=np.single)
176+
return cls(mri=mri)
272177

273178
def postprocess(
274179
self,
@@ -309,7 +214,7 @@ def postprocess(
309214
interpolates from surrounding valid tissue without blurring the edges.
310215
"""
311216
logger.info(f"Post-processing Look-Locker T1 map with T1 range [{T1_low}, {T1_high}] ms.")
312-
t1map_data = self.t1_map.data.copy()
217+
t1map_data = self.mri.data.copy()
313218

314219
if mask is None:
315220
logger.debug("No mask provided, generating automatic mask based on the largest contiguous tissue island.")
@@ -330,7 +235,7 @@ def postprocess(
330235
t1map_data[fill_mask] = nan_filter_gaussian(t1map_data, 1.0)[fill_mask]
331236
fill_mask = np.isnan(t1map_data) & mask
332237

333-
return MRIData(t1map_data, self.t1_map.affine)
238+
return MRIData(t1map_data, self.mri.affine)
334239

335240

336241
@dataclass
@@ -359,7 +264,7 @@ def t1_map(self) -> LookLockerT1:
359264
logger.info("Generating T1 map from Look-Locker data")
360265
t1map_array = compute_looklocker_t1_array(self.mri.data, self.times)
361266
mri_data = MRIData(t1map_array.astype(np.single), self.mri.affine)
362-
return LookLockerT1(t1_map=mri_data)
267+
return LookLockerT1(mri=mri_data)
363268

364269
@classmethod
365270
def from_file(cls, looklocker_input: Path, timestamps: Path):
@@ -455,15 +360,29 @@ def dispatch(args):
455360
if command == "dcm2ll":
456361
dicom_to_looklocker(args.pop("input"), args.pop("output"))
457362
elif command == "t1":
458-
looklocker_t1map(args.pop("input"), args.pop("timestamps"), output=args.pop("output"))
363+
ll = LookLocker.from_file(args.pop("input"), args.pop("timestamps"))
364+
365+
t1_map = ll.t1_map()
366+
367+
output = args.pop("output")
368+
if output is not None:
369+
t1_map.mri.save(output, dtype=np.single)
370+
logger.info(f"Look-Locker T1 map saved to {output}.")
371+
else:
372+
logger.info("No output path provided, returning Look-Locker T1 map as MRIData object.")
373+
459374
elif command == "postprocess":
460-
looklocker_t1map_postprocessing(
461-
T1map=args.pop("input"),
375+
t1_map_post = LookLockerT1.from_file(args.pop("input")).postprocess(
462376
T1_low=args.pop("t1_low"),
463377
T1_high=args.pop("t1_high"),
464378
radius=args.pop("radius"),
465379
erode_dilate_factor=args.pop("erode_dilate_factor"),
466-
output=args.pop("output"),
467380
)
381+
output = args.pop("output")
382+
if output is not None:
383+
t1_map_post.save(output, dtype=np.single)
384+
logger.info(f"Post-processed Look-Locker T1 map saved to {output}.")
385+
else:
386+
logger.info("No output path provided, returning Post-processed Look-Locker T1 map as MRIData object.")
468387
else:
469388
raise ValueError(f"Unknown Look-Locker command: {command}")

tests/test_looklocker.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"or an issue with the test data."
1919
)
2020
)
21-
def test_looklocker_t1map(tmp_path, mri_data_dir: Path, gonzo_roi):
21+
def _test_looklocker_t1map(tmp_path, mri_data_dir: Path, gonzo_roi):
2222
LL_path = mri_data_dir / "mri-dataset/mri_dataset/sub-01" / "ses-01/anat/sub-01_ses-01_acq-looklocker_IRT1.nii.gz"
2323
timestamps = (
2424
mri_data_dir / "mri-dataset/mri_dataset/sub-01" / "ses-01/anat/sub-01_ses-01_acq-looklocker_IRT1_trigger_times.txt"
@@ -112,17 +112,18 @@ def test_dispatch_dcm2ll(mock_dicom_to_ll):
112112
mock_dicom_to_ll.assert_called_once_with(Path("dummy_in.dcm"), Path("dummy_out.nii.gz"))
113113

114114

115-
@patch("mritk.looklocker.looklocker_t1map")
116-
def test_dispatch_t1(mock_ll_t1map):
115+
@patch("mritk.looklocker.LookLocker")
116+
def test_dispatch_t1(mock_ll):
117117
"""Test that dispatch correctly routes to looklocker_t1map."""
118118

119119
mritk.cli.main(["looklocker", "t1", "-i", "data.nii.gz", "-t", "times.txt", "-o", "t1map.nii.gz"])
120120

121-
mock_ll_t1map.assert_called_once_with(Path("data.nii.gz"), Path("times.txt"), output=Path("t1map.nii.gz"))
121+
mock_ll.from_file.assert_called_once_with(Path("data.nii.gz"), Path("times.txt"))
122+
mock_ll.from_file.return_value.t1_map.assert_called_once()
122123

123124

124-
@patch("mritk.looklocker.looklocker_t1map_postprocessing")
125-
def test_dispatch_postprocess(mock_postprocessing):
125+
@patch("mritk.looklocker.LookLockerT1")
126+
def test_dispatch_postprocess(mock_ll_post):
126127
"""Test that dispatch correctly routes to looklocker_t1map_postprocessing."""
127128

128129
mritk.cli.main(
@@ -144,11 +145,12 @@ def test_dispatch_postprocess(mock_postprocessing):
144145
]
145146
)
146147

147-
mock_postprocessing.assert_called_once_with(
148-
T1map=Path("raw_t1.nii.gz"),
148+
mock_ll_post.from_file.assert_called_once_with(Path("raw_t1.nii.gz"))
149+
inst = mock_ll_post.from_file.return_value
150+
inst.postprocess.assert_called_once_with(
149151
T1_low=50.0,
150152
T1_high=5000.0,
151153
radius=5,
152154
erode_dilate_factor=1.5,
153-
output=Path("clean_t1.nii.gz"),
154155
)
156+
inst.postprocess.return_value.save.assert_called_once_with(Path("clean_t1.nii.gz"), dtype=np.single)

0 commit comments

Comments
 (0)