资讯动态

基于深度学习的甲状腺结节超声图像识别:从数据到部署

发布时间:2026/9/30 5:18:48 来源:尧图企业网站定制
简介这份PDF文献聚焦深度学习在甲状腺超声图像中的结节自动识别方法面向医学影像研究、人工智能辅助诊断方向的科研人员与临床医师帮助解决超声结节判读耗时长、低年资医师识别能力不足等问题。资源包内含1个PDF文件大小约585KB为期刊论文全文涵盖研究论著、摘要、关键词及资料与方法等完整章节便于系统研读。文中基于2013年1月至2018年1月共6321张甲状腺图像构建并验证模型其中3200张确诊图像用于训练、3121张未确诊图像用于验证并交由4名临床医师诊断对比结果显示深度学习在阳性预期率、阴性预期率、诊断敏感性、诊断效率与特异性等指标上均超过超声医师阳性预期率高出10.00%、阴性预期率高出5.02%、诊断效率高出10.24%。目前已有306人学习适合作为医学影像深度学习研究的参考文献与专业指导材料。1. 超声图像里的甲状腺结节为什么值得用深度学习重做一遍体检报告上那句「甲状腺结节建议复查」背后是超声科医生每天要盯几百张灰阶切片的现实。超声是甲状腺结节筛查的首选手段无辐射、便宜、实时但它的短板同样明显结节边界模糊、回声不均、钙化点细小良恶性判断高度依赖医生的经验和手感。同一个结节不同年资的医生给出的 TI-RADS 分级可能差一到两级这种主观性在基层医院尤其突出。深度学习介入这件事的价值不是替代医生而是把「看片」这件事里可量化的部分固化下来——结节在哪、多大、边界清不清、有没有微钙化、纵横比是否大于 1。这些特征一旦被模型稳定提取就能作为第二意见帮低年资医生兜底也能在体检海量筛查里做初筛分流。这篇笔记讲的就是基于深度学习的甲状腺结节自动识别方法在超声图像上从数据准备到模型落地一条能跑通的路径。适合有 Python 基础、想把这个方向做成毕设或落地原型的工程师和医工交叉方向的研究生。2. 从超声图像到可训练数据结节识别的数据管线怎么搭2.1 为什么甲状腺结节超声数据不能直接丢进模型超声图像和自然图像最大的区别在于成像机制。探头频率、增益、深度、焦点位置任何一个参数变了同一结节的灰度分布都会漂移。更麻烦的是超声图像自带大量斑点噪声speckle noise这是相干波干涉的产物不是传感器缺陷滤波滤不干净。直接把原始图喂给 CNN模型很容易学到「这台机器拍的」而不是「这个结节长什么样」。所以数据管线的第一步不是标注是标准化。常见做法是统一裁剪到结节感兴趣区域ROI做灰度归一化再决定要不要去噪。我一般会保留原始灰度做一路输入同时生成一路去噪后的图让模型自己学哪路更有用——这个思路在后面讲双分支结构时会展开。标注环节是另一个坎。甲状腺结节标注不是画个框就完事良恶性判断需要结节轮廓、内部回声、钙化位置。如果只做检测结节在哪矩形框够用如果要做分类良性/恶性至少需要结节级标签理想情况是像素级分割掩膜。标注成本差异巨大这直接决定你选检测、分割还是分类路线。2.2 数据采集与标注的最小可行流程假设你手上有一批超声科导出的 JPG/PNG没有 DICOM 原始数据也能起步。下面是一段把原始超声图整理成训练集的脚本覆盖读取、ROI 裁剪、归一化和数据集划分。import os import cv2 import numpy as np from sklearn.model_selection import train_test_split # 输入原始超声图目录文件名格式 患者ID_结节ID_标签.png RAW_DIR raw_ultrasound OUT_DIR dataset IMG_SIZE 256 def normalize_ultrasound(img): 超声灰度归一化CLAHE 增强局部对比度再缩放到 [0,1] clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) img clahe.apply(img) img img.astype(np.float32) / 255.0 return img def crop_roi(img, maskNone): 无掩膜时按中心裁剪有掩膜时按掩膜外接框裁剪 h, w img.shape[:2] if mask is not None: ys, xs np.where(mask 0) if len(ys) 0: return None y1, y2, x1, x2 ys.min(), ys.max(), xs.min(), xs.max() # 外扩 10% 留上下文 pad_y int((y2 - y1) * 0.1) pad_x int((x2 - x1) * 0.1) y1, y2 max(0, y1 - pad_y), min(h, y2 pad_y) x1, x2 max(0, x1 - pad_x), min(w, x2 pad_x) return img[y1:y2, x1:x2] # 无掩膜中心裁剪 70% ch, cw int(h * 0.7), int(w * 0.7) y1, x1 (h - ch) // 2, (w - cw) // 2 return img[y1:y1 ch, x1:x1 cw] samples [] for fname in os.listdir(RAW_DIR): if not fname.lower().endswith((.png, .jpg)): continue parts fname.rsplit(., 1)[0].split(_) if len(parts) 3: continue label int(parts[-1]) # 0 良性 1 恶性 img cv2.imread(os.path.join(RAW_DIR, fname), cv2.IMREAD_GRAYSCALE) if img is None: continue roi crop_roi(img) roi cv2.resize(roi, (IMG_SIZE, IMG_SIZE)) roi normalize_ultrasound(roi) samples.append((roi, label)) X np.stack([s[0] for s in samples])[..., np.newaxis] # (N,256,256,1) y np.array([s[1] for s in samples]) # 按 7:1.5:1.5 划分stratify 保证良恶性比例一致 X_train, X_tmp, y_train, y_tmp train_test_split( X, y, test_size0.3, stratifyy, random_state42) X_val, X_test, y_val, y_test train_test_split( X_tmp, y_tmp, test_size0.5, stratifyy_tmp, random_state42) np.savez_compressed(os.path.join(OUT_DIR, thyroid_split.npz), X_trainX_train, y_trainy_train, X_valX_val, y_valy_val, X_testX_test, y_testy_test) print(ftrain {len(X_train)} val {len(X_val)} test {len(X_test)})这段脚本的关键点有三个。第一normalize_ultrasound用 CLAHE 而不是简单除以 255因为超声图像动态范围大直接归一化会让低回声区细节全丢。clipLimit2.0控制对比度增强上限设太大会放大斑点噪声我一般从 2.0 起调。第二crop_roi在有掩膜时按外接框外扩 10%这是给模型留边界上下文——结节边界特征模糊/清晰本身就是良恶性判据裁太紧反而丢信息。第三划分用stratify保证良恶性比例甲状腺结节数据里良性远多于恶性不 stratify 会出现验证集全是良性的情况。提示如果手上有 DICOM 原始数据优先用 pydicom 读取因为 DICOM 里带像素间距PixelSpacing后续做结节实际尺寸测量时用得上JPG 导出会丢这个信息。2.3 数据增强在超声图像上的边界自然图像那套增强随机裁剪、颜色抖动、翻转不能照搬。超声图像里左右翻转通常安全因为甲状腺左右叶解剖对称但上下翻转要谨慎因为探头方向决定了图像上方是体表、下方是深部组织上下翻转会造出解剖上不存在的图。颜色抖动更不能用超声是灰阶图没有颜色通道。我常用的增强组合是水平翻转、±15° 旋转、±10% 缩放、亮度微调±0.1。旋转角度别超过 15°因为超声探头角度变化会改变声束入射角大角度旋转后的图像在物理上不合理。亮度微调模拟的是增益变化这个在真实场景里很常见值得加。数据量方面甲状腺结节公开数据集规模都不大几百到几千张是常态。这个量级下从头训练 CNN 基本会过拟合迁移学习是必选项下一章展开。3. 模型选型检测、分割还是分类先想清楚要解决哪个问题3.1 三类任务的适用场景与代价对比很多人一上来就说「用 YOLO 做甲状腺结节识别」但没想清楚识别到底指什么。检测是回答「结节在哪」输出矩形框分割是回答「结节边界在哪」输出像素掩膜分类是回答「这个结节良性还是恶性」输出概率。三者数据标注成本、模型复杂度、临床价值完全不同。任务标注需求典型模型输出适用场景检测矩形框YOLOv8 / Faster R-CNN框坐标置信度体检初筛快速定位分割像素掩膜U-Net / nnU-Net二值掩膜测量结节尺寸、边界分析分类结节级标签ResNet / EfficientNet良恶性概率辅助诊断分级从落地角度我建议的路线是先做检测把结节框出来再在框内做分类。这样标注成本可控框比掩膜便宜得多而且检测和分类可以分开迭代。如果直接上分割标注成本会劝退大部分人。3.2 用迁移学习搭一个结节分类基线下面用 PyTorch 搭一个基于 ResNet18 的结节分类基线。选 ResNet18 不是因为它最强而是因为它小、快、在几千张图上不容易过拟合适合做第一版基线。import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader import numpy as np import torchvision.models as models class ThyroidDataset(Dataset): def __init__(self, X, y, augmentFalse): self.X X # (N,256,256,1) self.y y self.augment augment def __len__(self): return len(self.y) def __getitem__(self, idx): img self.X[idx] # (256,256,1) label self.y[idx] # 转成 (1,256,256) 供 torchvision 使用 img torch.from_numpy(img).permute(2, 0, 1).float() # 单通道复制成三通道适配 ImageNet 预训练权重 img img.repeat(3, 1, 1) if self.augment: # 水平翻转 if torch.rand(1) 0.5: img torch.flip(img, dims[2]) # 亮度微调 img img * (1 (torch.rand(1) - 0.5) * 0.2) return img, label def build_model(): model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) # 替换第一层适配单通道输入虽然上面复制成三通道这里保留灵活性 model.fc nn.Sequential( nn.Dropout(0.5), nn.Linear(model.fc.in_features, 2) ) return model # 训练循环 def train(model, train_loader, val_loader, epochs30, lr1e-4): device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) # 类别不平衡良性多恶性少用加权交叉熵 class_weights torch.tensor([1.0, 2.0]).to(device) criterion nn.CrossEntropyLoss(weightclass_weights) optimizer torch.optim.AdamW(model.parameters(), lrlr, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs) best_acc 0.0 for epoch in range(epochs): model.train() for imgs, labels in train_loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() loss criterion(model(imgs), labels) loss.backward() optimizer.step() scheduler.step() # 验证 model.eval() correct, total 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels imgs.to(device), labels.to(device) pred model(imgs).argmax(dim1) correct (pred labels).sum().item() total labels.size(0) acc correct / total if acc best_acc: best_acc acc torch.save(model.state_dict(), best_thyroid.pth) print(fepoch {epoch1} val_acc {acc:.4f}) return best_acc几个参数需要解释。class_weights[1.0, 2.0]是应对良恶性不平衡的具体权重按你的数据里良性/恶性比例调如果比例是 3:1恶性类权重可以设到 3.0。lr1e-4是迁移学习的常用起点因为预训练权重已经很好学习率太大会破坏特征。Dropout(0.5)加在全连接层前小数据集上这个正则很关键我试过 0.3 和 0.50.5 在几百张图上更稳。注意weightsmodels.ResNet18_Weights.IMAGENET1K_V1会触发权重下载如果环境不能联网需要提前把权重文件放到~/.cache/torch/hub/checkpoints/下。这是新手最容易卡住的地方报错信息通常是 URLError。3.3 检测路线YOLOv8 在结节定位上的最小配置如果要做检测YOLOv8 是目前上手最快的选择。数据格式需要转成 YOLO 的 txt 标注类别 中心x 中心y 宽 高全部归一化到 0-1。配置文件里几个关键参数# thyroid.yaml path: ./dataset train: images/train val: images/val nc: 1 names: [nodule]训练命令用yolo detect train datathyroid.yaml modelyolov8n.pt epochs100 imgsz640 batch16。yolov8n是最小模型几千张图够用imgsz640是默认值但超声图像分辨率通常更高如果结节很小可以提到 1024代价是显存翻倍。batch16在 8G 显存上跑 640 尺寸没问题1024 尺寸要降到 8 或 4。检测的评估指标看 mAP0.5但超声结节检测里我更关注召回率——漏检一个恶性结节比误检一个良性结节代价大得多。如果召回率低优先调低置信度阈值默认 0.25可以降到 0.1再看是不是小目标问题小目标就提高 imgsz。4. 训练过程中的坑甲状腺结节模型为什么在验证集上翻车4.1 验证集准确率虚高测试集一塌糊涂现象训练时验证集准确率能到 0.92换一家医院的测试集掉到 0.65。原因数据泄漏。同一患者的多个结节切片被随机分到了训练集和验证集模型记住了这个患者的图像特征而不是结节特征。超声检查一次会采多张切面图同一结节的多张图高度相似。解决按患者 ID 划分数据集不是按图像划分。train_test_split的groups参数配合GroupShuffleSplit可以做到。这一步不做后面所有指标都是自欺欺人。4.2 模型把标注框外的标记当成了特征现象模型在测试集上表现异常好但可视化热力图发现它关注的是图像角落的测量标尺和文字。原因超声图像上常带设备自动生成的测量线、深度标尺、患者信息文字。如果裁剪时没去掉模型会走捷径学这些。解决预处理阶段裁掉图像边缘 5%-10% 的区域或者用掩膜把非组织区域置零。我一般直接裁边简单有效。另外如果图像上有彩色多普勒血流信号要么统一去掉要么作为单独通道输入别让模型把颜色和恶性直接关联。4.3 良恶性不平衡导致模型全预测良性现象训练完模型在测试集上准确率 0.85但混淆矩阵显示恶性结节召回率只有 0.3。原因良性样本远多于恶性模型学会了「全猜良性」这个偷懒策略。解决三个手段叠加。一是加权交叉熵恶性类权重设为良性类的 2-3 倍二是过采样恶性样本用WeightedRandomSampler三是换评估指标别只看准确率看 AUC 和恶性类召回率。我一般把恶性召回率作为模型选型的首要指标准确率参考就行。4.4 数据增强把结节特征增强没了现象加了旋转和缩放增强后验证集指标反而下降。原因旋转角度太大结节被转出图像边界或者缩放后结节变得太小模型学不到有效特征。解决增强参数要保守。旋转 ±15° 以内缩放 0.9-1.1 倍裁剪时保证结节完整。增强后随机抽 20 张图肉眼检查一遍这个习惯能省很多调试时间。4.5 学习率设太大预训练权重被破坏现象训练 loss 一开始就震荡验证集准确率上不去。原因迁移学习时学习率设成了 1e-2太大预训练学到的通用特征被快速覆盖。解决迁移学习学习率从 1e-4 或 1e-5 起步用余弦退火调度。如果数据量特别小几百张可以冻结 backbone 前几层只训练后面几层和分类头等 loss 稳定了再解冻微调。5. 把模型推到能用的程度评估、可解释性与部署前检查5.1 甲状腺结节模型该看哪些指标准确率在医学图像里是最没用的指标。我一般看四个AUC整体判别能力、恶性类召回率漏诊率、恶性类精确率误诊率、F1。AUC 低于 0.85 基本不能看0.9 以上才有讨论价值。恶性召回率我要求至少 0.9因为漏掉一个恶性结节的代价远大于误报一个良性。另外要做校准calibration模型输出的概率得靠谱。一个输出 0.9 恶性的结节实际恶性比例应该接近 90%。用可靠性图reliability diagram检查如果偏差大用 Platt scaling 或 isotonic regression 校准。5.2 用 Grad-CAM 看模型到底在看哪可解释性在医疗场景不是锦上添花是刚需。医生不会信一个黑匣子。Grad-CAM 能生成热力图显示模型做判断时关注图像哪个区域。如果热力图集中在结节上说明模型学对了如果集中在图像边缘或标尺上说明有捷径特征得回去清数据。from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image # 取 ResNet18 最后一个卷积层 target_layers [model.layer4[-1]] cam GradCAM(modelmodel, target_layerstarget_layers) # 对单张图生成热力图 input_tensor test_img.unsqueeze(0).to(device) grayscale_cam cam(input_tensorinput_tensor)[0] visualization show_cam_on_image(test_img_np, grayscale_cam, use_rgbTrue)跑完把热力图叠加到原图上随机抽 50 张测试图看一遍。这个步骤我每次训练完必做比看指标更能发现问题。5.3 部署前的三个检查项第一输入预处理一致性。训练时用的归一化参数CLAHE 的 clipLimit、图像尺寸必须和推理时完全一致差一点指标就掉。建议把预处理封装成一个函数训练和推理共用。第二推理速度。超声实时检查场景下单张推理要在 100ms 以内。ResNet18 在 GPU 上没问题CPU 上可能要 200-300ms如果部署在边缘设备考虑量化或换 MobileNet。第三失败模式兜底。模型对分布外数据比如不同设备、不同增益的图会给出高置信度错误答案。部署时要加一个分布检测输入图和训练集分布差异过大时拒绝给出判断转人工。这个机制在医疗场景里是安全底线。6. 一个提升小样本结节识别的小技巧双分支灰度去噪输入最后分享一个我在数据量少的时候常用的技巧。甲状腺结节超声图像里斑点噪声既是干扰也是信息——有些恶性结节的微钙化就藏在噪声里。单路输入要么去噪丢细节要么保留噪声让模型难学。双分支结构让模型自己权衡。具体做法一路输入原始灰度图一路输入去噪后的图非局部均值去噪或 BM3D两个分支各用一个轻量 CNN 提特征在中间层拼接后送分类头。代码上就是在 ResNet 前面加一个并行分支class DualBranchNet(nn.Module): def __init__(self, num_classes2): super().__init__() # 原始灰度分支 self.branch_raw models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) self.branch_raw.fc nn.Identity() # 去噪分支 self.branch_denoise models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) self.branch_denoise.fc nn.Identity() # 融合分类头 self.classifier nn.Sequential( nn.Linear(512 * 2, 256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x_raw, x_denoise): f_raw self.branch_raw(x_raw) f_den self.branch_denoise(x_denoise) feat torch.cat([f_raw, f_den], dim1) return self.classifier(feat)去噪那一路用 OpenCV 的非局部均值就行cv2.fastNlMeansDenoising(img, None, h10, templateWindowSize7, searchWindowSize21)。h10是滤波强度超声图像我一般设 8-12太低去噪不干净太高微钙化点会被抹掉。这个结构在我做过的一个 800 张图的数据集上比单分支 ResNet18 的恶性召回率高了约 5 个百分点。代价是参数量和推理时间翻倍如果部署资源紧张可以把去噪分支换成 MobileNet 这种轻量 backbone。训练时两个分支的学习率可以不同去噪分支因为输入更干净学习率可以设小一点比如原始分支 1e-4去噪分支 5e-5避免它过拟合去噪后的伪影。这个技巧不是万能的数据量够大上万张时单分支加足够增强也能达到类似效果但在小样本场景下双分支是个性价比很高的选择。我自己的习惯是每做完一版模型先别急着调参把 Grad-CAM 热力图和混淆矩阵各看一遍八成的问题都能在这两步暴露出来。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