@@ -374,6 +374,53 @@ def compute_channel(self, y_pred: torch.Tensor, y: torch.Tensor) -> torch.Tensor
374374 return torch .tensor (1.0 , device = y_o .device )
375375 return torch .tensor (0.0 , device = y_o .device )
376376
377+ def compute_confusion_dice (self , y_pred : torch .Tensor , y : torch .Tensor , n_pred_ch : int ) -> torch .Tensor :
378+ """
379+ Fast Dice for multi-class label maps. Both ``y_pred`` and ``y`` are single-channel integer class-index maps
380+ with values in ``[0, n_pred_ch)``. A single batched confusion matrix is accumulated with ``torch.bincount``
381+ (one pass over the voxels, independent of the number of classes), and per-class true-positive, prediction and
382+ ground-truth counts are read off its diagonal and margins. This matches :meth:`compute_channel` exactly while
383+ avoiding both the per-channel Python loop and the materialization of one-hot tensors.
384+
385+ Args:
386+ y_pred: predicted class indices with shape (batch_size, 1, spatial_dims...).
387+ y: ground-truth class indices with shape (batch_size, 1, spatial_dims...).
388+ n_pred_ch: number of classes (background included).
389+
390+ Returns:
391+ Per-(batch, class) Dice with shape (batch_size, num_classes_selected), background excluded when
392+ ``include_background`` is False, following the same ``ignore_empty`` convention as the per-channel path.
393+ """
394+ batch_size = y_pred .shape [0 ]
395+ device = y_pred .device
396+ y_pred_flat = y_pred .reshape (batch_size , - 1 ).long ()
397+ y_flat = y .reshape (batch_size , - 1 ).long ()
398+ batch_offset = torch .arange (batch_size , device = device ).unsqueeze (1 ) * (n_pred_ch * n_pred_ch )
399+ indices = (batch_offset + y_pred_flat * n_pred_ch + y_flat ).reshape (- 1 )
400+ confusion = torch .bincount (indices , minlength = batch_size * n_pred_ch * n_pred_ch )
401+ confusion = confusion .reshape (batch_size , n_pred_ch , n_pred_ch ).to (torch .float32 )
402+
403+ tp = torch .diagonal (confusion , dim1 = 1 , dim2 = 2 )
404+ pred_sum = confusion .sum (dim = 2 )
405+ y_sum = confusion .sum (dim = 1 )
406+ dice = (2.0 * tp ) / (pred_sum + y_sum )
407+
408+ if self .ignore_empty :
409+ dice = torch .where (y_sum > 0 , dice , torch .tensor (float ("nan" ), device = device , dtype = dice .dtype ))
410+ else :
411+ dice = torch .where (
412+ y_sum == 0 ,
413+ torch .where (
414+ pred_sum == 0 ,
415+ torch .tensor (1.0 , device = device , dtype = dice .dtype ),
416+ torch .tensor (0.0 , device = device , dtype = dice .dtype ),
417+ ),
418+ dice ,
419+ )
420+
421+ first_ch = 0 if self .include_background else 1
422+ return dice [:, first_ch :]
423+
377424 def __call__ (self , y_pred : torch .Tensor , y : torch .Tensor ) -> torch .Tensor | tuple [torch .Tensor , torch .Tensor ]:
378425 """
379426 Compute the metric for the given prediction and ground truth.
@@ -383,6 +430,9 @@ def __call__(self, y_pred: torch.Tensor, y: torch.Tensor) -> torch.Tensor | tupl
383430 the number of channels is inferred from ``y_pred.shape[1]`` when ``num_classes is None``.
384431 y: ground truth with shape (batch_size, num_classes or 1, spatial_dims...).
385432
433+ Returns:
434+ Per-(batch, class) Dice scores, or ``(scores, not_nans)`` when ``get_not_nans`` is True.
435+
386436 Raises:
387437 ValueError: when the shapes of `y_pred` and `y` are not compatible for the per-component computation.
388438 """
@@ -413,13 +463,28 @@ def __call__(self, y_pred: torch.Tensor, y: torch.Tensor) -> torch.Tensor | tupl
413463 "(B, 2, H, W) or (B, 2, D, H, W). "
414464 f"Got y_pred={ tuple (y_pred .shape )} , y={ tuple (y .shape )} ."
415465 )
466+ data = torch .stack (
467+ [
468+ self .compute_cc_dice (y_pred [b ].unsqueeze (0 ), y [b ].unsqueeze (0 )).reshape (- 1 )
469+ for b in range (y_pred .shape [0 ])
470+ ],
471+ dim = 0 ,
472+ ).contiguous ()
473+ f , not_nans = do_metric_reduction (data , self .reduction ) # type: ignore
474+ return (f , not_nans ) if self .get_not_nans else f
475+
476+ # multi-class label maps use the single-pass confusion matrix; all else uses the per-channel loop
477+ label_maps = (
478+ y_pred .shape [1 ] == 1 and y .shape [1 ] == 1 and not y_pred .is_floating_point () and not y .is_floating_point ()
479+ )
480+ if label_maps and n_pred_ch >= 3 and int (torch .max (y_pred .max (), y .max ())) < n_pred_ch :
481+ data = self .compute_confusion_dice (y_pred , y , n_pred_ch )
482+ f , not_nans = do_metric_reduction (data , self .reduction ) # type: ignore
483+ return (f , not_nans ) if self .get_not_nans else f
416484
417- first_ch = 0 if self .include_background and not self . per_component else 1
485+ first_ch = 0 if self .include_background else 1
418486 data = []
419487 for b in range (y_pred .shape [0 ]):
420- if self .per_component :
421- data .append (self .compute_cc_dice (y_pred = y_pred [b ].unsqueeze (0 ), y = y [b ].unsqueeze (0 )).reshape (- 1 ))
422- continue
423488 c_list = []
424489 for c in range (first_ch , n_pred_ch ) if n_pred_ch > 1 else [1 ]:
425490 x_pred = (y_pred [b , 0 ] == c ) if (y_pred .shape [1 ] == 1 ) else y_pred [b , c ].bool ()
0 commit comments