1111
1212from __future__ import annotations
1313
14+ import warnings
15+ from collections .abc import Callable
16+
1417import torch
1518import torch .nn .functional as F
1619from torch .nn .modules .loss import _Loss
1720
21+ from monai .losses .dice import DiceLoss
22+ from monai .networks import one_hot
23+ from monai .utils import LossReduction
24+ from monai .utils .deprecate_utils import deprecated_arg
25+
1826
1927def soft_erode (img : torch .Tensor ) -> torch .Tensor : # type: ignore
2028 """
@@ -92,26 +100,6 @@ def soft_skel(img: torch.Tensor, iter_: int) -> torch.Tensor:
92100 return skel
93101
94102
95- def soft_dice (y_true : torch .Tensor , y_pred : torch .Tensor , smooth : float = 1.0 ) -> torch .Tensor :
96- """
97- Function to compute soft dice loss
98-
99- Adapted from:
100- https://github.com/jocpae/clDice/blob/master/cldice_loss/pytorch/cldice.py#L22
101-
102- Args:
103- y_true: the shape should be BCH(WD)
104- y_pred: the shape should be BCH(WD)
105-
106- Returns:
107- dice loss
108- """
109- intersection = torch .sum ((y_true * y_pred )[:, 1 :, ...])
110- coeff = (2.0 * intersection + smooth ) / (torch .sum (y_true [:, 1 :, ...]) + torch .sum (y_pred [:, 1 :, ...]) + smooth )
111- soft_dice : torch .Tensor = 1.0 - coeff
112- return soft_dice
113-
114-
115103class SoftclDiceLoss (_Loss ):
116104 """
117105 Compute the Soft clDice loss defined in:
@@ -121,64 +109,269 @@ class SoftclDiceLoss(_Loss):
121109
122110 Adapted from:
123111 https://github.com/jocpae/clDice/blob/master/cldice_loss/pytorch/cldice.py#L7
112+
113+ The data `input` (BNHW[D] where N is number of classes) is compared with ground truth `target` (BNHW[D]).
114+ Note that axis N of `input` is expected to be logits or probabilities for each class, if passing logits as input,
115+ must set `sigmoid=True` or `softmax=True`, or specifying `other_act`. And the same axis of `target`
116+ can be 1 or N (one-hot format).
117+
124118 """
125119
126- def __init__ (self , iter_ : int = 3 , smooth : float = 1.0 ) -> None :
120+ def __init__ (
121+ self ,
122+ iter_ : int = 3 ,
123+ smooth_nr : float = 1.0 ,
124+ smooth_dr : float = 1.0 ,
125+ smooth : float = 1e-4 ,
126+ include_background : bool = True ,
127+ to_onehot_y : bool = False ,
128+ sigmoid : bool = False ,
129+ softmax : bool = False ,
130+ other_act : Callable | None = None ,
131+ reduction : LossReduction | str = LossReduction .MEAN ,
132+ ) -> None :
127133 """
128134 Args:
129- iter_: Number of iterations for skeletonization. Defaults to 3.
130- smooth: Smoothing parameter. Defaults to 1.0.
135+ iter_: Number of iterations for skeletonization. Must be a non-negative integer. Defaults to 3.
136+ smooth_nr: a small constant added to the numerator to avoid zero. Defaults to 1.0.
137+ smooth_dr: a small constant added to the denominator to avoid nan. Defaults to 1.0.
138+ smooth: a small constant added to the denominator of the harmonic mean to avoid nan. Defaults to 1e-4.
139+ include_background: if False, channel index 0 (background category) is excluded from the calculation.
140+ if the non-background segmentations are small compared to the total image size they can get overwhelmed
141+ by the signal from the background so excluding it in such cases helps convergence.
142+ to_onehot_y: whether to convert the ``target`` into the one-hot format,
143+ using the number of classes inferred from `input` (``input.shape[1]``). Defaults to False.
144+ sigmoid: if True, apply a sigmoid function to the prediction.
145+ softmax: if True, apply a softmax function to the prediction.
146+ other_act: callable function to execute other activation layers, Defaults to ``None``. for example:
147+ ``other_act = torch.tanh``.
148+ reduction: {``"none"``, ``"mean"``, ``"sum"``}
149+ Specifies the reduction to apply to the output. Defaults to ``"mean"``.
150+
151+ - ``"none"``: no reduction will be applied.
152+ - ``"mean"``: the sum of the output will be divided by the number of elements in the output.
153+ - ``"sum"``: the output will be summed.
154+
155+ Raises:
156+ TypeError: When ``other_act`` is not an ``Optional[Callable]``.
157+ TypeError: When ``iter_`` is not an ``int``.
158+ ValueError: When ``iter_`` is a negative integer.
159+ ValueError: When ``smooth`` is not a positive value.
160+ ValueError: When more than 1 of [``sigmoid=True``, ``softmax=True``, ``other_act is not None``].
161+ Incompatible values.
162+
131163 """
132- super ().__init__ ()
164+ super ().__init__ (reduction = LossReduction (reduction ).value )
165+ if other_act is not None and not callable (other_act ):
166+ raise TypeError (f"other_act must be None or callable but is { type (other_act ).__name__ } ." )
167+ if int (sigmoid ) + int (softmax ) + int (other_act is not None ) > 1 :
168+ raise ValueError ("Incompatible values: more than 1 of [sigmoid=True, softmax=True, other_act is not None]." )
169+ if not isinstance (iter_ , int ):
170+ raise TypeError (f"iter_ must be an integer but got { type (iter_ ).__name__ } ." )
171+ if iter_ < 0 :
172+ raise ValueError (f"iter_ must be a non-negative integer but got { iter_ } ." )
173+ if smooth <= 0 :
174+ raise ValueError (f"smooth must be a positive value but got { smooth } ." )
133175 self .iter = iter_
134- self .smooth = smooth
176+ self .smooth_nr = float (smooth_nr )
177+ self .smooth_dr = float (smooth_dr )
178+ self .smooth = float (smooth )
179+ self .include_background = include_background
180+ self .to_onehot_y = to_onehot_y
181+ self .sigmoid = sigmoid
182+ self .softmax = softmax
183+ self .other_act = other_act
184+
185+ @deprecated_arg ("y_pred" , since = "1.5" , removed = "1.8" , new_name = "input" , msg_suffix = "please use `input` instead." )
186+ @deprecated_arg ("y_true" , since = "1.5" , removed = "1.8" , new_name = "target" , msg_suffix = "please use `target` instead." )
187+ def forward (self , input : torch .Tensor , target : torch .Tensor ) -> torch .Tensor :
188+ """
189+ Args:
190+ input: the shape should be BNH[WD], where N is the number of classes.
191+ target: the shape should be BNH[WD] or B1H[WD], where N is the number of classes.
192+
193+ Raises:
194+ AssertionError: When input and target (after one hot transform if set)
195+ have different shapes.
196+
197+ """
198+ n_pred_ch = input .shape [1 ]
199+
200+ if self .sigmoid :
201+ input = torch .sigmoid (input )
202+
203+ if self .softmax :
204+ if n_pred_ch == 1 :
205+ warnings .warn ("single channel prediction, `softmax=True` ignored." , stacklevel = 2 )
206+ else :
207+ input = torch .softmax (input , dim = 1 )
135208
136- def forward (self , y_true : torch .Tensor , y_pred : torch .Tensor ) -> torch .Tensor :
137- skel_pred = soft_skel (y_pred , self .iter )
138- skel_true = soft_skel (y_true , self .iter )
139- tprec = (torch .sum (torch .multiply (skel_pred , y_true )[:, 1 :, ...]) + self .smooth ) / (
140- torch .sum (skel_pred [:, 1 :, ...]) + self .smooth
209+ if self .other_act is not None :
210+ input = self .other_act (input )
211+
212+ if self .to_onehot_y :
213+ if n_pred_ch == 1 :
214+ warnings .warn ("single channel prediction, `to_onehot_y=True` ignored." , stacklevel = 2 )
215+ else :
216+ target = one_hot (target , num_classes = n_pred_ch )
217+
218+ if not self .include_background :
219+ if n_pred_ch == 1 :
220+ warnings .warn ("single channel prediction, `include_background=False` ignored." , stacklevel = 2 )
221+ else :
222+ target = target [:, 1 :]
223+ input = input [:, 1 :]
224+
225+ if target .shape != input .shape :
226+ raise AssertionError (f"ground truth has different shape ({ target .shape } ) from input ({ input .shape } )" )
227+
228+ skel_pred = soft_skel (input , self .iter )
229+ skel_true = soft_skel (target , self .iter )
230+
231+ # Compute per-batch clDice by reducing over channel and spatial dimensions
232+ # reduce_axis includes all dimensions except batch (dim 0)
233+ reduce_axis : list [int ] = list (range (1 , len (input .shape )))
234+
235+ tprec = (torch .sum (torch .multiply (skel_pred , target ), dim = reduce_axis ) + self .smooth_nr ) / (
236+ torch .sum (skel_pred , dim = reduce_axis ) + self .smooth_dr
141237 )
142- tsens = (torch .sum (torch .multiply (skel_true , y_pred )[:, 1 :, ...] ) + self .smooth ) / (
143- torch .sum (skel_true [:, 1 :, ...] ) + self .smooth
238+ tsens = (torch .sum (torch .multiply (skel_true , input ), dim = reduce_axis ) + self .smooth_nr ) / (
239+ torch .sum (skel_true , dim = reduce_axis ) + self .smooth_dr
144240 )
145- cl_dice : torch .Tensor = 1.0 - 2.0 * (tprec * tsens ) / (tprec + tsens )
241+ # Add small epsilon for numerical stability in harmonic mean
242+ cl_dice : torch .Tensor = 1.0 - 2.0 * (tprec * tsens ) / (tprec + tsens + self .smooth )
243+
244+ # Apply reduction
245+ if self .reduction == LossReduction .MEAN .value :
246+ cl_dice = torch .mean (cl_dice )
247+ elif self .reduction == LossReduction .SUM .value :
248+ cl_dice = torch .sum (cl_dice )
249+ elif self .reduction == LossReduction .NONE .value :
250+ pass # keep per-batch values
251+ else :
252+ raise ValueError (f'Unsupported reduction: { self .reduction } , available options are ["mean", "sum", "none"].' )
253+
146254 return cl_dice
147255
148256
149257class SoftDiceclDiceLoss (_Loss ):
150258 """
151- Compute the Soft clDice loss defined in:
259+ Compute both Dice loss and clDice loss, and return the weighted sum of these two losses.
260+ The details of Dice loss is shown in ``monai.losses.DiceLoss``.
261+ The details of clDice loss is shown in ``monai.losses.SoftclDiceLoss``.
152262
263+ Adapted from:
153264 Shit et al. (2021) clDice -- A Novel Topology-Preserving Loss Function
154265 for Tubular Structure Segmentation. (https://arxiv.org/abs/2003.07311)
155266
156- Adapted from:
157- https://github.com/jocpae/clDice/blob/master/cldice_loss/pytorch/cldice.py#L38
158267 """
159268
160- def __init__ (self , iter_ : int = 3 , alpha : float = 0.5 , smooth : float = 1.0 ) -> None :
269+ def __init__ (
270+ self ,
271+ iter_ : int = 3 ,
272+ alpha : float = 0.5 ,
273+ smooth_nr : float = 1.0 ,
274+ smooth_dr : float = 1.0 ,
275+ smooth : float = 1e-4 ,
276+ include_background : bool = True ,
277+ to_onehot_y : bool = False ,
278+ sigmoid : bool = False ,
279+ softmax : bool = False ,
280+ other_act : Callable | None = None ,
281+ reduction : LossReduction | str = LossReduction .MEAN ,
282+ ) -> None :
161283 """
162284 Args:
163- iter_: Number of iterations for skeletonization. Defaults to 3.
164- alpha: Weighing factor for cldice. Defaults to 0.5.
165- smooth: Smoothing parameter. Defaults to 1.0.
285+ iter_: Number of iterations for skeletonization, used by clDice. Must be a non-negative integer. Defaults to 3.
286+ alpha: Weighing factor for cldice component. Total loss = (1 - alpha) * dice + alpha * cldice.
287+ Defaults to 0.5.
288+ smooth_nr: a small constant added to the numerator to avoid zero, used by both Dice and clDice. Defaults to 1.0.
289+ smooth_dr: a small constant added to the denominator to avoid nan, used by both Dice and clDice. Defaults to 1.0.
290+ smooth: a small constant added to the denominator of the harmonic mean in clDice to avoid nan.
291+ Defaults to 1e-4. Note: This differs from standalone DiceLoss defaults (1e-5) to follow clDice convention.
292+ include_background: if False, channel index 0 (background category) is excluded from the calculation.
293+ if the non-background segmentations are small compared to the total image size they can get overwhelmed
294+ by the signal from the background so excluding it in such cases helps convergence.
295+ to_onehot_y: whether to convert the ``target`` into the one-hot format,
296+ using the number of classes inferred from `input` (``input.shape[1]``). Defaults to False.
297+ sigmoid: if True, apply a sigmoid function to the prediction.
298+ softmax: if True, apply a softmax function to the prediction.
299+ other_act: callable function to execute other activation layers, Defaults to ``None``. for example:
300+ ``other_act = torch.tanh``.
301+ reduction: {``"none"``, ``"mean"``, ``"sum"``}
302+ Specifies the reduction to apply to the output. Defaults to ``"mean"``.
303+
304+ - ``"none"``: no reduction will be applied.
305+ - ``"mean"``: the sum of the output will be divided by the number of elements in the output.
306+ - ``"sum"``: the output will be summed.
307+
308+ Raises:
309+ TypeError: When ``other_act`` is not an ``Optional[Callable]``.
310+ ValueError: When ``alpha`` is not in ``[0, 1]``.
311+ ValueError: When more than 1 of [``sigmoid=True``, ``softmax=True``, ``other_act is not None``].
312+ Incompatible values.
313+
166314 """
167315 super ().__init__ ()
168- self .iter = iter_
169- self .smooth = smooth
170- self .alpha = alpha
171-
172- def forward (self , y_true : torch .Tensor , y_pred : torch .Tensor ) -> torch .Tensor :
173- dice = soft_dice (y_true , y_pred , self .smooth )
174- skel_pred = soft_skel (y_pred , self .iter )
175- skel_true = soft_skel (y_true , self .iter )
176- tprec = (torch .sum (torch .multiply (skel_pred , y_true )[:, 1 :, ...]) + self .smooth ) / (
177- torch .sum (skel_pred [:, 1 :, ...]) + self .smooth
316+ if not 0.0 <= alpha <= 1.0 :
317+ raise ValueError (f"alpha must be in [0, 1] but got { alpha } ." )
318+ self .dice = DiceLoss (
319+ include_background = include_background ,
320+ to_onehot_y = False ,
321+ sigmoid = sigmoid ,
322+ softmax = softmax ,
323+ other_act = other_act ,
324+ reduction = reduction ,
325+ smooth_nr = smooth_nr ,
326+ smooth_dr = smooth_dr ,
178327 )
179- tsens = (torch .sum (torch .multiply (skel_true , y_pred )[:, 1 :, ...]) + self .smooth ) / (
180- torch .sum (skel_true [:, 1 :, ...]) + self .smooth
328+ self .cldice = SoftclDiceLoss (
329+ iter_ = iter_ ,
330+ smooth_nr = smooth_nr ,
331+ smooth_dr = smooth_dr ,
332+ smooth = smooth ,
333+ include_background = include_background ,
334+ to_onehot_y = False ,
335+ sigmoid = sigmoid ,
336+ softmax = softmax ,
337+ other_act = other_act ,
338+ reduction = reduction ,
181339 )
182- cl_dice = 1.0 - 2.0 * (tprec * tsens ) / (tprec + tsens )
183- total_loss : torch .Tensor = (1.0 - self .alpha ) * dice + self .alpha * cl_dice
340+ self .alpha = alpha
341+ self .to_onehot_y = to_onehot_y
342+
343+ @deprecated_arg ("y_pred" , since = "1.5" , removed = "1.8" , new_name = "input" , msg_suffix = "please use `input` instead." )
344+ @deprecated_arg ("y_true" , since = "1.5" , removed = "1.8" , new_name = "target" , msg_suffix = "please use `target` instead." )
345+ def forward (self , input : torch .Tensor , target : torch .Tensor ) -> torch .Tensor :
346+ """
347+ Args:
348+ input: the shape should be BNH[WD], where N is the number of classes.
349+ target: the shape should be BNH[WD] or B1H[WD], where N is the number of classes.
350+
351+ Raises:
352+ ValueError: When number of dimensions for input and target are different.
353+ ValueError: When number of channels for target is neither 1 nor the same as input.
354+
355+ """
356+ if input .dim () != target .dim ():
357+ raise ValueError (
358+ f"the number of dimensions for input and target should be the same, got shape { input .shape } and { target .shape } ."
359+ )
360+
361+ if target .shape [1 ] != 1 and target .shape [1 ] != input .shape [1 ]:
362+ raise ValueError (
363+ f"number of channels for target is neither 1 nor the same as input, got shape { input .shape } and { target .shape } ."
364+ )
365+
366+ if self .to_onehot_y :
367+ n_pred_ch = input .shape [1 ]
368+ if n_pred_ch == 1 :
369+ warnings .warn ("single channel prediction, `to_onehot_y=True` ignored." , stacklevel = 2 )
370+ else :
371+ target = one_hot (target , num_classes = n_pred_ch )
372+
373+ dice_loss = self .dice (input , target )
374+ cldice_loss = self .cldice (input , target )
375+ total_loss : torch .Tensor = (1.0 - self .alpha ) * dice_loss + self .alpha * cldice_loss
376+
184377 return total_loss
0 commit comments