|
23 | 23 | logger = logging.getLogger(__name__) |
24 | 24 |
|
25 | 25 |
|
| 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 | + |
26 | 152 | def read_dicom_trigger_times(dicomfile: Path) -> np.ndarray: |
27 | 153 | """ |
28 | 154 | 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 |
150 | 276 | return np.minimum(t1map, t1_roof) |
151 | 277 |
|
152 | 278 |
|
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 | | - |
279 | 279 | def dicom_to_looklocker(dicomfile: Path, outpath: Path): |
280 | 280 | """ |
281 | 281 | Converts a Look-Locker DICOM file to a standardized NIfTI format. |
|
0 commit comments