返回列表

5th place solution - 3D interpolation is all you need (updated with code)

596. SenNet + HOA - Hacking the Human Vasculature in 3D | blood-vessel-segmentation

开始: 2023-11-07 结束: 2024-02-06 医学影像分析 数据算法赛
第五名解决方案 - 3D插值足矣(附代码更新)

第五名解决方案 - 3D插值足矣(附代码更新)

作者: Ivan Panshin(Grandmaster)
发布时间: 2024年2月7日

首先,我要向竞赛组织者致以最诚挚的感谢。我参加计算机视觉竞赛的主要原因是出于热爱,尤其是这类医学竞赛。非常高兴能有机会使用如此先进的技术(分辨率令人难以置信)。

验证

起初我认为验证会是一个挑战,因为我们根本没有足够的数据来进行可靠的验证。为了创建无泄漏且与测试集相似的验证方式,我决定以两种方式训练我的模型:

  • 以kidney_1作为训练基础,kidney_3用于验证
  • 以kidney_3作为训练基础,kidney_1用于验证

由于测试集是密集标注的,我只想在密集标注的肾脏上计算指标,这排除了kidney_2的参与。

数据

我一直相信数据是关键。因此我竭尽全力利用组织者在人类器官图谱上提供的额外数据集。

最终,在伪标签的帮助下,我决定使用以下数据:

  • LADAF-2020-31 肾脏
  • LADAF-2020-27 脾脏

换句话说,在检查了脾脏的伪标注后,我发现它们相当不错,应该可以作为一种良好的正则化方法。

此外,我尝试过使用心脏+大脑+肺部数据。然而,我的模型对肺部的预测还算准确,但对心脏和大脑的预测非常糟糕。所以最终我决定只使用肾脏+脾脏。

伪标注

我认为有两点很重要:

第一点:不要立刻伪标注所有内容。为了创建完整的伪标注,我运行了一个4步流程:

  • 在kidney_1上训练,伪标注kidney_2
  • 在kidney_1 + kidney_2上训练,伪标注2020-31肾脏
  • 在kidney_1 + kidney_2 + 2020-31肾脏上训练,伪标注2020-27脾脏
  • 在kidney_1 + kidney_2 + 2020-31肾脏 + 2020-27脾脏上训练

第二点:不要使用硬标签。换句话说,不要对预测结果进行阈值处理。只需使用软标签(预测值经过sigmoid函数转换到[0,1]范围内)进行训练。

损失函数

在语义分割中,我一贯使用的基线损失函数是CE + Dice + Focal。这在本次竞赛中效果很好。然而,由于我们使用表面距离指标,我希望更重视掩码的边界。

  • 无效的方法:开源仓库中找到的损失函数(如Hausdorff距离损失)
  • 在验证集上对Surface Dice、FP和FN效果非常好的方法:边界权重加倍的CE损失

因此最终我决定对大多数模型使用CE_boundaries + Dice + Focal,对单个模型使用CE_boundaries + Twersky + Focal

Twersky更关注FN而非FP,但这一点在下一节会详细说明。

class BoundDiceFocalLoss(torch.nn.modules.loss._Loss):
    def __init__(self, bound_alpha=1.0, bound_weight, dice_weight, focal_weight):
        super().__init__()
        self.bound = EdgeEmphasisLoss(alpha=bound_alpha)
        self.dice = smp.losses.DiceLoss(mode="binary")
        self.focal = smp.losses.FocalLoss(mode="binary")
        self.bound_weight = bound_weight
        self.dice_weight = dice_weight
        self.focal_weight = focal_weight

    def forward(self, preds, gt, boundaries):
        return (
            self.bound_weight * self.bound(preds, gt, boundaries)
            + self.dice_weight * self.dice(preds, gt)
            + self.focal_weight * self.focal(preds, gt)
        )

class EdgeEmphasisLoss(nn.Module):
    def __init__(self, alpha=1.0):
        super(EdgeEmphasisLoss, self).__init__()
        self.alpha = alpha

    def forward(self, inputs, targets, boundaries):
        bce_loss = F.binary_cross_entropy_with_logits(inputs, targets, reduction="none")

        # 应用边界加权
        weighted_loss = bce_loss * (1 + self.alpha * boundaries)

        # 对batch取平均
        return weighted_loss.mean()

预处理

在分析初始模型及其错误后,我意识到最大的问题是FN(假阴性),而不是FP(假阳性)。换句话说,我的模型无法识别出某些掩码,主要是较小的那些。

因此,我决定通过将训练裁剪分辨率从512x512提高到1024x1024来增加分辨率。然而,训练了几个小时后我突然意识到:这并没有太大意义。从512x512到1024x512并不能真正提高分辨率(每个像素代表的实际物理尺寸相同),只是增加了上下文信息,而512x512看起来已经足够大。

相反,我决定采用以下方法:

