在这篇文章中,您将了解到我的团队是如何构建一个合成健康信息的生成流程的,从而为医学影像人工智能系统创建出既安全又符合隐私保护要求的训练数据和验证数据。

## 问题所在

想象一下,您正在开发一个能够从医学图像中去除患者信息的人工智能系统。该模型需要大量的样本来了解健康信息具体出现在哪些位置、以及这些信息的具体表现形式;它看到的样本越多,识别和删除敏感信息的能力就越强。然而,这里存在一个棘手的问题:

**用于训练模型的数据,恰恰就是那些不能被随意共享的数据。**医疗机构有责任保护患者的隐私,像HIPAA这样的法规也要求,在将医学图像用于研究、人工智能开发或外部合作之前,必须先去除患者身份识别信息。这就引发了一个有趣的工程难题:当训练这些系统所需的数据本身就无法被自由使用时,该如何构建和测试去标识化系统呢?

其中一个实用的解决方案就是**合成健康信息**。在这篇文章中,我会解释为什么合成健康信息如此重要,剖析医学图像中隐藏的健康信息问题,并详细介绍我们团队构建的那个能够生成包含完全受控的合成患者信息的真实超声数据集的生成流程。

## 本教程您将学到什么

通过学习本教程,您将会了解以下内容:

– 医学影像数据中存在的那些隐藏的健康信息相关问题;
– 为什么合成健康信息对于构建和测试医疗人工智能系统如此有用;
– 如何使用Python和Faker工具来生成真实的合成患者身份信息;
– 如何将健康信息嵌入到图像像素以及DICOM元数据中;
– 如何为人工智能模型的训练和评估创建真实的数据标签;
– 在将这些合成医学影像数据集用于后续工作流程之前,应该如何对其进行验证。

## 我们将会涵盖的内容

– **原始图像来源:OpenPOCUS**;
– **“冰山问题”:大部分健康信息其实都是隐藏的**;
– **为什么合成健康信息如此重要**;
– **挑战1:隐私法规的限制**;
– **挑战2:大规模数据标注的工作难度**;
– **挑战3:数据的有效性验证**;
– **合成健康信息如何解决这三个问题**;
– **构建合成健康信息生成流程的具体步骤**;
– **流程的架构设计**;
– **在最终使用之前需要进行的各项安全检查**;
– **第一步:生成合成患者身份信息**;
– **第二步:将健康信息嵌入图像像素中**;
– **第三步:将健康信息添加到DICOM元数据中**;
– **第四步:对去标识化后的患者身份信息进行映射处理**;
– **第五步:生成结构化的CSV格式的真实数据文件**;
– **三层结构的DICOM数据验证方法**;
– **一个令人意外的错误:MONAI与PIL之间的差异**;
– **最后的总结与思考》。

来源图像:OpenPOCUS

用于生成合成健康信息的数据,来源于OpenPOCUS提供的肺部超声检查图像。OpenPOCUS是一个由相关社区共同贡献的、基于公开许可机制建立的真实超声图像数据库。

这些图像本身并不包含任何真实的健康信息。OpenPOCUS能够提供具有临床真实性的超声图像,同时避免引发患者隐私方面的问题。因此,它成为生成合成健康信息的理想基础——因为我们完全可以专注于创建和追踪识别标识符,而无需担心会泄露患者的真实信息。

“冰山问题”:大部分健康信息其实是隐藏的

当人们提到医疗图像中的健康信息时,他们通常会想到那些可见的文本标识符。

这些可见的标识符包括:

患者姓名
病历编号
出生日期
检查日期

这类标识符往往会被超声、X光、CT或MRI系统直接嵌入到图像像素中。

但显然,这些可见的文本仅仅只是“冰山一角”。实际上,大部分健康信息都隐藏在DICOM文件的头信息中——那些用于描述图像及其检查信息的元数据字段里。这些字段中包含了诸如患者姓名病历编号检查日期以及机构名称等敏感信息。

与那些直接嵌入到图像中的文本不同,DICOM文件头信息在查看图像时是无法看到的;但它们仍然会随文件一起被传输,因此在执行去标识化处理时也必须将这些信息清除掉。

