Skip to content

Commit 5da2472

Browse files
txmed82ericspod
andauthored
Add BoundaryLoss (#8916)
Added BoundaryLoss. - Handles 2D/3D seg - Supports sigmoid, softmax, other_act, include_background, batch - Does empty masks without producing arbitrary distance ramps - Added tests for shape handling, reductions, gradient flow, single channel warnings, degenerative mask & channel free targets Verified with tests including run tests.sh Closes #8884 Signed-off-by: Colin Son <txmed82@users.noreply.github.com> Co-authored-by: Colin Son <txmed82@users.noreply.github.com> Co-authored-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com>
1 parent 3a458fe commit 5da2472

4 files changed

Lines changed: 573 additions & 0 deletions

File tree

docs/source/losses.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ Segmentation Losses
7878
.. autoclass:: BarlowTwinsLoss
7979
:members:
8080

81+
`BoundaryLoss`
82+
~~~~~~~~~~~~~~
83+
.. autoclass:: BoundaryLoss
84+
:members:
85+
8186
`HausdorffDTLoss`
8287
~~~~~~~~~~~~~~~~~
8388
.. autoclass:: HausdorffDTLoss

monai/losses/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .adversarial_loss import PatchAdversarialLoss
1515
from .aucm_loss import AUCMLoss
1616
from .barlow_twins import BarlowTwinsLoss
17+
from .boundary_loss import BoundaryLoss
1718
from .cldice import SoftclDiceLoss, SoftDiceclDiceLoss
1819
from .contrastive import ContrastiveLoss
1920
from .deform import BendingEnergyLoss, DiffusionLoss

monai/losses/boundary_loss.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
# Copyright (c) MONAI Consortium
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
# Unless required by applicable law or agreed to in writing, software
7+
# distributed under the License is distributed on an "AS IS" BASIS,
8+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
# See the License for the specific language governing permissions and
10+
# limitations under the License.
11+
12+
from __future__ import annotations
13+
14+
import warnings
15+
from collections.abc import Callable
16+
17+
import torch
18+
from torch.nn.modules.loss import _Loss
19+
20+
from monai.networks import one_hot
21+
from monai.transforms.utils import distance_transform_edt
22+
from monai.utils import LossReduction
23+
24+
__all__ = ["BoundaryLoss"]
25+
26+
27+
class BoundaryLoss(_Loss):
28+
"""
29+
Compute the boundary loss for highly unbalanced segmentation.
30+
31+
The boundary loss is a distance-based loss that operates on the interface between segmentation
32+
regions rather than on the regions themselves. This makes it particularly effective for
33+
highly imbalanced segmentation tasks (e.g., small lesions, thin structures), where standard
34+
Dice or Cross-Entropy losses struggle due to foreground-background imbalance.
35+
36+
The loss is formulated as a pixel-wise weighted sum of predicted probabilities and a
37+
signed distance map derived from the ground truth. The signed distance map is negative
38+
inside the foreground region and positive outside, with zero on the boundary.
39+
40+
The data `input` (BNHW[D] where N is number of classes) is compared with ground truth `target`
41+
(BNHW[D]).
42+
Note that axis N of `input` is expected to be logits or probabilities for each class, if passing logits as input,
43+
must set `sigmoid=True` or `softmax=True`, or specifying `other_act`. And the same axis of `target`
44+
can be 1 or N (one-hot format).
45+
46+
The original paper:
47+
Kervadec, H. et al. (2019) Boundary loss for highly unbalanced segmentation. MIDL 2019.
48+
https://arxiv.org/abs/1812.07032
49+
50+
Example:
51+
>>> import torch
52+
>>> from monai.losses import BoundaryLoss
53+
>>> B, C, H, W = 2, 3, 5, 5
54+
>>> input = torch.rand(B, C, H, W)
55+
>>> target = torch.randint(0, C, size=(B, H, W))
56+
>>> bl = BoundaryLoss(softmax=True, to_onehot_y=True)
57+
>>> loss = bl(input, target)
58+
"""
59+
60+
def __init__(
61+
self,
62+
include_background: bool = True,
63+
to_onehot_y: bool = False,
64+
sigmoid: bool = False,
65+
softmax: bool = False,
66+
other_act: Callable | None = None,
67+
reduction: LossReduction | str = LossReduction.MEAN,
68+
batch: bool = False,
69+
) -> None:
70+
"""
71+
Args:
72+
include_background: if False, channel index 0 (background category) is excluded from the calculation.
73+
if the non-background segmentations are small compared to the total image size they can get overwhelmed
74+
by the signal from the background so excluding it in such cases helps convergence.
75+
to_onehot_y: whether to convert the ``target`` into the one-hot format,
76+
using the number of classes inferred from `input` (``input.shape[1]``). Defaults to False.
77+
sigmoid: if True, apply a sigmoid function to the prediction.
78+
softmax: if True, apply a softmax function to the prediction.
79+
other_act: callable function to execute other activation layers, Defaults to ``None``. for example:
80+
``other_act = torch.tanh``.
81+
reduction: {``"none"``, ``"mean"``, ``"sum"``}
82+
Specifies the reduction to apply to the output. Defaults to ``"mean"``.
83+
84+
- ``"none"``: no reduction will be applied.
85+
- ``"mean"``: the sum of the output will be divided by the number of elements in the output.
86+
- ``"sum"``: the output will be summed.
87+
batch: whether to compute the distance map and loss over the batch dimension before the dividing.
88+
Defaults to False, a boundary loss value is computed independently from each item in the batch
89+
before any `reduction`.
90+
91+
Raises:
92+
TypeError: When ``other_act`` is not an ``Optional[Callable]``.
93+
ValueError: When more than 1 of [``sigmoid=True``, ``softmax=True``, ``other_act is not None``].
94+
Incompatible values.
95+
"""
96+
super().__init__(reduction=LossReduction(reduction).value)
97+
if other_act is not None and not callable(other_act):
98+
raise TypeError(f"other_act must be None or callable but is {type(other_act).__name__}.")
99+
if int(sigmoid) + int(softmax) + int(other_act is not None) > 1:
100+
raise ValueError("Incompatible values: more than 1 of [sigmoid=True, softmax=True, other_act is not None].")
101+
102+
self.include_background = include_background
103+
self.to_onehot_y = to_onehot_y
104+
self.sigmoid = sigmoid
105+
self.softmax = softmax
106+
self.other_act = other_act
107+
self.batch = batch
108+
109+
@torch.no_grad()
110+
def compute_distance_map(self, target: torch.Tensor) -> torch.Tensor:
111+
"""
112+
Compute the signed distance map for each class in the target.
113+
114+
The signed distance map is negative inside the foreground region and positive outside,
115+
with zero on the boundary.
116+
117+
Args:
118+
target: target tensor of shape BNHW[D], with values in {0, 1} (one-hot encoded).
119+
120+
Returns:
121+
Signed distance map of the same shape as target.
122+
"""
123+
if target.dim() not in (4, 5):
124+
raise ValueError("Only 2D (BNHW) and 3D (BNHWD) supported")
125+
126+
distance_map = torch.zeros_like(target, dtype=torch.float32)
127+
128+
for batch_idx in range(target.shape[0]):
129+
for channel_idx in range(target.shape[1]):
130+
mask = target[batch_idx, channel_idx : channel_idx + 1] > 0.5
131+
132+
# Empty or full masks do not have a foreground/background interface.
133+
if not mask.any() or mask.all():
134+
continue
135+
136+
fg_dist: torch.Tensor = distance_transform_edt(mask) # type: ignore
137+
bg_dist: torch.Tensor = distance_transform_edt(~mask) # type: ignore
138+
139+
signed = torch.zeros_like(mask, dtype=torch.float32)
140+
signed[mask] = -(fg_dist[mask].to(torch.float32) - 1)
141+
signed[~mask] = bg_dist[~mask].to(torch.float32)
142+
143+
distance_map[batch_idx, channel_idx] = signed[0]
144+
145+
return distance_map
146+
147+
def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
148+
"""
149+
Args:
150+
input: the shape should be BNHW[D], where N is the number of classes.
151+
target: the shape should be BNHW[D] or B1HW[D], where N is the number of classes.
152+
153+
Raises:
154+
ValueError: If the input is not 2D (BNHW) or 3D (BNHWD).
155+
AssertionError: When input and target (after one hot transform if set)
156+
have different shapes.
157+
ValueError: When ``self.reduction`` is not one of ["mean", "sum", "none"].
158+
159+
Example:
160+
>>> import torch
161+
>>> from monai.losses import BoundaryLoss
162+
>>> B, C, H, W = 2, 3, 5, 5
163+
>>> input = torch.rand(B, C, H, W)
164+
>>> target_idx = torch.randint(0, C, size=(B, H, W)).long()
165+
>>> target = one_hot(target_idx[:, None, ...], num_classes=C)
166+
>>> bl = BoundaryLoss(softmax=True)
167+
>>> loss = bl(input, target)
168+
"""
169+
if input.dim() not in (4, 5):
170+
raise ValueError("Only 2D (BNHW) and 3D (BNHWD) supported")
171+
172+
n_pred_ch = input.shape[1]
173+
174+
# Apply activation to input
175+
if self.sigmoid:
176+
input = torch.sigmoid(input)
177+
178+
if self.softmax:
179+
if n_pred_ch == 1:
180+
warnings.warn("single channel prediction, `softmax=True` ignored.", stacklevel=2)
181+
else:
182+
input = torch.softmax(input, dim=1)
183+
184+
if self.other_act is not None:
185+
input = self.other_act(input)
186+
187+
# Convert target to one-hot if needed
188+
if self.to_onehot_y:
189+
if n_pred_ch == 1:
190+
warnings.warn("single channel prediction, `to_onehot_y=True` ignored.", stacklevel=2)
191+
else:
192+
if target.dim() == input.dim() - 1:
193+
target = target.unsqueeze(dim=1)
194+
target = one_hot(target, num_classes=n_pred_ch)
195+
196+
# Validate shapes match
197+
if input.shape != target.shape:
198+
raise AssertionError(f"input and target shapes do not match: {input.shape} vs {target.shape}")
199+
200+
# Exclude background if requested
201+
if not self.include_background:
202+
if n_pred_ch == 1:
203+
warnings.warn("single channel prediction, `include_background=False` ignored.", stacklevel=2)
204+
else:
205+
input = input[:, 1:]
206+
target = target[:, 1:]
207+
208+
# Compute signed distance maps from target
209+
distance_map = self.compute_distance_map(target)
210+
211+
# Compute boundary loss: sum over spatial dimensions of (probabilities * distance_map)
212+
# Then average over classes and batch
213+
spatial_axes = list(range(2, input.dim()))
214+
215+
loss = torch.sum(input * distance_map, dim=spatial_axes)
216+
217+
# Normalize by number of pixels per class per batch element
218+
num_pixels = torch.prod(torch.as_tensor(input.shape[2:], device=input.device))
219+
loss = loss / num_pixels
220+
if self.batch:
221+
loss = loss.mean(dim=0)
222+
223+
if self.reduction == LossReduction.MEAN.value:
224+
loss = loss.mean()
225+
elif self.reduction == LossReduction.SUM.value:
226+
loss = loss.sum()
227+
elif self.reduction == LossReduction.NONE.value:
228+
# Return shape (B, C') unless batch=True reduces the batch dimension first.
229+
pass
230+
else:
231+
raise ValueError(f"Unsupported reduction: {self.reduction}")
232+
233+
return loss

0 commit comments

Comments
 (0)