|
28 | 28 | from scipy.ndimage import median_filter |
29 | 29 | from skimage.morphology import ball |
30 | 30 |
|
| 31 | +from nifreeze.data.dmri import DEFAULT_CLIP_PERCENTILE |
| 32 | + |
31 | 33 | DEFAULT_DTYPE = "int16" |
32 | 34 | """The default image's data type.""" |
| 35 | +BVAL_ATOL = 100.0 |
| 36 | +"""b-value tolerance value.""" |
33 | 37 |
|
34 | 38 |
|
35 | 39 | def advanced_clip( |
@@ -96,3 +100,161 @@ def advanced_clip( |
96 | 100 | data = np.round(255 * data).astype(dtype) |
97 | 101 |
|
98 | 102 | return data |
| 103 | + |
| 104 | + |
| 105 | +def robust_minmax_normalization( |
| 106 | + data: np.ndarray, |
| 107 | + mask: np.ndarray | None = None, |
| 108 | + p_min: float = 5.0, |
| 109 | + p_max: float = 95.0, |
| 110 | + inplace: bool = False, |
| 111 | +) -> np.ndarray | None: |
| 112 | + r"""Normalize min-max percentiles of each volume to the grand min-max |
| 113 | + percentiles. |
| 114 | +
|
| 115 | + Robust min/max normalization of the volumes in the dataset following: |
| 116 | +
|
| 117 | + .. math:: |
| 118 | + \text{data}_{\text{normalized}} = \frac{(\text{data} - p_{min}) \cdot p_{\text{mean}}}{p_{\text{range}}} + p_{min}^{\text{mean}} |
| 119 | +
|
| 120 | + where |
| 121 | +
|
| 122 | + .. math:: |
| 123 | + p_{\text{range}} = p_{max} - p_{min}, \quad p_{\text{mean}} = \frac{1}{N} \sum_{i=1}^N p_{\text{range}_i}, \quad p_{min}^{\text{mean}} = \frac{1}{N} \sum_{i=1}^N p_{5_i} |
| 124 | +
|
| 125 | + If a mask is provided, only the data within the mask are considered. |
| 126 | +
|
| 127 | + Parameters |
| 128 | + ---------- |
| 129 | + data : :obj:`~numpy.ndarray` |
| 130 | + Data to be normalized. |
| 131 | + mask : :obj:`~numpy.ndarray`, optional |
| 132 | + Mask. If provided, only the data within the mask are considered. |
| 133 | + p_min : :obj:`float`, optional |
| 134 | + The lower percentile value for normalization. |
| 135 | + p_max : :obj:`float`, optional |
| 136 | + The upper percentile value for normalization. |
| 137 | + inplace : :obj:`bool`, optional |
| 138 | + If ``False``, the normalization is performed on the original data. |
| 139 | +
|
| 140 | + Returns |
| 141 | + ------- |
| 142 | + data : :obj:`~numpy.ndarray` or None |
| 143 | + Normalized data or ``None`` if ``inplace`` is ``True``. |
| 144 | + """ |
| 145 | + |
| 146 | + normalized = data if inplace else data.copy() |
| 147 | + |
| 148 | + mask = mask if mask is not None else np.ones(data.shape[-1], dtype=bool) |
| 149 | + volumes = data[..., mask] |
| 150 | + reshape_shape = (-1, volumes.shape[-1]) if mask is None else (-1, sum(mask)) |
| 151 | + reshaped_data = volumes.reshape(reshape_shape) |
| 152 | + p5 = np.percentile(reshaped_data, p_min, axis=0) |
| 153 | + p95 = np.percentile(reshaped_data, p_max, axis=0) - p5 |
| 154 | + normalized[..., mask] = (volumes - p5) * p95.mean() / p95 + p5.mean() |
| 155 | + |
| 156 | + if inplace: |
| 157 | + return None |
| 158 | + |
| 159 | + return normalized |
| 160 | + |
| 161 | + |
| 162 | +def grand_mean_normalization( |
| 163 | + data: np.ndarray, |
| 164 | + mask: np.ndarray | None = None, |
| 165 | + center: float = DEFAULT_CLIP_PERCENTILE, |
| 166 | + inplace: bool = False, |
| 167 | +) -> np.ndarray | None: |
| 168 | + """Robust grand mean normalization. |
| 169 | +
|
| 170 | + Regresses out global signal differences so that data are normalized and |
| 171 | + centered around a given value. |
| 172 | +
|
| 173 | + If a mask is provided, only the data within the mask are considered. |
| 174 | +
|
| 175 | + Parameters |
| 176 | + ---------- |
| 177 | + data : :obj:`~numpy.ndarray` |
| 178 | + Data to be normalized. |
| 179 | + mask : :obj:`~numpy.ndarray`, optional |
| 180 | + Mask. If provided, only the data within the mask are considered. |
| 181 | + center : float, optional |
| 182 | + Central value around which to normalize the data. |
| 183 | + inplace : :obj:`bool`, optional |
| 184 | + If ``False``, the normalization is performed on the original data. |
| 185 | +
|
| 186 | + Returns |
| 187 | + ------- |
| 188 | + data : :obj:`~numpy.ndarray` or None |
| 189 | + Normalized data or ``None`` if ``inplace`` is ``True``. |
| 190 | + """ |
| 191 | + |
| 192 | + normalized = data if inplace else data.copy() |
| 193 | + |
| 194 | + mask = mask if mask is not None else np.ones(data.shape[-1], dtype=bool) |
| 195 | + volumes = data[..., mask] |
| 196 | + |
| 197 | + centers = np.median(volumes, axis=(0, 1, 2)) |
| 198 | + reference = np.percentile(centers[centers >= 1.0], center) |
| 199 | + centers[centers < 1.0] = reference |
| 200 | + drift = reference / centers |
| 201 | + normalized[..., mask] = volumes * drift |
| 202 | + |
| 203 | + if inplace: |
| 204 | + return None |
| 205 | + |
| 206 | + return normalized |
| 207 | + |
| 208 | + |
| 209 | +def dwi_select_shells( |
| 210 | + gradients: np.ndarray, |
| 211 | + index: int, |
| 212 | + atol_low: float | None = None, |
| 213 | + atol_high: float | None = None, |
| 214 | +) -> np.ndarray: |
| 215 | + """Select DWI shells around the given index and lower and upper b-value |
| 216 | + bounds. |
| 217 | +
|
| 218 | + Computes a boolean mask of the DWI shells around the given index with the |
| 219 | + provided lower and upper bound b-values. |
| 220 | +
|
| 221 | + If ``atol_low`` and ``atol_high`` are both ``None``, the returned shell mask |
| 222 | + corresponds to the lengths of the diffusion-sensitizing gradients. |
| 223 | +
|
| 224 | + Parameters |
| 225 | + ---------- |
| 226 | + gradients : :obj:`~numpy.ndarray` |
| 227 | + Gradients. |
| 228 | + index : :obj:`int` |
| 229 | + Index of the shell data. |
| 230 | + atol_low : :obj:`float`, optional |
| 231 | + A lower bound for the b-value. |
| 232 | + atol_high : :obj:`float`, optional |
| 233 | + An upper bound for the b-value. |
| 234 | +
|
| 235 | + Returns |
| 236 | + ------- |
| 237 | + shellmask : :obj:`~numpy.ndarray` |
| 238 | + Shell mask. |
| 239 | + """ |
| 240 | + |
| 241 | + bvalues = gradients[:, -1] |
| 242 | + bcenter = bvalues[index] |
| 243 | + |
| 244 | + shellmask = np.ones(len(bvalues), dtype=bool) |
| 245 | + shellmask[index] = False # Drop the held-out index |
| 246 | + |
| 247 | + if atol_low is None and atol_high is None: |
| 248 | + return shellmask |
| 249 | + |
| 250 | + atol_low = 0 if atol_low is None else atol_low |
| 251 | + atol_high = gradients[:, -1].max() if atol_high is None else atol_high |
| 252 | + |
| 253 | + # Keep only b-values within the range defined by atol_high and atol_low |
| 254 | + shellmask[bvalues > (bcenter + atol_high)] = False |
| 255 | + shellmask[bvalues < (bcenter - atol_low)] = False |
| 256 | + |
| 257 | + if not shellmask.sum(): |
| 258 | + raise RuntimeError(f"Shell corresponding to index {index} (b={bcenter}) is empty.") |
| 259 | + |
| 260 | + return shellmask |
0 commit comments