冰山示意图:可见的健康信息位于图像像素中,而隐藏的健康信息则存在于DICOM元数据中。

任何去标识化系统都必须同时处理这两类信息。

如果只删除可见的文本,而让DICOM元数据中的健康信息仍然存在,那么依然会带来隐私风险;同样地,如果只清除元数据,而让患者姓名这些信息继续留在图像像素中,也会引发同样的问题。

这种隐藏在背后的健康信息问题,使得测试去标识化软件的工作难度远超人们的想象。

为什么合成健康信息如此重要

乍一看,医院似乎已经拥有了大量的真实数据,那么为什么不直接使用这些数据呢?

原因其实在于三个方面的挑战。

挑战1:隐私法规

医疗图像中往往包含患者的识别标识符。

如果将这些图像分享到安全的临床环境之外,就会带来严重的法律和合规风险。

参与其中的机构越多,管理难度也就越大。

挑战2:大规模标注工作

现代的人工智能系统需要带有标签的训练数据。

必须有人来确定以下内容:

  • 哪些地方包含患者隐私信息

  • 这些患者隐私信息属于哪种类型

  • 哪些DICOM标签中包含了患者隐私信息

手动创建这些标注工作既费时又成本高昂。

挑战3:验证

假设你正在评估一种去标识化工具,如何才能确定它是否成功移除了所有标识信息呢?

使用真实的患者数据时,往往无法准确知道每条患者隐私信息具体位于图像的哪个位置。如果没有真实的数据作为参考,就很难衡量该工具的准确性。

合成患者隐私信息可以解决这三个问题

我们不必从真实的患者标识信息开始入手,而是可以生成逼真的虚假标识信息,并故意将它们插入到医学图像中。

由于这个处理流程本身就是由我们创建的,因此我们能够掌握以下所有信息:

  • 所有的标识信息内容

  • 每個像素的位置

  • 所有的DICOM标签

  • 预期的处理结果

这样我们就拥有了完美的参考数据。

现在,就可以客观地评估去标识化工具的性能了:如果处理后图像中还残留有患者姓名,那就说明该工具失败了;如果临床信息被错误地删除了,我们也能及时发现。

合成患者隐私信息所生成的数据集能够用于以下用途:

  • 训练人工智能模型

  • 评估去标识化软件的性能

  • 进行回归测试

  • 在部署前进行验证

构建合成患者隐私信息处理流程

为了研究这个问题,我的团队专门开发了一个用于生成肺部超声检查图像中合成患者隐私信息的处理流程。

我们的目标是:

  1. 从不包含任何患者信息的超声图像开始处理

  2. 生成逼真的合成患者标识信息

  3. 将这些虚假标识信息嵌入到图像的像素中

  4. 将相应的患者隐私信息添加到DICOM元数据中

  5. 自动生成参考标签

  6. 验证处理后的DICOM文件

从去标识化工具的角度来看,处理后的结果看起来非常真实,但实际上其中并不包含任何真实的患者信息。

处理流程架构

整个处理流程如下所示(下面我们会详细说明每一步的具体操作):

生成超声图像及DICOM文件中合成患者隐私信息的处理流程

每个处理阶段都会生成下个阶段所需的数据。一旦出现错误,系统会立即将其隔离起来,而不会被忽略。

在嵌入数据之前需要进行安全检查

在将合成患者隐私信息写入图像之前,系统会先进行安全检查,以确保要插入这些信息的区域并不位于超声成像的范围之内。

肺部POCUS图像的左上角通常位于成像区域之外,因此该区域为深色边界,将PHI数据烧录到该区域上不会影响临床图像的内容显示。

为了确保每一张图像的这一区域都符合要求,处理流程会对每张图像进行两项检查:

  • 亮度检测: 如果配置好的烧录区域的平均亮度超过了某个阈值,说明该区域很可能位于超声成像区域内,而非深色边界范围内。

  • 边界检测: 处理流程会验证配置好的烧录区域是否完全位于图像范围内;如果某张图像的尺寸小于预定的烧录区域大小,那么该图像就会被标记为异常图像并进行隔离处理。

