在处理医疗数据时,会遇到一些不同于处理结构化数据时的预处理难题。某些常见的技术仍然适用,但当数据变为医学图像时,其他技术的应用方式则会发生很大变化。

在本文中,你将学习如何为机器学习准备真实的医学图像数据集,从初始的数据验证到完整的预处理流程。

我们将以胸部X光肺炎数据集作为示例进行讲解,但这些方法同样适用于其他类型的医疗影像数据,包括超声、MRI、CT以及皮肤科图像等。

本文你将学到什么

读完本文后,你将能够:

  • 采用与处理结构化数据不同的方法来进行医疗数据的预处理,并了解标准技术在哪些方面存在局限性

  • 在训练之前对医学图像数据集进行验证,以检测损坏的文件、错误的标签以及训练集和测试集之间的数据泄露问题

  • 应用六种针对医学图像的核心预处理技术

  • 使用Python和OpenCV为胸部X光图像构建一个完整的预处理流程

我们将涵盖的内容:

为什么在医疗领域数据预处理更为重要

想象一下,如果你把一块拼图交给一个幼儿——这块拼图的某些碎片缺失了,边缘也发生了变形,而且其中还混杂着来自三块不同拼图的碎片。这个幼儿肯定无法完成这项任务,但这也并不能真正归咎于他/她。

当原始的、混乱的数据被输入到机器学习模型中时,也会出现同样的情况。如果对一张临床图像的分析结果不准确,就可能会导致诊断失误。

示意图展示了医疗数据预处理的工作流程。大小不一、标签缺失、扫描质量不佳或文件损坏的医学影像在经过预处理后,会变成格式规范、适合模型分析的数据。” height=

医疗数据往往比大多数机器学习从业者习惯处理的数据更加混乱:

  • 这些影像来自不同的设备、医院,采用的采集方式也各不相同

  • 数据的标签并不一致,有时会缺失,有时也会错误

  • 患者的相关信息往往不完整

  • 不同来源的影像在尺寸、对比度以及方向上也存在差异

如果预处理工作做得不好,那么训练出来的模型虽然在基准数据集上的表现不错,但在面对来自不同医院或使用不同设备采集的数据时,就会显得力不从心。

数据集

本指南使用了Paul Mooney在Kaggle平台上提供的胸部X光片肺炎数据集。这个数据集非常适合用来学习预处理技术,因为:

  • 它包含了大约5,800张儿童的胸部X光片

  • 其中只包含两种明确的类别:正常和肺炎

  • 这些数据已经被分成了训练集、验证集和测试集

  • 即使没有接受过专业的医学培训,人们也能看懂这些影像

  • 这个数据集几乎涵盖了所有值得研究的预处理问题

该数据集可以在Kaggle平台上的“胸部X光片肺炎数据集”页面中找到。

文件夹结构

下载完成后,这些数据会按照以下结构进行组织:

chest_xray/
├── train/
│   ├── NORMAL/
│   └── PNEUMONIA/
├── val/
│   ├── NORMAL/
│   └── PNEUMONIA/
└── test/
    ├── NORMAL/
    └── PNEUMONIA/

正常胸部X光片与肺炎胸部X光片的对比图:

两张并排展示的胸部X光片:左图为正常肺部图像,右图为肺炎患者的影像。从图中可以看出,肺炎患者的肺部区域有明显的阴影。” height=

让我们先快速看一下其中一张影像吧:

import os
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
import cv2

DATA_DIR = "chest_xray"
TRAIN_DIR = os.path.join(DATA_DIR, "train")

# 查看一张样本图像
sample_path = os.path.join(TRAIN_DIR, "NORMAL", os.listdir(os.path.join(TRAIN_DIR, "NORMAL"))[0])
sample_image = cv2.imread(sample_path, cv2.IMREADGRAYSCALE)

