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+
1825
1926def soft_erode (img : torch .Tensor ) -> torch .Tensor : # type: ignore
2027 """
@@ -92,26 +99,6 @@ def soft_skel(img: torch.Tensor, iter_: int) -> torch.Tensor:
9299 return skel
93100
94101
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-
115102class SoftclDiceLoss (_Loss ):
116103 """
117104 Compute the Soft clDice loss defined in:
@@ -121,64 +108,241 @@ class SoftclDiceLoss(_Loss):
121108
122109 Adapted from:
123110 https://github.com/jocpae/clDice/blob/master/cldice_loss/pytorch/cldice.py#L7
111+
112+ The data `input` (BNHW[D] where N is number of classes) is compared with ground truth `target` (BNHW[D]).
113+ Note that axis N of `input` is expected to be logits or probabilities for each class, if passing logits as input,
114+ must set `sigmoid=True` or `softmax=True`, or specifying `other_act`. And the same axis of `target`
115+ can be 1 or N (one-hot format).
116+
124117 """
125118
126- def __init__ (self , iter_ : int = 3 , smooth : float = 1.0 ) -> None :
119+ def __init__ (
120+ self ,
121+ iter_ : int = 3 ,
122+ smooth : float = 1.0 ,
123+ include_background : bool = True ,
124+ to_onehot_y : bool = False ,
125+ sigmoid : bool = False ,
126+ softmax : bool = False ,
127+ other_act : Callable | None = None ,
128+ reduction : LossReduction | str = LossReduction .MEAN ,
129+ ) -> None :
127130 """
128131 Args:
129- iter_: Number of iterations for skeletonization
130- smooth: Smoothing parameter
132+ iter_: Number of iterations for skeletonization.
133+ smooth: Smoothing parameter.
134+ include_background: if False, channel index 0 (background category) is excluded from the calculation.
135+ if the non-background segmentations are small compared to the total image size they can get overwhelmed
136+ by the signal from the background so excluding it in such cases helps convergence.
137+ to_onehot_y: whether to convert the ``target`` into the one-hot format,
138+ using the number of classes inferred from `input` (``input.shape[1]``). Defaults to False.
139+ sigmoid: if True, apply a sigmoid function to the prediction.
140+ softmax: if True, apply a softmax function to the prediction.
141+ other_act: callable function to execute other activation layers, Defaults to ``None``. for example:
142+ ``other_act = torch.tanh``.
143+ reduction: {``"none"``, ``"mean"``, ``"sum"``}
144+ Specifies the reduction to apply to the output. Defaults to ``"mean"``.
145+
146+ - ``"none"``: no reduction will be applied.
147+ - ``"mean"``: the sum of the output will be divided by the number of elements in the output.
148+ - ``"sum"``: the output will be summed.
149+
150+ Raises:
151+ TypeError: When ``other_act`` is not an ``Optional[Callable]``.
152+ ValueError: When more than 1 of [``sigmoid=True``, ``softmax=True``, ``other_act is not None``].
153+ Incompatible values.
154+
131155 """
132- super ().__init__ ()
156+ super ().__init__ (reduction = LossReduction (reduction ).value )
157+ if other_act is not None and not callable (other_act ):
158+ raise TypeError (f"other_act must be None or callable but is { type (other_act ).__name__ } ." )
159+ if int (sigmoid ) + int (softmax ) + int (other_act is not None ) > 1 :
160+ raise ValueError ("Incompatible values: more than 1 of [sigmoid=True, softmax=True, other_act is not None]." )
133161 self .iter = iter_
134162 self .smooth = smooth
163+ self .include_background = include_background
164+ self .to_onehot_y = to_onehot_y
165+ self .sigmoid = sigmoid
166+ self .softmax = softmax
167+ self .other_act = other_act
168+
169+ def forward (self , input : torch .Tensor , target : torch .Tensor ) -> torch .Tensor :
170+ """
171+ Args:
172+ input: the shape should be BNH[WD], where N is the number of classes.
173+ target: the shape should be BNH[WD] or B1H[WD], where N is the number of classes.
174+
175+ Raises:
176+ AssertionError: When input and target (after one hot transform if set)
177+ have different shapes.
178+
179+ """
180+ n_pred_ch = input .shape [1 ]
181+
182+ if self .sigmoid :
183+ input = torch .sigmoid (input )
184+
185+ if self .softmax :
186+ if n_pred_ch == 1 :
187+ warnings .warn ("single channel prediction, `softmax=True` ignored." )
188+ else :
189+ input = torch .softmax (input , dim = 1 )
135190
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
191+ if self .other_act is not None :
192+ input = self .other_act (input )
193+
194+ if self .to_onehot_y :
195+ if n_pred_ch == 1 :
196+ warnings .warn ("single channel prediction, `to_onehot_y=True` ignored." )
197+ else :
198+ target = one_hot (target , num_classes = n_pred_ch )
199+
200+ if not self .include_background :
201+ if n_pred_ch == 1 :
202+ warnings .warn ("single channel prediction, `include_background=False` ignored." )
203+ else :
204+ target = target [:, 1 :]
205+ input = input [:, 1 :]
206+
207+ if target .shape != input .shape :
208+ raise AssertionError (f"ground truth has different shape ({ target .shape } ) from input ({ input .shape } )" )
209+
210+ skel_pred = soft_skel (input , self .iter )
211+ skel_true = soft_skel (target , self .iter )
212+
213+ # Compute per-batch clDice by reducing over channel and spatial dimensions
214+ # reduce_axis includes all dimensions except batch (dim 0)
215+ reduce_axis : list [int ] = list (range (1 , len (input .shape )))
216+
217+ tprec = (torch .sum (torch .multiply (skel_pred , target ), dim = reduce_axis ) + self .smooth ) / (
218+ torch .sum (skel_pred , dim = reduce_axis ) + self .smooth
141219 )
142- tsens = (torch .sum (torch .multiply (skel_true , y_pred )[:, 1 :, ...] ) + self .smooth ) / (
143- torch .sum (skel_true [:, 1 :, ...] ) + self .smooth
220+ tsens = (torch .sum (torch .multiply (skel_true , input ), dim = reduce_axis ) + self .smooth ) / (
221+ torch .sum (skel_true , dim = reduce_axis ) + self .smooth
144222 )
145223 cl_dice : torch .Tensor = 1.0 - 2.0 * (tprec * tsens ) / (tprec + tsens )
224+
225+ # Apply reduction
226+ if self .reduction == LossReduction .MEAN .value :
227+ cl_dice = torch .mean (cl_dice )
228+ elif self .reduction == LossReduction .SUM .value :
229+ cl_dice = torch .sum (cl_dice )
230+ elif self .reduction == LossReduction .NONE .value :
231+ pass # keep per-batch values
232+ else :
233+ raise ValueError (f'Unsupported reduction: { self .reduction } , available options are ["mean", "sum", "none"].' )
234+
146235 return cl_dice
147236
148237
149238class SoftDiceclDiceLoss (_Loss ):
150239 """
151- Compute the Soft clDice loss defined in:
240+ Compute both Dice loss and clDice loss, and return the weighted sum of these two losses.
241+ The details of Dice loss is shown in ``monai.losses.DiceLoss``.
242+ The details of clDice loss is shown in ``monai.losses.SoftclDiceLoss``.
152243
244+ Adapted from:
153245 Shit et al. (2021) clDice -- A Novel Topology-Preserving Loss Function
154246 for Tubular Structure Segmentation. (https://arxiv.org/abs/2003.07311)
155247
156- Adapted from:
157- https://github.com/jocpae/clDice/blob/master/cldice_loss/pytorch/cldice.py#L38
158248 """
159249
160- def __init__ (self , iter_ : int = 3 , alpha : float = 0.5 , smooth : float = 1.0 ) -> None :
250+ def __init__ (
251+ self ,
252+ iter_ : int = 3 ,
253+ alpha : float = 0.5 ,
254+ smooth : float = 1.0 ,
255+ include_background : bool = True ,
256+ to_onehot_y : bool = False ,
257+ sigmoid : bool = False ,
258+ softmax : bool = False ,
259+ other_act : Callable | None = None ,
260+ reduction : LossReduction | str = LossReduction .MEAN ,
261+ ) -> None :
161262 """
162263 Args:
163- iter_: Number of iterations for skeletonization
164- smooth: Smoothing parameter
165- alpha: Weighing factor for cldice
264+ iter_: Number of iterations for skeletonization, used by clDice.
265+ alpha: Weighing factor for cldice component. Total loss = (1 - alpha) * dice + alpha * cldice.
266+ Defaults to 0.5.
267+ smooth: Smoothing parameter, used by both Dice and clDice.
268+ include_background: if False, channel index 0 (background category) is excluded from the calculation.
269+ if the non-background segmentations are small compared to the total image size they can get overwhelmed
270+ by the signal from the background so excluding it in such cases helps convergence.
271+ to_onehot_y: whether to convert the ``target`` into the one-hot format,
272+ using the number of classes inferred from `input` (``input.shape[1]``). Defaults to False.
273+ sigmoid: if True, apply a sigmoid function to the prediction.
274+ softmax: if True, apply a softmax function to the prediction.
275+ other_act: callable function to execute other activation layers, Defaults to ``None``. for example:
276+ ``other_act = torch.tanh``.
277+ reduction: {``"none"``, ``"mean"``, ``"sum"``}
278+ Specifies the reduction to apply to the output. Defaults to ``"mean"``.
279+
280+ - ``"none"``: no reduction will be applied.
281+ - ``"mean"``: the sum of the output will be divided by the number of elements in the output.
282+ - ``"sum"``: the output will be summed.
283+
284+ Raises:
285+ TypeError: When ``other_act`` is not an ``Optional[Callable]``.
286+ ValueError: When more than 1 of [``sigmoid=True``, ``softmax=True``, ``other_act is not None``].
287+ Incompatible values.
288+
166289 """
167290 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
291+ self .dice = DiceLoss (
292+ include_background = include_background ,
293+ to_onehot_y = False ,
294+ sigmoid = sigmoid ,
295+ softmax = softmax ,
296+ other_act = other_act ,
297+ reduction = reduction ,
298+ smooth_nr = smooth ,
299+ smooth_dr = smooth ,
178300 )
179- tsens = (torch .sum (torch .multiply (skel_true , y_pred )[:, 1 :, ...]) + self .smooth ) / (
180- torch .sum (skel_true [:, 1 :, ...]) + self .smooth
301+ self .cldice = SoftclDiceLoss (
302+ iter_ = iter_ ,
303+ smooth = smooth ,
304+ include_background = include_background ,
305+ to_onehot_y = False ,
306+ sigmoid = sigmoid ,
307+ softmax = softmax ,
308+ other_act = other_act ,
309+ reduction = reduction ,
181310 )
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
311+ self .alpha = alpha
312+ self .to_onehot_y = to_onehot_y
313+
314+ def forward (self , input : torch .Tensor , target : torch .Tensor ) -> torch .Tensor :
315+ """
316+ Args:
317+ input: the shape should be BNH[WD], where N is the number of classes.
318+ target: the shape should be BNH[WD] or B1H[WD], where N is the number of classes.
319+
320+ Raises:
321+ ValueError: When number of dimensions for input and target are different.
322+ ValueError: When number of channels for target is neither 1 nor the same as input.
323+
324+ """
325+ if input .dim () != target .dim ():
326+ raise ValueError (
327+ "the number of dimensions for input and target should be the same, "
328+ f"got shape { input .shape } and { target .shape } ."
329+ )
330+
331+ if target .shape [1 ] != 1 and target .shape [1 ] != input .shape [1 ]:
332+ raise ValueError (
333+ "number of channels for target is neither 1 nor the same as input, "
334+ f"got shape { input .shape } and { target .shape } ."
335+ )
336+
337+ if self .to_onehot_y :
338+ n_pred_ch = input .shape [1 ]
339+ if n_pred_ch == 1 :
340+ warnings .warn ("single channel prediction, `to_onehot_y=True` ignored." )
341+ else :
342+ target = one_hot (target , num_classes = n_pred_ch )
343+
344+ dice_loss = self .dice (input , target )
345+ cldice_loss = self .cldice (input , target )
346+ total_loss : torch .Tensor = (1.0 - self .alpha ) * dice_loss + self .alpha * cldice_loss
347+
184348 return total_loss
0 commit comments