无论出现哪种情况,该图像都会被标记为异常图像,并且其原因会被记录在相应的日志文件中。这样就可以确保不会发生部分数据被烧录、临床信息被覆盖,或者测试数据被意外损坏的情况。

这种机制能够有效防止合成生成的标识符无意中掩盖真实的解剖结构信息。

def burn_region_is_safe(arr):
"""检查烧录区域是否足够暗,以确保它位于成像区域之外。"""
h, w = arr.shape
y2 = min(BURN_REGION_Y + BURN_REGION_H, h)
x2 = min(BURN_REGION_X + BURN_REGION_W, w)
region = arr[BURN_REGION_Y:y2, BURN_REGION_X:x2]
if region.size == 0:
return False, float("nan")
mean = float(region.mean())
return mean <= BRIGHTNESS_SKIP_THRESHOLD, mean

该函数会提取出配置好的烧录区域,并计算其平均亮度;如果该区域的亮度过高,说明它很可能位于超声成像区域内。

步骤1:生成合成患者身份信息

这些合成身份信息是由Faker工具生成的,而且每个案例都会使用不同的种子值进行生成,因此相同的图像总是会生成相同的虚假患者信息。

这种确定性是非常重要的,因为:

  • 要重现某个测试结果,就必须重新生成相应的测试数据。

  • 当输入数据在多次测试中保持不变时,对后续工具进行调试就会变得更加方便。

  • 只有当两种去标识化工具处理的是相同的虚假患者信息时,才能进行公平的对比分析。

def case_seed(globalSeed: int, sourceId: str) -> int:
"""根据全局种子值和来源路径生成每张图像唯一的确定性种子值。"""
h = hashlib.sha256(f"{globalSeed}|{sourceId}".encode()).hexdigest()
return int(h[:8], 16)

def generate_phi(seed: int) -> dict:
fake = Faker()
Faker.seed(seed)
rng = random.Random(seed)

last = fake.last_name()
first = fake.first_name()
middle = fake.random_letter().upper()
mrn = f"{rng.randint(1000000, 9999999]}"
dob = fake.date_of_birth(minimum_age=18, maximum_age=95)
study_date = fake.date_time_this_decade()
institution = rng.choice(INSTITUTION_POOL)

return {
"case_uuid": f"SYNTH-{uuid.UUID(int=rng.getrandbits(128))}",
"patient_name_display": f"{last}, {first} {middle}.",
"patient_name_dicom": f"{last}^{first}^{middle}", # DICOM PN VR格式
"patient_id": mrn,
"dob": dob,
"study_date": study_date,
"institution_name": institution,
}

上述代码用于生成合成患者身份信息,包括各种必要的字段值。

case_seed()函数会根据源图像路径生成一个确定的随机种子值,Faker会使用这个种子值来创建伪造的患者信息。

由于这个种子值是可重复使用的,因此相同的输入图像总会得到相同的患者信息。这样一来,调试和性能测试就可以获得一致的结果。

步骤2:将患者信息嵌入图像像素中

在图像上渲染文本会消耗较多的资源。对于一个包含30多帧的图像区域来说,如果为每一帧都重复进行这种操作,将会造成资源的浪费。

因此,我们的处理流程会在每个区域仅将患者信息渲染一次,并将其覆盖到透明画布上。这种处理方式与实际使用的许多超声设备的工作原理是一致的——在这些设备中,患者信息是固定的,而图像内容则会随着帧数的变化而发生变化。

