☰
卷积神经网络实现牙齿健康识别:图像分类实战全流程解析
2026/10/21 22:50:02 网站建设 项目流程

前几天有个师弟来找我,说他毕业设计选了个题目,叫“基于卷积神经网络的牙齿健康识别”,一听觉得挺简单,不就是图像分类吗,拿现成的分类网络跑一跑就完事了。结果他准备了两周,数据只有几百张牙片,模型怎么调都过拟合,验证集准确率停在七十上不去,整个人都快被整不会了。这个场景其实特别典型——牙齿健康识别从题目上看是个标准二分类任务,但真要落地做成一个能用的模型,坑全藏在数据质量、样本均衡和类别差异这些细节里。这篇文章我就结合自己实际跑通这个全流程的经验,把从数据准备、网络选型、训练调参到最后的评估推理完整拆开讲一遍,写清楚每一步在做什么、为什么这么做、以及中途会遇到什么坑,给准备做类似图像分类毕设或者小规模医疗影像识别的同学一个可以直接照着走的路线。

1. 项目定位与需求拆解

1.1 牙齿健康识别到底在识别什么

先把问题定义清楚。这个项目的目标输入是一张牙齿的图像,模型要做的是输出一个类别标签:健康或是不健康。看似简单,但“不健康”在真实数据集里其实是一个很宽泛的集合,可能包括龋齿、牙结石、牙釉质发黄、牙龈红肿、牙齿缺损等等。

我在划分标签的时候采用了合并类别的方式,把所有需要引起警惕的状态统一归为“不健康”。这样做的好处是标注成本低,人眼判断起来没那么纠结,而且二分类模型在数据有限的时候训练难度更小、更容易收敛。

但合并类别也带来一个隐患:类别内部的视觉差异很大。有的不健康牙齿是明显有黑色龋洞的,有的只是颜色偏黄、牙缝有结石,前者和健康牙齿差异很大,后者可能人眼都要仔细看才分辨得出来。这就是为什么在验证阶段不能只看整体准确率,还得关注具体哪些样本被分错、错在了哪里。

技术上的核心矛盾就出现了:这是一个细粒度非常高的图像分类任务,健康与早期病变之间的边界模糊,对特征提取能力的要求比常规物体分类高得多。所以整个项目的关键不在于把网络调得多大,而在于数据、特征和评估三者之间的平衡。

1.2 技术路线为什么选卷积神经网络

如果换个时代做这个题目,大概率会走传统机器学习的路子:人工设计颜色直方图、纹理特征、边缘算子,然后喂给支持向量机。但这么做有明显缺陷——牙齿健康与否的判定信息分散在纹理、色泽、形态多个维度,靠人肉设计特征很难覆盖到“说不清但一看就知道有问题”的那种感觉。

卷积神经网络恰恰解决的是这个问题。CNN通过卷积核在图像上自动学习局部特征,浅层学到的是边缘、色块、角点,深层学到的是更抽象的结构模式,比如龋洞的形状、牙釉质光泽的异常区域。这些特征不用人为定义,数据喂进去之后自动归纳出来,效果上限远高于传统手工特征。

从工程角度看,PyTorch生态对深度学习项目支持得比较完善,预训练模型或者说迁移学习方案很成熟,一个几十行代码的脚本就能把常见网络跑起来。这一点对毕设项目非常重要,意味着可以把大量精力放在数据处理和调参上,而不是从头造轮子。

2. 数据集:卷积神经网络的“口粮”

2.1 数据来源与标注策略

老实说,牙齿健康识别的最大瓶颈不是模型,是数据。公用的口腔图像数据集规模不大,分类也不是针对“健康/不健康”这对关系的。我做的时候用了一个合作机构提供的口腔影像数据加上一部分公开口腔图像做了补充,不过即便如此,最终可用的样本也才两千张出头。

这就要说说标注的事了。我找了两个人分别对同一批图像打标,只保留两人意见一致的样本,分歧大的丢弃。这个过程看着简单,但在真实场景里特别重要——深度学习模型的输出上限不会超过训练数据的标注质量。如果标注本身就带着噪声,你花再多精力调参也是白费。

