Skip to content

Commit 55f0cbb

Browse files
committed
Add suggestions from Cecile - reorder classes and functions
1 parent 3ddfcb8 commit 55f0cbb

File tree

1 file changed

+126
-126
lines changed

1 file changed

+126
-126
lines changed

src/mritk/looklocker.py

Lines changed: 126 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,132 @@
2323
logger = logging.getLogger(__name__)
2424

2525

26+
@dataclass
27+
class LookLocker:
28+
"""
29+
A class encapsulating Look-Locker T1 map generation and post-processing.
30+
31+
Args:
32+
mri (MRIData): An MRIData object containing the 4D Look-Locker sequence.
33+
times (np.ndarray): A 1D array of trigger delay times in seconds corresponding to each volume in the 4D sequence.
34+
35+
"""
36+
37+
mri: MRIData
38+
times: np.ndarray
39+
40+
def t1_map(self) -> "LookLockerT1":
41+
"""
42+
Computes the T1 map from the Look-Locker data using the provided trigger times.
43+
44+
Returns:
45+
LookLockerT1: A LookLockerT1 object containing the computed 3D T1 map (in milliseconds)
46+
and the original affine transformation matrix.
47+
"""
48+
49+
logger.info("Generating T1 map from Look-Locker data")
50+
t1map_array = compute_looklocker_t1_array(self.mri.data, self.times)
51+
mri_data = MRIData(t1map_array.astype(np.single), self.mri.affine)
52+
return LookLockerT1(mri=mri_data)
53+
54+
@classmethod
55+
def from_file(cls, looklocker_input: Path, timestamps: Path):
56+
logger.info(f"Loading Look-Locker data from {looklocker_input} and trigger times from {timestamps}.")
57+
ll_mri = MRIData.from_file(looklocker_input, dtype=np.single)
58+
time_s = np.loadtxt(timestamps) / 1000.0
59+
logger.debug(f"Loaded trigger times: {time_s}.")
60+
return cls(mri=ll_mri, times=time_s)
61+
62+
63+
@dataclass
64+
class LookLockerT1:
65+
"""A class representing a Look-Locker T1 map with post-processing capabilities.
66+
67+
Args:
68+
mri (MRIData): An MRIData object containing the raw T1 map data and affine transformation.
69+
"""
70+
71+
mri: MRIData
72+
73+
@classmethod
74+
def from_file(cls, t1map_path: Path) -> "LookLockerT1":
75+
"""Loads a Look-Locker T1 map from a NIfTI file.
76+
77+
Args:
78+
t1map_path (Path): The file path to the Look-Locker T1 map
79+
NIfTI file.
80+
Returns:
81+
LookLockerT1: An instance of the LookLockerT1 class containing the loaded
82+
T1 map data and affine transformation.
83+
"""
84+
85+
logger.info(f"Loading Look-Locker T1 map from {t1map_path}.")
86+
mri = MRIData.from_file(t1map_path, dtype=np.single)
87+
return cls(mri=mri)
88+
89+
def postprocess(
90+
self,
91+
T1_low: float = 100,
92+
T1_high: float = 6000,
93+
radius: int = 10,
94+
erode_dilate_factor: float = 1.3,
95+
mask: np.ndarray | None = None,
96+
) -> MRIData:
97+
"""Performs quality-control and post-processing on a raw Look-Locker T1 map.
98+
99+
Args:
100+
T1_low (float): Lower physiological limit for T1 values (in ms).
101+
T1_high (float): Upper physiological limit for T1 values (in ms).
102+
radius (int, optional): Base radius for morphological dilation when generating
103+
the automatic mask. Defaults to 10.
104+
erode_dilate_factor (float, optional): Multiplier for the erosion radius
105+
relative to the dilation radius to ensure tight mask edges. Defaults to 1.3.
106+
mask (Optional[np.ndarray], optional): Pre-computed 3D boolean mask. If None,
107+
one is generated automatically. Defaults to None.
108+
output (Path | None, optional): Path to save the cleaned T1 map. Defaults to None.
109+
110+
Returns:
111+
MRIData: An MRIData object containing the cleaned, masked, and interpolated T1 map.
112+
113+
Raises:
114+
RuntimeError: If more than 99% of the voxels are removed during the outlier
115+
filtering step, indicating a likely unit mismatch (e.g., T1 in seconds instead of ms).
116+
117+
Notes:
118+
This function cleans up noisy T1 fits by applying a three-step pipeline:
119+
1. Masking: If no mask is provided, it automatically isolates the brain/head by
120+
finding the largest contiguous tissue island and applying morphological smoothing.
121+
2. Outlier Removal: Voxels falling outside the provided physiological bounds
122+
[T1_low, T1_high] are discarded (set to NaN).
123+
3. Interpolation: Internal "holes" (NaNs) created by poor fits or outlier
124+
removal are iteratively filled using a specialized Gaussian filter that
125+
interpolates from surrounding valid tissue without blurring the edges.
126+
"""
127+
logger.info(f"Post-processing Look-Locker T1 map with T1 range [{T1_low}, {T1_high}] ms.")
128+
t1map_data = self.mri.data.copy()
129+
130+
if mask is None:
131+
logger.debug("No mask provided, generating automatic mask based on the largest contiguous tissue island.")
132+
mask = create_largest_island_mask(t1map_data, radius, erode_dilate_factor)
133+
else:
134+
logger.debug("Using provided mask for post-processing.")
135+
136+
t1map_data = remove_outliers(t1map_data, mask, T1_low, T1_high)
137+
138+
if np.isfinite(t1map_data).sum() / t1map_data.size < 0.01:
139+
raise RuntimeError("After outlier removal, less than 1% of the image is left. Check image units.")
140+
141+
# Fill internal missing values iteratively using a Gaussian filter
142+
fill_mask = np.isnan(t1map_data) & mask
143+
logger.debug(f"Initial fill mask has {fill_mask.sum()} voxels.")
144+
while fill_mask.sum() > 0:
145+
logger.info(f"Filling in {fill_mask.sum()} voxels within the mask.")
146+
t1map_data[fill_mask] = nan_filter_gaussian(t1map_data, 1.0)[fill_mask]
147+
fill_mask = np.isnan(t1map_data) & mask
148+
149+
return MRIData(t1map_data, self.mri.affine)
150+
151+
26152
def read_dicom_trigger_times(dicomfile: Path) -> np.ndarray:
27153
"""
28154
Extracts unique nominal cardiac trigger delay times from DICOM functional groups.
@@ -150,132 +276,6 @@ def compute_looklocker_t1_array(data: np.ndarray, time_s: np.ndarray, t1_roof: f
150276
return np.minimum(t1map, t1_roof)
151277

152278

153-
@dataclass
154-
class LookLockerT1:
155-
"""A class representing a Look-Locker T1 map with post-processing capabilities.
156-
157-
Args:
158-
mri (MRIData): An MRIData object containing the raw T1 map data and affine transformation.
159-
"""
160-
161-
mri: MRIData
162-
163-
@classmethod
164-
def from_file(cls, t1map_path: Path) -> "LookLockerT1":
165-
"""Loads a Look-Locker T1 map from a NIfTI file.
166-
167-
Args:
168-
t1map_path (Path): The file path to the Look-Locker T1 map
169-
NIfTI file.
170-
Returns:
171-
LookLockerT1: An instance of the LookLockerT1 class containing the loaded
172-
T1 map data and affine transformation.
173-
"""
174-
175-
logger.info(f"Loading Look-Locker T1 map from {t1map_path}.")
176-
mri = MRIData.from_file(t1map_path, dtype=np.single)
177-
return cls(mri=mri)
178-
179-
def postprocess(
180-
self,
181-
T1_low: float = 100,
182-
T1_high: float = 6000,
183-
radius: int = 10,
184-
erode_dilate_factor: float = 1.3,
185-
mask: np.ndarray | None = None,
186-
) -> MRIData:
187-
"""Performs quality-control and post-processing on a raw Look-Locker T1 map.
188-
189-
Args:
190-
T1_low (float): Lower physiological limit for T1 values (in ms).
191-
T1_high (float): Upper physiological limit for T1 values (in ms).
192-
radius (int, optional): Base radius for morphological dilation when generating
193-
the automatic mask. Defaults to 10.
194-
erode_dilate_factor (float, optional): Multiplier for the erosion radius
195-
relative to the dilation radius to ensure tight mask edges. Defaults to 1.3.
196-
mask (Optional[np.ndarray], optional): Pre-computed 3D boolean mask. If None,
197-
one is generated automatically. Defaults to None.
198-
output (Path | None, optional): Path to save the cleaned T1 map. Defaults to None.
199-
200-
Returns:
201-
MRIData: An MRIData object containing the cleaned, masked, and interpolated T1 map.
202-
203-
Raises:
204-
RuntimeError: If more than 99% of the voxels are removed during the outlier
205-
filtering step, indicating a likely unit mismatch (e.g., T1 in seconds instead of ms).
206-
207-
Notes:
208-
This function cleans up noisy T1 fits by applying a three-step pipeline:
209-
1. Masking: If no mask is provided, it automatically isolates the brain/head by
210-
finding the largest contiguous tissue island and applying morphological smoothing.
211-
2. Outlier Removal: Voxels falling outside the provided physiological bounds
212-
[T1_low, T1_high] are discarded (set to NaN).
213-
3. Interpolation: Internal "holes" (NaNs) created by poor fits or outlier
214-
removal are iteratively filled using a specialized Gaussian filter that
215-
interpolates from surrounding valid tissue without blurring the edges.
216-
"""
217-
logger.info(f"Post-processing Look-Locker T1 map with T1 range [{T1_low}, {T1_high}] ms.")
218-
t1map_data = self.mri.data.copy()
219-
220-
if mask is None:
221-
logger.debug("No mask provided, generating automatic mask based on the largest contiguous tissue island.")
222-
mask = create_largest_island_mask(t1map_data, radius, erode_dilate_factor)
223-
else:
224-
logger.debug("Using provided mask for post-processing.")
225-
226-
t1map_data = remove_outliers(t1map_data, mask, T1_low, T1_high)
227-
228-
if np.isfinite(t1map_data).sum() / t1map_data.size < 0.01:
229-
raise RuntimeError("After outlier removal, less than 1% of the image is left. Check image units.")
230-
231-
# Fill internal missing values iteratively using a Gaussian filter
232-
fill_mask = np.isnan(t1map_data) & mask
233-
logger.debug(f"Initial fill mask has {fill_mask.sum()} voxels.")
234-
while fill_mask.sum() > 0:
235-
logger.info(f"Filling in {fill_mask.sum()} voxels within the mask.")
236-
t1map_data[fill_mask] = nan_filter_gaussian(t1map_data, 1.0)[fill_mask]
237-
fill_mask = np.isnan(t1map_data) & mask
238-
239-
return MRIData(t1map_data, self.mri.affine)
240-
241-
242-
@dataclass
243-
class LookLocker:
244-
"""
245-
A class encapsulating Look-Locker T1 map generation and post-processing.
246-
247-
Args:
248-
mri (MRIData): An MRIData object containing the 4D Look-Locker sequence.
249-
times (np.ndarray): A 1D array of trigger delay times in seconds corresponding to each volume in the 4D sequence.
250-
251-
"""
252-
253-
mri: MRIData
254-
times: np.ndarray
255-
256-
def t1_map(self) -> LookLockerT1:
257-
"""
258-
Computes the T1 map from the Look-Locker data using the provided trigger times.
259-
260-
Returns:
261-
LookLockerT1: A LookLockerT1 object containing the computed 3D T1 map (in milliseconds)
262-
and the original affine transformation matrix.
263-
"""
264-
265-
logger.info("Generating T1 map from Look-Locker data")
266-
t1map_array = compute_looklocker_t1_array(self.mri.data, self.times)
267-
mri_data = MRIData(t1map_array.astype(np.single), self.mri.affine)
268-
return LookLockerT1(mri=mri_data)
269-
270-
@classmethod
271-
def from_file(cls, looklocker_input: Path, timestamps: Path):
272-
logger.info(f"Loading Look-Locker data from {looklocker_input} and trigger times from {timestamps}.")
273-
ll_mri = MRIData.from_file(looklocker_input, dtype=np.single)
274-
time_s = np.loadtxt(timestamps) / 1000.0
275-
logger.debug(f"Loaded trigger times: {time_s}.")
276-
return cls(mri=ll_mri, times=time_s)
277-
278-
279279
def dicom_to_looklocker(dicomfile: Path, outpath: Path):
280280
"""
281281
Converts a Look-Locker DICOM file to a standardized NIfTI format.

0 commit comments

Comments
 (0)