def make_phi-overlay(shape, phi):
    """在画布上仅渲染一次患者信息。返回结果为:(覆盖图像数组, 注释元数据)。"""
    h, w = shape
    canvas = Image.new("L", (w, h), 0)  # 创建空白画布
    draw = ImageDraw.Draw(canvas)

    overlays, x, y = [], BURN_REGION_X, BURN_REGION_Y
    for entry in _phi_text_block(phi):
        x0, y0, x1, y1 = draw.textbbox((x, y), entry["line"], font=FONT)
        tw, th = x1 - x0, y1 - y0

        if x + tw > w or y + th > h:
            raise ValueError(
                f"渲染出的患者信息超出了图像范围:'{entry['line']} "
                f"位于位置 ({x},{y}),尺寸为 ({tw}x{th}),而图像的尺寸是 {w}x{h}"
            )

        draw.text((x, y), entry["line"], font=FONT, fill=TEXT_COLOR)
        overlays.append({
            "phi_category": entry["phi_category"],
            "rendered_text": entry["line"],
            "phi_value": entry["value"],
            "bbox": [x, y, tw, th],
            "dicom_tag": entry["dicom_tag"],
        })
        y += th + LINE_gap
    return np.array(canvas), overlays

make_phi-overlay()函数会创建一个空白画布,并将每一条患者信息都渲染到这个画布上。同时,它还会记录诸如渲染文本、边界框坐标以及对应的DICOM标签等元数据。

该函数会同时返回覆盖图像和注释元数据,这样就能确保真实数据与实际被绘制出来的像素内容是一致的。

仅渲染一次然后重复使用这些覆盖图像有很多优点:

  • 处理速度更快

  • 不同帧中的患者信息位置保持一致

  • 生成真实数据的流程更加简单

  • 其表现方式更接近真实的超声设备

还有一个额外的好处是,这个处理流程会自动记录所有被嵌入到图像中的标识符的位置。

步骤3:将患者信息添加到DICOM头部中

DICOM标准提供了两种方式来表示连续的超声检查数据:一种是将这些数据作为一系列共享相同UID的单帧DICOM文件来存储;另一种则是将所有帧的数据合并成一个多帧DICOM文件,其中像素数据中包含了所有帧的内容。

该处理流程采用多帧处理方式,原因如下:

  • 这种处理方式与真实的超声设备生成影像数据的方式一致。

  • 一个头部文件即可包含所有帧的数据,从而避免了患者元数据的重复存储。

  • 这样的处理方式能够提高数据存储和传输的效率。

ds.PatientName = phi["patient_name_dicom"]
dsPatientID = deid.patient_id
ds.PatientBirthDate = phi["dob"].strftime("%Y%m%d")

ds.StudyInstanceUID = study_uid
ds.StudyDate = phi["study_date"].strftime("%Y%m%d")
ds.InstitutionName = phi["institution_name"]

这些字段会将与图像中显示的信息相同的标识信息填充到DICOM头部文件中。这样一来,可见的患者信息与隐藏的元数据就能保持一致,从而生成出更加真实的测试数据。

DICOM标准还规定了一些细节,但相关规范并没有对这些细节进行明确说明:

  • StudyID是必填字段,且必须是一个短字符串,不能与StudyInstanceUID重复。这个字段很容易被忽略。

  • ImageType也是必填字段。对于合成数据来说,将["DERIVED", "SECONDARY"]作为其值是合理的,因为这类数据并非由真实设备生成的。

  • Manufacturer属于“通用设备”相关字段,即使数据是合成的,这个字段也是必须填写的。将其设置为SYNTHETIC-DEID-TUTORIAL这样的明确标识值,可以清楚地说明数据的来源。

包含合成超声数据、图像中的患者信息以及元数据的DICOM文件。

步骤4:身份映射:去标识化的患者ID

为了便于后续评估,每个原始患者都会被分配一个固定的标识符,例如DEID-0001。通过一个映射文件,可以将原始患者信息、合成研究数据以及生成的DICOM对象关联起来。这样,评估人员就可以将去标识化工具的处理结果与真实的参考数据进行对比了。

source.patient,deid_patient_id,study_instance_uid
patient_001,DEID-0001,1.2.826.0.1.3680043.8.498.1234...
patient_002,DEID-0002,1.2.826.0.1.3680043.8.498.5678...

步骤5:参考数据:结构化CSV格式的输出

合成患者信息的一个显著优点就是能够自动生成标签。由于整个处理流程会为每条数据生成相应的标识符,因此系统已经知道了这些数据的文本内容、边界框坐标以及对应的DICOM标签。

这些标注信息会被导出为结构化CSV文件,从而成为用于训练和评估的参考数据。