print(f"图像的尺寸为:{sample_image.shape}")
print(f"像素值的范围为:{sample_image.min()} 到 {sample_image.max()}")
print(f>数据类型为:{sample_image.dtype}

输出结果立即揭示了一些有用的信息:大多数图像的尺寸都很大(通常约为1500×2000像素),像素值介于0到255之间,而且不同数据集中的图像大小也各不相同。这些发现都会为后续的预处理步骤提供参考。

预处理之前:验证数据集

在应用任何转换之前,先检查数据本身是否完整是很有必要的。这个步骤能够及时发现那些否则会导致训练失败或产生错误结果的问题。

下面是一个简单的验证函数:

def validate_dataset(data_dir):
    """扫描数据集文件夹,并标记常见的数据质量问题。」
    corrupted = []
    too_small = []
    nearly_black = []
    total = 0
    
    for class_name in os.listdir(data_dir):
        class_path = os.path.join(data_dir, class_name)
        if not os.path.isdir(class_path):
            continue
        for fname in os.listdir(class_path):
            fpath = os.path.join(class_path, fname)
            total += 1
            try:
                img = cv2.imread(fpath, cv2.IMREADGRAYSCALE)
                if img is None:
                    corrupted.append(fpath)
                    continue
                if img.shape[0] < 100 or img.shape[1] < 100:
                    too_small.append(fpath)
                if img.mean() < 5:
                    nearly_black.append(fpath)
            except Exception:
                corrupted.append(fpath)
    
    print(f"扫描到的文件总数:{total}")
    print(f"损坏的文件数量:{len(corrupted)}")
    print(f"尺寸过小的文件数量:{len(too_small)}")
    print(f"颜色接近黑色的文件数量:{len(nearly_black)}")
    return corrupted, too_small, nearly_black

validate_dataset(TRAIN_DIR)

这个函数能够检测出以下常见问题:

  • 损坏的文件 — 无法正常打开的文件

  • 空白或颜色接近黑色的图像 — 采集失败或被保存为空白文件的图像

  • 尺寸不正确

    — 包含缩略图或部分下载完成的图像

  • 重复的图像 — 训练集和测试集中存在相同的图像(这会导致数据泄露)

  • 标签错误的图像 — 应该属于肺炎类别的X光片被错误地归类到了其他文件夹中

⚠️ 这一步骤至关重要,即使只有一个损坏的文件,也可能会在训练过程中导致程序崩溃;而训练集和测试集中存在一个重复的图像,也可能会使准确率虚增几个百分点,而人们却毫无察觉。

医疗影像预处理的六大核心环节

医学图像的预处理工作可以围绕六个核心方面来进行。其中两个方面与结构化数据的预处理方法完全相同;另外两个方面则需要根据图像数据的特点进行相应的调整;还有两个方面则是专门针对医学影像数据而设计的。

支柱1:缩放——让数值公平地发挥作用

想象有两个孩子在比较他们的收藏品。一个有3个贝壳,另一个则有3000张贴纸。问谁的收藏更多,答案似乎显而易见,但它们的衡量标准截然不同。要想进行有意义的比较,就必须将这两种收藏品放在相同的衡量体系中。

直方图对比显示了图像在缩放前后像素值的变化情况。左边的直方图展示了0–255范围内的数值,而右边的直方图则将相同的分布范围调整到了机器学习所使用的0–1范围内。

解决方法:将每个像素值除以它的最大可能值,从而使所有数值都落在0到1的范围内。

image = cv2.imread(sample_path, cv2.IMREADGRAYSCALE)

# 将图像缩放到[0, 1]的范围
image_scaled = image.astype(np.float32) / 255.0

print(f"缩放前的数值范围:{image.min()} 到 {image.max()}")
print(f"缩放后的数值范围:{image_scaled.min():.3f} 到 {image_scaled.max():.3f}")

要点:像素值的缩放原则与任何数值特征的缩放原则是一样的。只不过这些数值是以图像的形式呈现的,而不是以一列数据的形式出现的而已。

支柱2:标准化——让数据处于中心位置

想象一位老师让全班同学对某部电影打分,评分范围是1到10分。其中一个孩子总是给9分或10分,而另一个孩子则把分数均匀分配在1到10分的范围内。要想公平地比较他们的评价结果,就需要根据每个孩子自己的平均得分来调整他们的最终评分。

在医学图像中,即使已经将像素值缩放到0–1的范围,图像的整体亮度仍然可能存在差异。有些X光片的曝光强度比其他片子更高。标准化操作会移动并重新调整每张图像(或每个通道)的数值,使其均值变为0,标准差为1。

解决方法:先减去平均值,然后再除以标准差。

# 仅从训练数据集中计算均值和标准差——切勿使用验证集或测试集的数据
def compute_train_stats(train_dir, sample_limit=1000):
    """计算训练数据集中的像素均值和标准差。"""
    pixel_values = []
    count = 0
    for class_name in os.listdir(train_dir):
        class_path = os.path.join(train_dir, class_name)
        for fname in os.listdir(class_path):
            if count >= sample_limit:
                break
            img = cv2.imread(os.path.join(class_path, fname), cv2.IMREADGRAYSCALE)
            if img is not None:
                pixel_values.append(img.astype(np.float32).flatten() / 255.0)
                count += 1
    pixels = np.concatenate(pixel_values)
    return pixels.mean(), pixels.std()

train_mean, train_std = compute_train_stats(TRAIN_DIR)
image_normalized = (image_scaled - train_mean) / train_std

⚠️ 避免这个常见的错误:用于标准化的统计数据应该仅从训练集中计算得出,绝不能使用验证集或测试集的数据。如果将这些数据纳入计算过程,就会导致评估数据中的信息被泄露到模型中。因此,在进行推理时,应该对验证集、测试集以及任何新的数据应用相同的统计方法。
总结: 将每张图像的中心和尺度调整到与数据集的统计特征相匹配,这相当于在处理特征列时进行了标准化操作。这样一来,无论每张图像的亮度如何,其中的像素就都可以进行相互比较了。

第三支柱:引导模型的注意力

想象一下,一个孩子走进了一家拥挤的宠物店。父母并没有描述看到的每一只动物,而是指出了那些重要的特征:“看看那柔软的毛发、蓬松的尾巴,还有它小巧的身材。”这样,孩子就学会了该把注意力集中在哪些地方。

医学图像预处理也是类似的做法。它会突出那些与诊断任务最为相关的区域和特征。

  • 感兴趣区域裁剪 —— 专注于肺部区域,同时剔除患者的手臂、机器边框以及任何打印在图像上的文字

  • 对比度增强 —— 使用CLAHE(对比度受限的自适应直方图均衡化)等技术,使肺部的纹理更加清晰可见

  • 通道选择 —— 对于以RGB格式存储但实际包含灰度信息的图像,应将其转换为单通道输入形式,以减少噪声干扰

三幅图展示了特征增强前后胸部X光片的对比效果。第一幅是原始图像,第二幅突出了肺部区域,第三幅则是经过CLAHE处理后的图像,肺部的纹理更加清晰可见。

应用CLAHE处理后的X光片:

# CLAHE能够增强局部对比度,这对处理X光图像非常有用
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
image_enhanced = clahe.apply(image)

# 查看处理前后的差异
fig, axes = plt.subplots(1, 2, figsize=(12, 6))
axes[0].imshow(image, cmap='gray')
axes[0].set_title('原始图像')
axes[1].imshow(image_enhanced, cmap='gray')
axes[1].set_title('经过CLAHE处理后的图像')
plt.show()

总结:教模型该关注哪些内容,这一目标并没有改变。对于结构化数据来说,答案在于新增的列;而对于图像而言,答案则体现在裁剪、增强以及突出那些具有诊断意义的区域上。

第四支柱:处理缺失数据

想象一下,你在读一本有几页损坏的故事书。你不会把整本书都扔掉,而是会决定是跳过这些损坏的页面,还是尝试推测其中可能缺失了什么内容,又或者把这些页面标记出来以便以后再阅读。

在医学影像领域,所谓的“数据缺失”通常指的是文件损坏、标签丢失,或是检查项目不完整,而不是指电子表格中的单元格为空。

那三种处理策略——删除、插补、标记——仍然适用,只不过具体实施方法会有所不同:


# 策略1:删除——移除无法读取或为空的图像
def is_valid_image(path):
try:
img = cv2.imread(path, cv2.IMREADGRAYSCALE)
if img is None:
return False
if img.mean() < 5: # 图像颜色过于接近黑色 return False if img.shape[0] < 50 or img.shape[1] < 50: # 图像尺寸过小 return False return True except Exception: return False # 策略2:插补——这种方法在处理图像数据时较为少见,但在某些情况下也是可行的(例如,在绘画中填补缺失的部分)。不过,对于诊断数据来说,通常应避免使用这种策略。 # 策略3:标记——用于记录哪些患者缺少哪些类型的影像检查,从而让模型能够根据这些信息来调整分析流程。这种方法在多模态医疗机器学习中较为常见。

总结:在医学影像数据中,“缺失”并不一定仅仅表示数值为NaN。它可能是文件损坏、扫描结果未标注、某些类型的影像检查缺失,也可能是图像中的某个区域颜色过深。不过,那三种处理策略仍然适用。

第五章:调整大小与重采样——让所有内容都能适配同一帧画面

想象一下,在教室的墙上展示孩子们的画作。如果这些画作的尺寸各不相同,它们就无法整齐地排列在墙上。因此,我们需要调整它们的大小,同时保持它们的比例关系。

医学影像往往也需要被调整到统一的尺寸,但解剖结构本身应该保持原有的形状。

两种胸部X光图像调整大小的对比:一种方法是将图像拉伸成正方形,导致肺部部分变形;另一种方法是在图像周围添加边框来保持原有的宽高比。显然,后者是更可取的方法。

解决方法:将所有图像调整到相同的尺寸。对于医学数据而言,调整大小的具体方法非常重要。


TARGET_SIZE = (224, 224)

# 简单的调整大小方法(可能会改变宽高比)
image_resized = cv2.resize(image, TARGET_SIZE)

# 更好的方法:通过添加边框来保持宽高比
def resize_with_padding(image, target_size):
h, w = image.shape[:2]
target_h, target_w = target_size
scale = min(target_h / h, target_w / w)
new_h, new_w = int(h * scale), int(w * scale)
resized = cv2.resize(image, (new_w, new_h))

pad_h = target_h - new_h
pad_w = target_w - new_w
top, bottom = pad_h // 2, pad_h - pad_h // 2
left, right = pad_w // 2, pad_w - pad_w // 2
padded = cv2.copyMakeBorder(resized, top, bottom, left, right,
cv2.BORDER CONSTANT, value=0)
return padded

image_clean.resize = resize_with_padding(image, TARGET_SIZE)

⚠️ 为什么宽高比在医疗领域如此重要: 如果将胸部X光图像水平压缩,肺部结构就会显得不自然。那些在扭曲的解剖结构数据上训练出来的模型,在处理真实的医学影像时往往表现较差。因此,保持图像的宽高比通常是一个更安全的选择。

总结: 模型需要输入尺寸一致的数据,但同时必须保留图像的几何结构。进行尺寸调整时必须谨慎操作。

第6章:去噪与伪影处理——清理“视野中的干扰因素”

想象一下,透过一块布满灰尘和污迹的窗户看外面:清洁窗户后视线会变得清晰,但过度擦拭却可能会划伤玻璃。

同样地,医学影像中也常常存在噪声以及各种伪影,这些都需要被谨慎处理,同时不能去除那些对临床诊断至关重要的细节。

对于胸部X光图像来说,最常见的问题就是轻微的噪声以及残留的文字或标记。使用温和的中值滤波器可以消除噪声,而裁剪或遮罩技术则有助于去除这些伪影。

# 温和去噪——注意不要模糊掉重要的临床细节
image_denoised = cv2.medianBlur(image, ksize=3)

# 双边滤波器能更好地保留图像边缘
image_bilateral = cv2.bilateralFilter(image, d=5, sigmaColor=50, sigmaSpace=50)

⚠️ 需要特别注意的一点: 过度强烈的去噪操作可能会抹去模型识别疾病所需的关键信息。对于诊断用途的机器学习来说,温和的去噪方法通常是更合适的选择。一个实用的判断标准是:如果放射科医生无法区分处理后的图像和原始图像,那就说明去噪操作做得太过火了。

总结: 影像数据中存在结构性数据所没有的噪声。虽然可以清理这些干扰因素,但绝对不能过度清洗,否则就会导致有用的信息也被丢失。

整合所有步骤:构建一个完整的预处理流程

工作流程图展示了胸部X光图像如何经过一系列医疗影像预处理环节。图像会依次经历验证、尺寸调整、去噪、对比度增强、缩放和归一化等处理,最终成为适合机器学习的输入数据。

以下是这六个预处理步骤如何组合成一个完整的胸部X光图像预处理流程:

def preprocess_xray(image_path, target_size=(224, 224),
                    train_mean=0.482, train_std=0.236):
    """
    完整的胸部X光图像预处理流程。
    按照规定的顺序执行所有六个步骤。
    """
    # 第4步:首先进行验证——跳过损坏的文件
    if not is_valid_image(image_path):
        return None
    
    image = cv2.imread(image_path, cv2.IMREADGRAYSCALE)
    
    # 第5步:在保持宽高比的情况下调整图像尺寸
    image = resize_with_padding(image, target_size)
    
    # 第6步:温和去噪
    image = cv2.medianBlur(image, 3)
    
    # 第3步:增强对比度,使肺部结构更清晰可见
    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
    image = clahe.apply(image)
    
    # 第1步:将图像缩放到[0, 1]的范围
    image = image.astype(np.float32) / 255.0
    
    # 第2步:利用训练集数据对图像进行归一化处理
    image = (image - train_mean) / train_std
    
    return image

自己动手试试

本文中的每一段代码都被整合成了一个可运行的Kaggle笔记本:胸部X光图像预处理——Kaggle笔记本。你可以克隆这个笔记本,添加数据集,然后运行所有的代码单元,从而了解各种预处理方法在实际胸部X光图像上的应用效果。

结论

以下是我们在本文中讨论的内容总结:

预处理步骤 作用 示例
缩放 标准化像素值范围 0-255 → 0-1
归一化 使亮度分布趋于均匀 z分数归一化
重点区域标注 突出显示诊断相关区域 CLAHE算法
缺失数据处理 删除无法使用的扫描数据 修复损坏的文件
尺寸调整 确保输入数据的统一尺寸 224×224像素
去噪 减少图像中的噪声干扰 中值滤波器

对于结构化数据而言,预处理的目的是让数值信息以统一、清晰的形式呈现出来,从而便于模型进行分析。

在医疗影像数据的预处理过程中,我们需要尊重医学数据在实际采集、存储和标注过程中所呈现的复杂性。有些标准预处理方法可以直接应用,有些则需要根据具体情况进行调整;而还有一些问题只有当数据被转化为人体图像形式后才会显现出来。

无论是一个孩子学习整理玩具箱,还是一个模型学习如何从胸部X光图像中识别肺炎,学习效果的好坏最终都取决于数据准备的质量。必须确保数据的质量符合要求。

如果这篇文章对你有帮助,你可以在这里找到关于数据预处理更全面的概念性介绍:机器学习中的数据预处理

Comments are closed.