返回列表

14th place solution

575. RSNA 2023 Abdominal Trauma Detection | rsna-2023-abdominal-trauma-detection

开始: 2023-07-26 结束: 2023-10-15 医学影像分析 数据算法赛
第14名解决方案

第14名解决方案

作者:Gunes Evitan

排名:第14名

发布时间:2023年10月16日

概述

我使用了高效的数据预处理流程和小型的多任务模型,采用单阶段框架。我没有使用图像级标签和分割掩码,因为我忘记它们已经给出 🤦‍♂️。

数据集

2D数据集

  • 使用DICOM的bits allocated和stored属性进行位移动
  • 使用DICOM的rescale slope和intercept属性进行线性像素值重缩放
  • 使用DICOM的window width和center属性进行窗宽窗位调整(腹部软组织窗:宽度400,中心50)
  • 调整最小像素值为0,并使用新的最大值缩放像素值
  • 如果DICOM的photometric interpretation属性为MONOCHROME1,则反转像素值
  • 将像素值乘以255并转换为uint8类型
  • 以原始尺寸将图像写入无损png格式

我的2D和3D数据集管道是分开的,因为这部分可以通过非阻塞IO非常快速地并行运行。我可以在大约20分钟内将所有训练DICOM导出为png。

3D数据集

我将训练集中的许多不同CT扫描保存为视频并进行检查。我注意到它们在z维度上的起点和终点都不同。有些从肩膀开始,到大腿之前结束;有些从肺部开始,到股骨中部附近结束。

我研究了解剖结构并决定定位ROI。我在轴向平面上手动标注了最大轮廓周围的边界框。我将肝脏之前的切片标记为"upper",股骨头之后的切片标记为"lower"。这两个位置之间的切片标记为"abdominal"。我训练了一个YOLOv8 nano模型,它在所有类别上都能轻松达到0.99x mAP@50。我丢弃了预测为"upper"和"lower"的切片,并使用预测为"abdominal"的切片和预测的边界框进行裁剪。

最终,我放弃了这种方法,因为它太慢了,而且完全没有提高我的整体分数。在我最新的3D管道中,我使用轻量级定位,只需在轴向平面上裁剪最大轮廓并保留z维度上的所有切片。

  • 读取扫描中所有导出为png的图像,并在z轴上堆叠
  • 根据DICOM的image position patient z属性按降序对z轴排序
  • 如果DICOM的patient position属性为HFS(head first supine),则翻转x轴
  • 丢弃部分切片(扫描开始或结束处的某些切片部分为黑色)

我通过计算z轴上所有黑色垂直线及其差异来丢弃这些切片。正常切片有0-5条全黑垂直线。如果全黑垂直线数量突然增加或减少,则该切片为部分切片。

# 通过计算所有零垂直线的和来查找部分切片
if scan.shape[0] != 1:
    scan_all_zero_vertical_line_transitions = np.diff(np.all(scan == 0, axis=1).sum(axis=1))
    # 启发式地选择z轴上的高和低转换并丢弃它们
    slices_with_all_zero_vertical_lines = (scan_all_zero_vertical_line_transitions > 5) | (scan_all_zero_vertical_line_transitions < -5)
    slices_with_all_zero_vertical_lines = np.append(slices_with_all_zero_vertical_lines, slices_with_all_zero_vertical_lines[-1])
    scan = scan[~slices_with_all_zero_vertical_lines]
    del scan_all_zero_vertical_line_transitions, slices_with_all_zero_vertical_lines
  • 在轴向平面上裁剪最大轮廓

我没有对每张图像分别进行此操作,因为这会破坏切片的对齐。我计算了每个切片的边界框,并通过取起点最小值和终点最大值来计算最大边界框。

# 裁剪最大轮廓
largest_contour_bounding_boxes = np.array([dicom_utilities.get_largest_contour(image) for image in scan])
largest_contour_bounding_box = [
    int(largest_contour_bounding_boxes[:, 0].min()),
    int(largest_contour_bounding_boxes[:, 1].min()),
    int(largest_contour_bounding_boxes[:, 2].max()),
    int(largest_contour_bounding_boxes[:, 3].max()),
]
scan = scan[
    :,
    largest_contour_bounding_box[1]:largest_contour_bounding_box[3] + 1,
    largest_contour_bounding_box[0]:largest_contour_bounding_box[2] + 1,
]
  • 沿3个平面裁剪非零切片
# 沿xz、yz和xy平面裁剪非零切片
mmin = np.array((scan > 0).nonzero()).min(axis=1)
mmax = np.array((scan > 0).nonzero()).max(axis=1)
scan = scan[
    mmin[0]:mmax[0] + 1,
    mmin[1]:mmax[1] + 1,
    mmin[2]:mmax[2] + 1
]
  • 使用区域插值将3D体积调整为96x256x256
  • 将图像写入numpy数组文件

总之,这些numpy数组用作模型输入。我未能在此阶段受益于并行执行。

验证