def build-overlay_rows(*, case_uuid, sop_instance_uid, source_id, source_relpath, output_dicom_relpath, overlays,
                      image_shape):
    h, w = image_shape
    rows = []
    for ov in overlays:
        x, y, ow, oh = ov["bbox"]
        rows.append({
            "case_uuid": case_uuid,
            "sop_instance_uid": sop_instance_uid,
            "source_id": source_id,
            "source_relpath": source_relpath,
            "output_dicom_relpath": outputDicom_relpath,
            "image_h": h,
            "image_w": w,
            "region": "top_left_banner",
            "phi_category": ov["phi_category"],
            "phi_value": ov["phi_value"],
            "rendered_text": ov["rendered_text"],
            "bbox_x": x, "bbox_y": y,
            "bbox_w": ow, "bbox_h": oh,
            "dicom_tag": ov["dicom_tag"],
            "seed": SEED,
            "pipeline_version": PIPELINE_VERSION,
            "run_id": RUN_ID,
        })
    return rows

build-overlay_rows函数会将每个覆盖层转换成一行结构化的元数据。除了文本和边界框坐标外,它还会记录标识符以及可复现性信息,比如处理流程的版本和随机种子值。

这些CSV文件会成为用于训练和评估去识别系统的真实参考数据。

在处理过程结束时,所有收集到的数据会按照去识别的患者ID进行分类,并被写入对应的患者专属CSV文件中。每个患者的文件夹里都会包含一个phi_overlays.csv文件,其中记录了该患者的所有区域信息;同时还会有一个run_manifest.csv文件,用于总结各区域的处理状态(已处理、被隔离、处理失败)及相关路径信息。

三层DICOM验证机制

一个合成的DICOM文件只有在其真正符合DICOM标准时才有用。否则,使用它的后续工具要么会出错,要么会默默地错误处理这些数据。

该处理流程采用了三层验证机制,根据环境中可用的资源不同,会自动选择合适的验证方式:

  1. dciodvfy来自dicom3tools:这是最严格的符合标准验证工具,由David Clunie编写。它不能通过pip安装,而是直接对比完整的DICOM IOD定义来进行验证。如果PATH环境中包含这个工具,就会优先使用它。

  2. dicom-validator命令行工具:可以通过pip安装。它在首次运行时会下载DICOM标准定义文件,然后进行符合性验证。当dciodvfy不可用时,就会使用这个工具。

  3. pydicom简单验证方式:这是最低级别的备用方案。它仅用于确认文件能否被重新打开、解码,以及像素数据在读写过程中是否正确。它不会检查是否符合标准,但能检测出明显的数据损坏情况。

一个令人意外的错误:MONAI与PIL的差异

最初,我计划使用MONAI来加载图像数据,因为它在医学影像处理领域被广泛使用。

但在测试过程中我发现了一个问题:由于MONAI的图像加载方式,那些非正方形的图像在后续处理中被视为矩形图像进行处理时,会出现旋转现象。

同时,许多超声图像中包含需要校正的EXIF方向元数据。

最终改用PIL后,这两个问题都得到了解决。

from PIL import Image, ImageOps

img = Image.open(path)
img = ImageOps.exif_transpose(img)

最后的思考

合成的PHI数据虽然不能替代真实的测试环境,但它为医疗AI团队提供了难得的资源:一个安全、可共享且带有明确标签的数据集,其结果也是已知正确的。

通过生成逼真的标识符,并将这些标识符嵌入到图像像素和DICOM元数据中,我们就可以构建出可复现的测试基准,从而评估去识别系统的性能,而无需暴露真实的患者数据。

随着人工智能系统在处理敏感医疗信息方面承担越来越重要的职责,合成后的患者健康信息很可能成为构建可靠医疗人工智能工作流程的重要工具之一。 该解决方案的完整实现方式以Jupyter笔记本的形式存在于MONAI超声工作组的代码仓库中。您可以自行查看这些笔记本并尝试使用其中的处理流程进行实验。 有时候,测试某个系统是否具备删除患者健康信息的功能,最可靠的方法就是自己手动生成这些敏感数据。
Comments are closed.