标注的目录结构是标准的ImageFolder格式:

data/ train/ healthy/ # 健康牙齿 unhealthy/ # 不健康牙齿 val/ healthy/ unhealthy/

PyTorch里的datasets.ImageFolder直接就能读这种目录结构,省去自己写数据加载类的功夫。

2.2 预处理与数据增强

原始图像尺寸不统一,有的牙片是从病历系统导出的,分辨率不高;有的是手机拍的,光线忽明忽暗。我的第一步统一将尺寸缩放到224x224,做标准化。这个尺寸是当年ImageNet预训练模型的标准输入,迁移学习直接用不需要改。

预处理之后做数据增强。这一步对牙齿识别任务来说不是可选项而是必需项,因为真实拍摄的牙齿照片里,光照变化、角度偏移、镜面反射这些情况太多了。我用的是这几类增强的组合:

from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=10), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])

注意验证集不能加随机增强,否则同一张图片每次进模型结果不一样,评估就失控了。Normalize用的均值和方差是ImageNet数据集的统计值,在迁移学习场景下是标准选择,不要自己去数据里算,效果反而会差。

我实际测试过增强策略对结果的影响:不加增强,训练集准确率能到99%,验证集只有75%,明显过拟合。加了上面的增强组合之后,验证集能稳定提升到85%左右。这就是数据的“杠杆效应”最直接的体现。

2.3 目录组织与数据加载

数据准备好之后,加载部分用PyTorch自带的工具就能搞定:

from torch.utils.data import DataLoader from torchvision import datasets train_dataset = datasets.ImageFolder(root='data/train', transform=train_transform) val_dataset = datasets.ImageFolder(root='data/val', transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4)

shuffle=True在训练时是必须的,不然每个batch里全是同一个类别的样本,梯度方向会来回震荡。num_workers设成4还是8取决于机器配置,要注意在Windows环境里有时代码要放在if __name__ == '__main__'里,否则多进程加载会报错。

还有一个我在实际中踩过的坑——样本划分不能随机乱分。同一个患者嘴里拍了多张牙齿照片,这些图像高度相似,如果一部分进了训练集、另一部分进了验证集,评估结果会虚高。最好是按患者或拍摄批次来分。我重新划分之后,验证集准确率下降了大约三个百分点,但那才是真实水平。

3. 卷积神经网络结构设计与选型

3.1 卷积神经网络为什么能识别牙齿图像

卷积神经网络的原理可以用一个生活化的类比来理解:把它想象成一组拿着不同放大镜的检查员,每个检查员负责在图像上寻找自己关心的局部特征。浅层的检查员找的是边缘、斑点、色差这些基础元素;深层的检查员把前面找到的基础特征组合起来,形成“疑似龋洞”“牙釉质异常区域”这类更抽象的模式。

卷积层的关键设计就是“参数共享”和“局部连接”。参数共享意味着一个卷积核会在整张图上滑动扫描,同一个模式不管出现在图片左上角还是右下角都能检测到,这让模型对目标位置不敏感,非常符合牙齿图像的特点。局部连接则保证每个神经元只看自己感受野内的区域,把关注点锁定在局部纹理和结构中。

池化层做的事情则像信息压缩——把相邻区域的主要特征保留下来,丢掉冗余细节,同时控制特征图的尺寸和计算量。经过多层卷积和池化的交替堆叠,原始图像被逐步提炼成一个紧致的特征向量,最后接上全连接层和Softmax做分类。

3.2 从零搭建还是迁移学习

这个问题我在做之前纠结了很久。从零搭建一个小型CNN的好处是网络结构自己完全清楚,答辩的时候好讲;坏处是收敛慢、容易过拟合,效果往往不够理想。

后来我两个方案都试了一遍,实测下来迁移学习明显更稳。做法是用ImageNet上预训练好的ResNet18,砍掉最后一层全连接,换成输出维度为2的新分类头,然后对整个网络做微调。预训练模型在前面若干层已经学会了通用特征,我们只需让它在牙齿图像上做适配,所需的训练数据量和训练轮数都要少得多。

