nnUNet代码底层学习-量化指标

Dice

在图像分割中,通常将图像转换为二值矩阵(前景为 1,背景为 0),此时公式可表示为逐像素的计算形式:

$$ \text{Dice} = \frac{2\sum_{i,j} P_{i,j} \cdot G_{i,j}}{\sum_{i,j} P_{i,j} + \sum_{i,j} G_{i,j}} $$

其中:

  • $P_{i,j}$:预测图像在 $(i,j)$ 位置的像素值。
  • $G_{i,j}$:真实标注图像在 $(i,j)$ 位置的像素值。
  • $\sum_{i,j}$:对图像所有像素进行求和。

在 validation_step 函数和自定义的 evaluate 脚本里的属于 hard dice,预测图是二值化的。而在训练时的是 soft dice,不把概率变成 0 和 1,而是直接用概率值去计算。dice 指标的实际含义就是预测和真值整体的重合率。且 dice 指标与交并比也有互相转化的公式。

IoU

45

语义分割中最常用的度量标准是 IoU,也称为Jaccard系数。对于给定类别,IoU衡量预测分割掩码 (A) 与真实掩码 (B) 之间的重叠程度。IoU分数范围从0(无重叠)到1(完全重叠)。IoU分数越高,表示该类别的分割效果越好。

通常会为每个类别单独计算 IoU,然后对所有类别取平均值,得到平均交并比 (mIoU)。这提供了一个单一的、全面的分数,用于衡量模型在整个数据集或图像上的性能。

HD95

46

HD 代表预测偏离的最大值。但是往往个别的噪点不应该毁了整个模型的评价。因此选用HD95,砍掉前5%的最大距离。当我们调用 hd95 函数时,其实计算逻辑如下:首先,提取轮廓,先把实心的掩码(Mask)变成空心的圈(Contour),只保留边缘像素。然后计算距离图 (Distance Map),对于预测轮廓上的每一个像素,计算它到真值轮廓最近像素的欧式距离。最后,把这些距离收集起来放在一个列表里,排序,取 95% 位置的数值。这里需要注意spacing,计算机算出来是偏离了n个像素,但是不同图的单位像素代表距离不同。

P/R

47

其他指标

VOE:VOE = 1 – IoU,如果计算了IoU,就没必要再算。

mIoU:mIoU 是所有类别 IoU 的平均值。

Hausdorff:HD 不稳定。只要有一个噪点,HD 就会变得无限大,不能反映真实性能。

Surface Dice (NSD):太麻烦且非必须。需要设定一个阈值(比如 1mm 容差)。除非打特定的比赛,否则用 Dice + HD95 代替足够说明表面情况。

Balanced Accuracy & Kappa:过时,深度学习分割领域现在很少用这两个。

RVD (Relative Volume Difference):改为 Volume Error。单纯的 RVD 有正负,平均时会抵消。我们计算体积偏差绝对值更好。

evaluate 脚本计算指标

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
import os
import numpy as np
import nibabel as nib
import pandas as pd
from tqdm import tqdm
from medpy.metric.binary import hd95, asd

# ================= 配置区域 =================
GT_FOLDER = r'/root/nnUNet/nnUNet_raw/Dataset004_Hippocampus/labelsTr'
PRED_FOLDER = r'/root/nnUNet/nnUNet_results/Dataset004_Hippocampus/nnUNetTrainer__nnUNetPlans__2d/fold_4/validation'
OUTPUT_CSV = 'task04_comprehensive_metrics.csv'

# 类别定义
CLASS_NAMES = {1: "Anterior", 2: "Posterior"}
# ===========================================