我使用多标签分层分组k折交叉验证。通过按患者级别分割可以实现分组功能。我将独热编码的类转换为序数编码的单个列,并创建了患者扫描次数的另一列。我将数据集分为5折,使用5个序数编码的目标列+患者扫描次数列进行分层。

模型

我尝试了许多不同的模型、头部和颈部,但两个简单模型表现最佳。

MIL-like 2D多任务分类模型

这个模型非常简单,类似于MIL方法,讽刺的是它是我表现最好的模型。架构如下:

  1. 在2D切片上提取特征
  2. 在z维度上进行平均或最大池化
  3. 在x和y维度上进行平均、最大、gem或注意力池化
  4. Dropout
  5. 每个目标有5个分类头

RNN 2D多任务分类模型

这个模型类似于在之前的比赛中其他人使用的模型。架构如下:

  1. 在2D切片上提取特征
  2. 在x和y维度上进行平均、最大或gem池化
  3. 使用z维度作为序列的双向LSTM或GRU最大池化
  4. Dropout
  5. 每个目标有5个分类头

骨干网络、颈部和头部

  • 我尝试了timm和monai的许多骨干网络,但最佳的是EfficientNet b0、EfficientNet v2 tiny和DenseNet121。我认为我无法让大型模型收敛。
  • 我还尝试了许多不同的池化类型,包括平均、求和、logsumexp、最大、gem、注意力,但平均和注意力对第一个模型效果最好,最大对第二个模型效果最好。
  • 我只使用了5个常规分类头用于5个目标
    • n_features x 1 肠道头 + 推理时sigmoid
    • n_features x 1 渗漏头 + 推理时sigmoid
    • n_features x 3 肾脏头 + 推理时softmax
    • n_features x 3 肝脏头 + 推理时softmax
    • n_features x 3 脾脏头 + 推理时softmax

训练

我对肠道和渗漏头使用BCEWithLogitsLoss,对肾脏、肝脏和脾脏权重使用CrossEntropyLoss。我做的唯一修改是实现完全相同的样本权重:

class SampleWeightedBCEWithLogitsLoss(_WeightedLoss):

    def __init__(self, weight=None, reduction='mean'):

        super(SampleWeightedBCEWithLogitsLoss, self).__init__(weight=weight, reduction=reduction)

        self.weight = weight
        self.reduction = reduction

    def forward(self, inputs, targets, sample_weights):

        loss = F.binary_cross_entropy_with_logits(inputs, targets, reduction='none', weight=self.weight)
        loss = loss * sample_weights

        if self.reduction == 'mean':
            loss = loss.mean()
        elif self.reduction == 'sum':
            loss = loss.sum()

        return loss
class SampleWeightedCrossEntropyLoss(_WeightedLoss):

    def __init__(self, weight=None, reduction='mean'):

        super(SampleWeightedCrossEntropyLoss, self).__init__(weight=weight, reduction=reduction)

        self.weight = weight
        self.reduction = reduction

    def forward(self, inputs, targets, sample_weights):

        loss = F.cross_entropy(inputs, targets, reduction='none', weight=self.weight)
        loss = loss * sample_weights

        if self.reduction == 'mean':
            loss = loss.mean()
        elif self.reduction == 'sum':
            loss = loss.sum()

        return loss

最终损失计算为每个头损失的总和,并对其调用backward。

训练变换包括:

  • 按最大8位像素值缩放
  • 随机X、Y和Z翻转(相互独立)
  • 轴向平面随机90度旋转
  • 轴向平面随机0-45度旋转
  • 直方图均衡化或随机对比度调整
  • 轴向平面随机224x224裁剪
  • 3D cutout

测试变换包括:

  • 按最大8位像素值缩放
  • 轴向平面中心224x224裁剪

根据模型大小使用2或4的批量大小。使用余弦退火学习率调度,以较小的基数和最大学习率探索不同区域。还使用AMP加速训练和正则化。

推理

最终集成使用2个MIL-like模型(efficientnetb0和densenet121)和2个RNN模型(efficientnetb0和efficientnetv2t)。

由于模型使用随机裁剪增强进行训练,测试时输入为中心裁剪。应用4倍TTA(xyz、xy、xz和yz翻转)并平均预测结果。

5折的预测被平均,然后使用sigmoid或softmax函数激活。

后处理

不同目标使用4个模型的不同权重。这些权重通过最小化OOF分数找到。

我按patient_id聚合扫描级别预测并取最大预测值。

我还使用不同的乘数缩放损伤目标预测,这些乘数也通过最小化OOF分数设置。

我的最终集成分数为0.3859,各目标分数如下表所示。我真的很喜欢我的OOF分数与LB分数几乎完美相关的结果。由于稳定的交叉验证,我选择了具有最佳OOF、公共和私有LB分数的提交。

肠道 渗漏 肾脏 肝脏 脾脏 任意
0.1282 0.5070 0.2831 0.4186 0.4736 0.5050
同比赛其他方案