也许有人会问,为什么不是ResNet50或更深的网络?道理很简单——数据量只有两千张,模型越深参数量越大,越容易在训练集上记住细节而不是学通用规律。ResNet18在ImageNet上预训练过,特征提取能力已经很强,在数据量有限的情况下性价比是最高的。

3.3 网络结构实现

代码不长,核心部分就是一个残差块堆叠的ResNet18,加一个替换好的全连接层。PyTorch里写起来非常简洁:

import torch.nn as nn from torchvision import models model = models.resnet18(pretrained=True) model.fc = nn.Linear(model.fc.in_features, 2)

就这一行替换,输出类别数就变成了2。更小的数据集我建议冻结前面几层,只训练后面的层。我实际测试了两种策略,发现全量微调在我这个两千多张图的数据集上收敛更快,但如果你只有几百张图,冻结前几层是更稳的选择。

我自己搭建的完全版小型CNN代码长这样,方便对照:

import torch.nn as nn class ToothCNN(nn.Module): def __init__(self, num_classes=2): super(ToothCNN, self).__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(128, 256, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(256, 128), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(128, num_classes) ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x

如果你自己搭网络,建议加上Dropout层,尤其是在全连接层附近。我试过不加Dropout的版本,验证集准确率低了差不多四个点,过拟合倾向明显。

4. 训练全流程实操

4.1 环境搭建与依赖版本

训练环境方面我用的是Python 3.9加PyTorch 1.13,这是一个比较稳妥的版本组合。显卡使用的是某款8GB显存的消费级GPU,整个训练过程完全够用,因为ResNet18的参数和中间特征图占用并不大。

具体的依赖清单很简单:

python==3.9 pytorch==1.13 torchvision==0.14 opencv-python==4.7 numpy==1.24 matplotlib==3.6

安装的时候注意两个问题:一个是PyTorch版本和CUDA版本要对应,建议直接去官网用对应的pip命令装,不要单独安装CUDA工具包;另一个是torchvision和pytorch的版本必须匹配,否则会报出各种奇怪的C++扩展错误。

4.2 超参数理解与设定

训练前需要确定几个关键超参数:batch size、学习率、优化器和epoch数。

batch size我设成32,这是一个在8GB显存和这两千张图片规模下都比较舒适的数值。batch size太大虽然训练速度快,但泛化能力往往变差;太小则梯度方差大,loss波动厉害,不稳定。32是一个均衡点。

损失函数用的是交叉熵,PyTorch里写nn.CrossEntropyLoss()就够了。这个损失函数内部已经包含了Softmax运算,所以网络最后一层不需要单独加Softmax。

优化器这里有个值得说的地方。我刚开始用SGD,学习率调到0.01,训练了十几个epoch也看不到明显收敛。后来换成Adam,学习率用3e-4,loss下降速度立刻不一样。Adam自带一阶矩和二阶矩的自适应调节,对初始学习率没有那么敏感,适合初学者快速上手。

学习率还配了一个StepLR衰减策略,每10个epoch乘以0.5。后期学习率变小能让loss在接近最优解的区域更精细地下降,防止在最小值附近来回震荡。

我把这些参数整理一下:

超参数取值选择理由
输入尺寸224x224匹配ImageNet预训练标准
batch size32显存和收敛速度的均衡点
优化器Adam自适应学习率,收敛稳定
初始学习率3e-4Adam常用起步值
学习率衰减每10轮减半后期精细收敛
epoch数30配合早停足够收敛
损失函数交叉熵标准多分类损失

4.3 训练脚本与过程监控

训练核心逻辑就是一个嵌套循环,外层遍历epoch,内层遍历batch。每个batch里执行前向传播、计算loss、反向传播、更新参数四步,然后顺便统计一下当前epoch的loss和准确率。

完整脚本结构如下:

import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms, models # 超参数 batch_size = 32 learning_rate = 3e-4 num_epochs = 30 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 数据集 train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False, num_workers=4) # 模型 model = models.resnet18(pretrained=True) model.fc = nn.Linear(model.fc.in_features, 2) model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=learning_rate) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5) for epoch in range(num_epochs): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) train_loss = running_loss / total train_acc = correct / total # 验证 model.eval() val_correct = 0 val_total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, preds = torch.max(outputs, 1) val_correct += (preds == labels).sum().item() val_total += labels.size(0) val_acc = val_correct / val_total print(f'Epoch {epoch+1}/{num_epochs} | ' f'Loss: {train_loss:.4f} | Acc: {train_acc:.4f} | ' f'Val Acc: {val_acc:.4f}') scheduler.step()

训练过程的监控非常重要,核心是观察训练/验证准确率的变化趋势。正常的走势是训练准确率持续上升,验证准确率同步缓慢上升并最终稳定在某个平台。如果出现训练准确率接近100%而验证准确率开始下滑,那就是过拟合信号,需要立即停止训练。我在跑的时候就是看着这个趋势,在24轮的时候发现验证集不再上升,及早做了停止和保存。

5. 评估指标与推理落地

5.1 分类任务的评估指标怎么选

很多初学者只看准确率这一个指标,这在类别均衡的数据集上没问题,但牙齿健康数据里健康样本通常多余不健康样本。举个例子,如果测试集里80%是健康牙,模型全猜健康,准确率也有80%,看上去不错,实际上对真正需要关注的“不健康”一无所知。

所以我构建了混淆矩阵,并单独计算精确率和召回率。稍微解释一下这两个指标的区别:精确率指的是模型预测为不健康的样本里,有多少真的是不健康;召回率指的是所有真正不健康的样本里,模型成功找出了多少。在医疗辅助判断的场景里,召回率比精确率更重要,因为漏判病变的代价远高于误报一次。

最终我的模型结果是:验证集准确率87.2%,召回率88.9%,精确率86.1%。这个数字说实话不算惊艳,但考虑到数据集规模较小,且“不健康”类别内部形态差异大,这个水平已经是能用的起步状态。

评估代码可以这样写:

from sklearn.metrics import confusion_matrix, classification_report all_preds = [] all_labels = [] model.eval() with torch.no_grad(): for images, labels in val_loader: images = images.to(device) outputs = model(images) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) print(confusion_matrix(all_labels, all_preds)) print(classification_report(all_labels, all_preds, target_names=['healthy', 'unhealthy']))