class UnetUpscale(nn.Module):
    def __init__(
        self, encoder_name, decoder_use_batchnorm, in_channels, classes, upscale_factor, encoder_weights="imagenet"
    ):
        super().__init__()
        self.upscale_factor = upscale_factor

        self.model = Unet(
            encoder_weights=encoder_weights,
            encoder_name=encoder_name,
            decoder_use_batchnorm=decoder_use_batchnorm,
            in_channels=in_channels,
            classes=classes,
        )

    def forward(self, x):
        x = torch.nn.functional.interpolate(
            x, (x.shape[-2] * self.upscale_factor, x.shape[-1] * self.upscale_factor), mode="bilinear"
        )
        x = self.model(x)
        x = torch.nn.functional.interpolate(
            x, (x.shape[-2] // self.upscale_factor, x.shape[-1] // self.upscale_factor), mode="bilinear"
        )
        return x

这个方法效果非常好,我能够清晰地看到CV和LB上的改进。

模型

我只使用了SMP库中的U-Net模型,但搭配了不同的骨干网络。尝试了很多方法,但最终集成决定采用以下模型:

  • effnet_v2_s
  • effnet_v2_m
  • maxvit_base
  • dpn68

Maxvit在512x512裁剪上训练,effnet和dpn在512x512分辨率但使用x2插值的情况下训练。从xy、xz和yz三个轴进行裁剪。推理时,我使用相同分辨率的裁剪,重叠区域为crop_size / 2(即256)。换句话说,就是采用滑动窗口方法。

数据增强强度为中等水平:

return A.Compose(
    [
        A.ShiftScaleRotate(
            p=0.7,
            shift_limit_x=(-0.1, 0.1),
            shift_limit_y=(-0.1, 0.1),
            scale_limit=(-0.25, 0.25),
            rotate_limit=(-25, 25),
            border_mode=cv2.BORDER_CONSTANT,
        ),
        A.RandomBrightnessContrast(
            brightness_limit=(-0.25, 0.25),
            contrast_limit=(-0.25, 0.25),
            p=0.5,
        ),
        A.HorizontalFlip(),
        A.VerticalFlip(),
        A.OneOf(
            [
                A.GridDistortion(border_mode=cv2.BORDER_CONSTANT, distort_limit=0.1),
                A.ElasticTransform(border_mode=cv2.BORDER_CONSTANT),
            ],
            p=0.2,
        ),
        AT.ToTensorV2(),
    ],
)

后处理

我尝试使用cc3d去除小物体,这对弱模型有改进效果,但对集成模型没有明显提升。

私有分辨率

这部分确实很棘手。非常感谢组织者公布测试分辨率。看到组织者在论坛上与参赛者积极互动,我由衷地感到温暖。真的,谢谢你们。

一种方法是不做任何处理:在50um/voxel上训练模型,在63um/voxel上推理。考虑到我使用的基于卷积的骨干网络(除了maxvit)具有一定尺度不变性,并且在验证中使用了尺度增强,这可能有效。

第二种方法是进行重缩放。我认为正确的重缩放方法如下:

if test_kidney == 6:
    private_res = 63.08
    public_res = 50.0
        
    scale = private_res / public_res
        
    d_original, h_original, w_original = test_kidney_image.shape
    test_kidney_image = torch.tensor(test_kidney_image).view(1, 1, d_original, h_original, w_original)
    test_kidney_image = test_kidney_image.to(dtype=torch.float32)
    test_kidney_image = torch.nn.functional.interpolate(test_kidney_image, (
        int(d_original*scale),
        int(h_original*scale),
        int(w_original*scale),
    ), mode='trilinear').squeeze().numpy()

...

d_preds, h_preds, w_preds = preds_ensemble.shape 
preds_ensemble = preds_ensemble.view(1, 1, d_preds, h_preds, w_preds)
preds_ensemble = preds_ensemble.to(dtype=torch.float32)
        
preds_ensemble = torch.nn.functional.interpolate(preds_ensemble, (
    d_original,
    h_original,
    w_original,
), mode='trilinear').squeeze()

因此我们采用3D resize而非2D:将图像从63um(私有)重缩放到50um(公开+CV),计算预测结果,然后再重缩放回63um。虽然使用2D方法也可以,但从理论上讲,那样会导致不同的空间和体素分辨率。

这个技巧确实有帮助。举一个数据点(我没有更多数据):相同的集成模型在不使用插值时在私有集上得分为0.634,使用插值后得分为0.670。

说实话,我没想到会有这么大差别。我在本地尝试了以下实验:

  • 下载25um分辨率的肾脏,在25um下计算预测结果,插值到50um后计算指标。这个方法将我的表面Dice从0.92降至0.895。考虑到这是在所有3个方向上进行x2插值(体积减少为1/8),且在较低分辨率下更难检测小物体,这个结果已经相当不错。
  • 下载25um分辨率的肾脏,将图像插值到50um,计算预测结果和指标。这个方法得到的指标与使用组织者提供的50um数据基本相同。

所以即使我原本不认为插值那么重要,但它也没有造成负面影响(我担心过插值伪影),因此我在两个最终提交中都使用了它。

最终提交

两个提交都包含3个模型的集成,每个模型在全部3个轴上进行推理(未使用TTA,因为TTA耗时太长且在CV上帮助不大)。

  • 第一次提交 CV: 0.84 (kidney_1),Public: 0.768,Private: 0.566
    Maxvit_ce_dice_focal + effnet_v2_s_ce_dice_focal + effnet_v2_m_ce_dice_focal,在kidney_3上训练,在kidney_1上验证。这个方法在CV、Public和Private上表现都不够理想。
  • 第二次提交 CV: 0.923 (kidney_3),Public: 0.855,Private: 0.691
    Maxvit_ce_dice_focal + effnet_v2_s_ce_bounds_dice_focal + dpn_68_ce_bounds_twersky_focal
同比赛其他方案