@@ -101,6 +101,7 @@ class NNUNetPreprocessd(MapTransform):
101101 This transform optionally applies:
102102 - foreground crop
103103 - spacing-aware resampling
104+ - optional percentile clipping
104105 - intensity normalization
105106
106107 It stores enough metadata in ``image_meta_dict['nnunet_preprocess']`` to
@@ -117,6 +118,8 @@ def __init__(
117118 target_spacing : Optional [Sequence [float ]] = None ,
118119 normalization : str = "zscore" ,
119120 normalization_use_nonzero_mask : bool = True ,
121+ clip_percentile_low : float = 0.0 ,
122+ clip_percentile_high : float = 1.0 ,
120123 force_separate_z : Optional [bool ] = None ,
121124 anisotropy_threshold : float = 3.0 ,
122125 image_order : int = 3 ,
@@ -132,11 +135,27 @@ def __init__(
132135 self .target_spacing = list (target_spacing ) if target_spacing is not None else None
133136 self .normalization = normalization
134137 self .normalization_use_nonzero_mask = normalization_use_nonzero_mask
138+ self .clip_percentile_low = float (clip_percentile_low )
139+ self .clip_percentile_high = float (clip_percentile_high )
135140 self .force_separate_z = force_separate_z
136141 self .anisotropy_threshold = anisotropy_threshold
137142 self .image_order = image_order
138143 self .label_order = label_order
139144 self .order_z = order_z
145+ self ._debug_stats_printed = False # Print one-line pre/post stats once per transform instance
146+ if not (0.0 <= self .clip_percentile_low <= 1.0 ):
147+ raise ValueError (
148+ f"clip_percentile_low must be in [0, 1], got { self .clip_percentile_low } "
149+ )
150+ if not (0.0 <= self .clip_percentile_high <= 1.0 ):
151+ raise ValueError (
152+ f"clip_percentile_high must be in [0, 1], got { self .clip_percentile_high } "
153+ )
154+ if self .clip_percentile_low > self .clip_percentile_high :
155+ raise ValueError (
156+ "clip_percentile_low must be <= clip_percentile_high, got "
157+ f"{ self .clip_percentile_low } > { self .clip_percentile_high } "
158+ )
140159
141160 def __call__ (self , data : Dict [str , Any ]) -> Dict [str , Any ]:
142161 d = dict (data )
@@ -164,6 +183,8 @@ def __call__(self, data: Dict[str, Any]) -> Dict[str, Any]:
164183 "target_spacing" : target_spacing ,
165184 "normalization" : self .normalization ,
166185 "normalization_use_nonzero_mask" : self .normalization_use_nonzero_mask ,
186+ "clip_percentile_low" : self .clip_percentile_low ,
187+ "clip_percentile_high" : self .clip_percentile_high ,
167188 "crop_bbox" : None ,
168189 "cropped_spatial_shape" : list (image_spatial_shape ),
169190 "resampled_spatial_shape" : list (image_spatial_shape ),
@@ -230,13 +251,37 @@ def __call__(self, data: Dict[str, Any]) -> Dict[str, Any]:
230251 self ._get_spatial_shape (image_np , spatial_dims )
231252 )
232253
233- # Normalize image after spatial transforms.
254+ pre_stats = None
255+ if not self ._debug_stats_printed :
256+ pre_stats = self ._format_debug_stats (image_np )
257+
258+ # Optional clipping before normalization (mirrors SmartNormalizeIntensityd order).
259+ image_np = self ._clip_image_percentiles (
260+ image_np ,
261+ spatial_dims = spatial_dims ,
262+ low = self .clip_percentile_low ,
263+ high = self .clip_percentile_high ,
264+ use_nonzero_mask = self .normalization_use_nonzero_mask ,
265+ )
266+
267+ # Normalize image after spatial transforms and clipping.
234268 image_np = self ._normalize_image (
235269 image_np ,
236270 spatial_dims = spatial_dims ,
237271 mode = self .normalization ,
238272 use_nonzero_mask = self .normalization_use_nonzero_mask ,
239273 )
274+ if not self ._debug_stats_printed :
275+ post_stats = self ._format_debug_stats (image_np )
276+ print (
277+ "[NNUNetPreprocessd] "
278+ f"key={ self .image_key } mode={ self .normalization } "
279+ f"clip=({ self .clip_percentile_low :.3f} ,{ self .clip_percentile_high :.3f} ) "
280+ f"nonzero_mask={ self .normalization_use_nonzero_mask } "
281+ f"pre={ pre_stats } post={ post_stats } " ,
282+ flush = True ,
283+ )
284+ self ._debug_stats_printed = True
240285 d [self .image_key ] = self ._from_numpy (image_np , image_state )
241286
242287 meta_dict ["nnunet_preprocess" ] = preprocess_meta
@@ -299,6 +344,20 @@ def _normalize_spacing(
299344 return values [- spatial_dims :]
300345 return None
301346
347+ @staticmethod
348+ def _format_debug_stats (array : np .ndarray ) -> str :
349+ arr = np .asarray (array , dtype = np .float32 )
350+ if arr .size == 0 :
351+ return "empty"
352+ finite = arr [np .isfinite (arr )]
353+ if finite .size == 0 :
354+ return "all_nonfinite"
355+ return (
356+ f"shape={ tuple (int (v ) for v in arr .shape )} "
357+ f"min={ float (finite .min ()):.4f} max={ float (finite .max ()):.4f} "
358+ f"mean={ float (finite .mean ()):.4f} std={ float (finite .std ()):.4f} "
359+ )
360+
302361 @staticmethod
303362 def _create_nonzero_mask (image : np .ndarray , spatial_dims : int ) -> Optional [np .ndarray ]:
304363 if image .ndim == spatial_dims + 1 :
@@ -449,6 +508,48 @@ def _resample_spatial(
449508 )
450509 return stacked
451510
511+ @staticmethod
512+ def _clip_image_percentiles (
513+ image : np .ndarray ,
514+ spatial_dims : int ,
515+ low : float ,
516+ high : float ,
517+ use_nonzero_mask : bool ,
518+ ) -> np .ndarray :
519+ if low <= 0.0 and high >= 1.0 :
520+ return image
521+
522+ image = image .astype (np .float32 , copy = False )
523+ low_q = float (low ) * 100.0
524+ high_q = float (high ) * 100.0
525+
526+ def _apply (volume : np .ndarray , mask : Optional [np .ndarray ]) -> np .ndarray :
527+ if mask is not None and np .any (mask ):
528+ values = volume [mask ]
529+ else :
530+ values = volume .reshape (- 1 )
531+ if values .size == 0 :
532+ return volume
533+ low_val = float (np .percentile (values , low_q ))
534+ high_val = float (np .percentile (values , high_q ))
535+ if high_val < low_val :
536+ low_val , high_val = high_val , low_val
537+ return np .clip (volume , low_val , high_val )
538+
539+ if image .ndim == spatial_dims + 1 :
540+ nonzero_mask = np .any (image != 0 , axis = 0 ) if use_nonzero_mask else None
541+ for c in range (image .shape [0 ]):
542+ image [c ] = _apply (image [c ], nonzero_mask )
543+ if nonzero_mask is not None :
544+ image [c ][~ nonzero_mask ] = 0
545+ return image
546+
547+ mask = image != 0 if use_nonzero_mask else None
548+ image = _apply (image , mask )
549+ if mask is not None :
550+ image [~ mask ] = 0
551+ return image
552+
452553 @staticmethod
453554 def _normalize_image (
454555 image : np .ndarray ,
0 commit comments