1212from dataclasses import dataclass
1313from functools import partial
1414from pathlib import Path
15- from typing import Optional
1615
1716import numpy as np
1817import 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 } " )
0 commit comments