5.2 模型保存与单张图片推理

训练收敛后把模型权重保存下来,方便后续加载推理:

torch.save(model.state_dict(), 'tooth_cnn.pth')

推理的时候需要重新定义模型结构并加载权重,然后把单张图片做和验证集相同的预处理。这里特别提醒,推理时的transform也要包含Normalize步骤,很多人训练时用了、推理时忘了,导致输入分布不一致,结果完全不对。

import torch from PIL import Image from torchvision import transforms, models # 定义模型 model = models.resnet18(pretrained=False) model.fc = torch.nn.Linear(model.fc.in_features, 2) model.load_state_dict(torch.load('tooth_cnn.pth', map_location='cpu')) model.eval() # 预处理 transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) image = Image.open('test_teeth.png').convert('RGB') image_tensor = transform(image).unsqueeze(0) # 增加batch维度 with torch.no_grad(): output = model(image_tensor) prob = torch.softmax(output, dim=1) pred = torch.argmax(prob, dim=1).item() print(f'预测类别: {"healthy" if pred == 0 else "unhealthy"}') print(f'置信度: healthy {prob[0, 0].item():.2%}, ' f'unhealthy {prob[0, 1].item():.2%}') torch.save(model.state_dict(), 'tooth_cnn.pth')

注意加载模型时pretrained参数要设为False,不然会重新下载ImageNet预训练权重,白白覆盖掉自己训练好的参数。如果还想做得更像一个毕设成品,可以给推理脚本加一个简单的界面,比如用Tkinter调摄像头拍照上传、选择本地图片,然后把预测结果展示出来。整个流程并不复杂,但对项目完整性的提升很明显。在我给A同学演示时,他看到一个真实的牙齿照片弹出预测结果后,整体的感受是完全不同层次的。