def calculate_metrics():
    if not os.path.exists(GT_FOLDER) or not os.path.exists(PRED_FOLDER):
        print(" 路径错误,请检查文件夹路径配置!")
        return

    pred_files = [f for f in os.listdir(PRED_FOLDER) if f.endswith('.nii.gz')]
    results = []
    
    print(f" 开始【·指标计算 (共 {len(pred_files)} 个样本)...")

    for filename in tqdm(pred_files):
        gt_path = os.path.join(GT_FOLDER, filename)
        pred_path = os.path.join(PRED_FOLDER, filename)
        
        if not os.path.exists(gt_path):
            continue
            
        # 1. 加载数据
        gt_obj = nib.load(gt_path)
        pred_obj = nib.load(pred_path)
        spacing = gt_obj.header.get_zooms()[:3] 
        
        gt_data = gt_obj.get_fdata().astype(np.uint8)
        pred_data = pred_obj.get_fdata().astype(np.uint8)

        file_metrics = {'filename': filename}

        for class_id, class_name in CLASS_NAMES.items():
            gt_mask = (gt_data == class_id)
            pred_mask = (pred_data == class_id)

            # --- 基础统计 (混淆矩阵) ---
            tp = np.sum(gt_mask & pred_mask)
            fp = np.sum((~gt_mask) & pred_mask)
            fn = np.sum(gt_mask & (~pred_mask))
            
            # --- 1. Overlap Metrics (重叠类) ---
            # Dice = 2TP / (2TP + FP + FN)
            smooth = 1e-5
            dice = (2.0 * tp) / (2.0 * tp + fp + fn + smooth)
            
            # IoU = TP / (TP + FP + FN)
            iou = tp / (tp + fp + fn + smooth)

            # --- 2. Statistical Metrics (统计类) ---
            # Precision = TP / (TP + FP)  (查准率)
            if (tp + fp) > 0:
                precision = tp / (tp + fp)
            else:
                precision = 1.0 if np.sum(gt_mask) == 0 else 0.0

            # Sensitivity (Recall) = TP / (TP + FN) (查全率/敏感度)
            if (tp + fn) > 0:
                sensitivity = tp / (tp + fn)
            else:
                sensitivity = 1.0 # 如果真值本来就是空的,且没预测出来,算对

            # --- 3. Distance Metrics (距离类) ---
            if np.sum(gt_mask) > 0 and np.sum(pred_mask) > 0:
                try:
                    hd95_val = hd95(pred_mask, gt_mask, voxelspacing=spacing)
                    asd_val = asd(pred_mask, gt_mask, voxelspacing=spacing)
                except:
                    hd95_val = np.nan
                    asd_val = np.nan
            else:
                hd95_val = np.nan
                asd_val = np.nan

            file_metrics[f'{class_name}_Dice'] = dice
            file_metrics[f'{class_name}_IoU'] = iou
            file_metrics[f'{class_name}_Precision'] = precision
            file_metrics[f'{class_name}_Sensitivity'] = sensitivity
            file_metrics[f'{class_name}_HD95'] = hd95_val
            file_metrics[f'{class_name}_ASD'] = asd_val

        results.append(file_metrics)

    df = pd.DataFrame(results)
    df.to_csv(OUTPUT_CSV, index=False)
    
    print("\n" + "="*40)
    print(" 评估完成!")
    print("各指标平均值 (Mean):")

    print(df.mean(numeric_only=True).round(4))
    print(f"\n 详细报表: {os.path.abspath(OUTPUT_CSV)}")
    print("="*40)

if __name__ == '__main__':
    calculate_metrics()

Task_04项目的指标结果

48

在 Task04 海马体数据集上,Dice 达到 0.88+ (前部) 和 0.86+ (后部) 是顶级水平。通常这个任务上的 baseline 就在 0.86 - 0.88 之间。

Anterior 的所有指标(Dice, IoU, HD95)都优于 Posterior 。

Anterior Dice: 0.8875 > Posterior Dice: 0.8638

Anterior HD95: 1.28mm < Posterior HD95: 1.40mm

海马体的头部 (Anterior) 结构比较宽大、特征明显;而尾部 (Posterior) 逐渐变细,边界模糊,且容易受到周围组织的干扰。模型在难分区域表现稍弱,证明它没有过拟合,而是真实反映了数据的难度。

对比 Precision (查准率) 和 Sensitivity (查全率)。

Anterior: Precision (0.8973) > Sensitivity (0.8809)

Posterior: Precision (0.8713) > Sensitivity (0.8608)

Precision 高意味着:模型预测是海马体的地方,几乎都是对的(误报少)。

Sensitivity 稍低意味着:模型漏掉了一小部分真实的边缘。

ASD (0.45mm):意味着平均误差只有 0.45 mm。Task04 的图像分辨率大概是 1mm^3。平均误差不到半个像素。

49

Licensed under CC BY-NC-SA 4.0