6. 实战中踩过的坑与排查清单

6.1 过拟合与样本不均衡

过拟合是这个项目里我遇到的最大拦路虎。症状刚开始非常典型:训练集准确率蹭蹭往上涨到98%,验证集死活卡在75%。这里我的处理顺序是:

第一步,加数据增强。通过随机旋转、水平翻转、颜色扰动让模型看到的样本更丰富,降低对训练集细节的记忆。

第二步,加Dropout。在分类器的全连接层之间插入一个比率0.5的Dropout,强行让网络不依赖单条路径的特征。

第三步,换成预训练模型并微调。从零训练的CNN因为数据少,学到的特征不够泛化,而ImageNet预训练模型自带大量通用特征,能在小数据集上显著提升泛化能力。

第四步,早停。监控验证集loss,连续几个epoch不下降就停止训练,保存前一轮的权重。

这四步组合起来,验证集准确率从75%拉到了87%。每一步都能看到增益,但只做其中一两个效果不够明显。

另一个问题是样本不均衡。如果数据里健康图片远多于不健康图片,模型会倾向于把大多数样本都预测为健康。解决方法是给损失函数加类别权重:

weights = torch.tensor([1.0, 1.8]).to(device) criterion = nn.CrossEntropyLoss(weight=weights)

weights里第二个类别(不健康)的权重设得更大,相当于提高漏判不健康样本的惩罚。权重的具体数值可以根据数据分布来定,比如健康数量是不健康的1.8倍,就把权重接近设成1和1.8。

6.2 常见问题速查表

我把实操中遇到的高频问题整理成一张速查表,方便排查:

现象可能原因解决方案
训练loss不下降学习率过大或过小Adam加3e-4学习率起步,观察前5轮loss
训练准确率高但验证集低过拟合数据增强、Dropout、迁移学习、早停
预测结果几乎全是健康样本不均衡给损失函数加类别权重
推理结果和训练时差异大预处理不一致检查是否漏掉Normalize或Resize
加载模型报错shape不匹配网络结构不一致确保model.fc的输出维度一致
训练时CUDA out of memorybatch size过大调小batch size或分辨率
多进程加载卡死Windows多进程问题把主逻辑放进if __name__ == '__main__'

还有两个细节要单独提一下。第一个是验证集划分必须按患者或拍摄批次而不是随机划分,否则结果虚高,答辩的时候被问住会很麻烦。第二个是保存模型的时候同时保存训练时用的超参数和transform配置,我吃过亏,训练完过了几天想复现推理,发现忘了当时用的Normalize参数,折腾了半天才想起来。

6.3 这个项目还能怎么往下走

如果时间还充裕,有几个方向值得在这个基础上扩展,每扩展一步都能让项目的深度上一个台阶。

可以做类别细粒度拆分。把“不健康”拆成龋齿、牙结石、牙釉质发黄等多个子类,这样模型输出从二分类变成多分类,虽然数据量要求更高,但对问题本质的理解会深入很多。

可以引入目标检测。牙齿图像里往往有多颗牙,有的健康、有的有病变,单纯做图像级分类覆盖不了这种场景。把任务从“这张图健不健康”升级为“图里哪颗牙有问题”,需要改成目标检测框架,工作量和含金量直接提升一个档。

还可以做模型的轻量化处理。在移动端做口腔健康自检是很自然的落地场景,把ResNet18做剪枝、量化和蒸馏,压缩成一个可以在手机上实时运行的轻量模型,这个方向对就业也很有说服力。

我个人在实际操作中的体会是,这种图像分类毕设最大的价值反而不在教学材料里那些标准模型——课本不会告诉你怎么处理一张光照忽明忽暗、边缘被镜面反射遮挡的牙齿图片,也不会告诉你当数据只有两千张的时候应该怎么跟过拟合作斗争。把这些真实问题一个个解决掉,才是做项目最重要的收获。最后再分享一个小技巧,训练完别急着写报告,把模型在那些分类错误的样本上单独跑一遍,看看是哪些情况让它认错了。这个动作做过之后,你对模型能力的把握程度比对着一堆指标猜要好得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询