简介:面向牙齿健康检测场景的深度学习实战资源,基于PyTorch框架构建卷积神经网络分类模型,并提供网页交互界面,适合希望掌握图像识别完整流程的开发者参考。整个资源包包含269个文件:262张牙齿图片构成多类别训练数据集,3个Python脚本分别用于生成数据集文本、训练模型和启动网页服务,3个文本文件包含环境依赖与训练说明,1个HTML文件负责前端展示,压缩后仅13.89MB,便于携带和二次修改。项目运行逻辑清晰,按数据准备—模型训练—网页端部署的顺序依次执行;训练阶段会保存每个epoch的验证集损失与准确率日志,方便分析模型收敛情况,也能帮助理解数据划分、标签映射与模型保存等关键环节。附带环境安装清单,参照说明完成配置后即可完整体验CNN图像识别项目。目前已有108人学习下载,适合作为深度学习和前后端结合实践的综合示例。
1. 这个zip装的是什么:牙齿健康识别从数据集到Web网页的完整闭环
收到一个名为“web网页html版基于python卷积神经网络训练识别牙齿是否健康-含图片数据集.zip”的项目时,别被这一长串名字吓到。它本质上是一个典型的图像二分类工程:用Python搭一个卷积神经网络,在牙齿图片数据集上训练,让模型学会区分“健康”和“不健康”两类照片,最后通过一个HTML网页上传图片、实时拿到预测结果。对牙科诊所、体检机构或教学演示来说,每天产生的大量口腔照片靠人工逐张看效率低,这套方案能承担初筛工作。适合有一定Python基础、想跑通“数据准备—模型训练—网页部署”全流程的开发者。接下来我按数据集、模型、网页三个部分拆开讲,把训练和部署阶段最容易翻车的地方也一并标出来。
2. 先啃图片数据集这块硬骨头:目录结构、预处理与三种划分策略
无论是PyTorch还是TensorFlow,牙齿识别这类图像分类项目的第一步都不是写模型,而是整理图片数据集。如果文件乱糟糟、标签不标准,后面所有工作都会跟着错。我一般会先把数据整理成深度学习框架最友好的形式,再谈训练。
2.1 图片数据集目录结构与标签映射:分文件夹就是打标签
常见做法是采用PyTorch的ImageFolder约定:根目录下按标签建子目录,每个子目录里放对应类别的图片。对牙齿健康识别来说,最直观的结构就是healthy和unhealthy两个文件夹,模型训练时自动把文件夹名当成类别标签,并按字母序映射为0和1。这样做的好处是省去手动写CSV标注文件的步骤,新增样本只要丢进对应文件夹即可。
mkdir -p dataset/train/healthy # 健康牙齿图片 mkdir -p dataset/train/unhealthy # 不健康牙齿图片(龋齿、牙结石、变色等) mkdir -p dataset/val/healthy mkdir -p dataset/val/unhealthy mkdir -p dataset/test/healthy mkdir -p dataset/test/unhealthy注意:这里的子目录名最终会成为模型的输出类别,英文名比中文名更稳妥,避免编码问题。ImagesFolder在读取时会自动统计每个类别的图片数量,如果你发现某个类别的数量少得离谱,说明原始数据收集阶段就出现了样本不均衡,需要回到素材源头补充图片,或者靠后文的数据增强来兜底。
实际操作中,我建议把原始图片先集中放在一个raw_photos目录,文件名带编号,不要直接把标注信息塞进文件名里。因为文件名一旦包含中文、空格或特殊符号,在部分Linux环境下会触发编码错误,这种问题往往要排查很久才意识到是文件名的锅。整理干净后,用下面的Python脚本一次性完成随机划分,比手工拖动文件可靠得多。
2.2 图像预处理三件套:尺寸统一、归一化与数据增强
牙齿照片来自不同设备,分辨率从几十万像素到上千万像素都有。如果直接把原始尺寸喂给神经网络,不仅显存扛不住,模型也会被无关细节干扰。我习惯把所有图片统一缩放到224×224像素,这是ImageNet时代就流行开来的标准输入尺寸,在CPU推理时速度和精度比较平衡。
import torch from torchvision import transforms # 训练集:带数据增强,防止模型见过的东西太单一 train_transforms = 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(), # 转为Tensor,像素值从0-255缩放到0-1 transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) # 验证集/测试集:只做必要处理,不做增强 val_transforms = 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数据集的统计值,不是牙齿图片自己的。如果你的数据集整体偏暗或偏黄(口腔照片常有色偏),更稳妥的做法是先对训练集所有图片计算mean和std,再替换进去。计算脚本不复杂,用torch.stack把所有图片的Tensor拼起来逐通道求均值方差即可。参数的含义:Resize的224是目标边长;RandomHorizontalFlip的p=0.5表示一半概率翻转;RandomRotation的degrees=10表示在±10度内随机旋转;ColorJitter的brightness和contrast取值范围0到正无穷,0.2表示在0.8到1.2倍之间扰动。
还有一个常被忽略的点:ToTensor必须放在Normalize之前。ToTensor负责把PIL图像从HWC格式变成CHW格式,并把0-255的整型像素值缩放到0-1浮点,Normalize则按通道做标准化。顺序写反会导致归一化作用于一个还没有被缩放的张量,训练时loss直接变成NaN的概率极高。这两行是血泪经验换来的顺序,别动。
2.3 按7:2:1划分训练集、验证集和测试集
划分比例不必过分纠结,但有一个原则必须守住:测试集只能用来做最终验收,绝不能参与训练过程中的任何决策。我把数据按7:2:1随机分成三份,其中验证集用来观察每个epoch后的模型表现并决定是否早停,测试集只在整个训练结束后跑一次,用于估计模型在真实场景中的泛化能力。
import os import random import shutil source_root = "raw_photos" dataset_root = "dataset" random.seed(42) # 固定随机种子,结果可复现 for root, dirs, files in os.walk(source_root): label = os.path.basename(root) # 原始目录名即标签 if not files: continue # 这里假设每个子目录完全对应一个类别 random.shuffle(files) n_total = len(files) n_train = int(n_total * 0.7) n_val = int(n_total * 0.2) for i, fname in enumerate(files): if i < n_train: target = os.path.join(dataset_root, "train", label) elif i < n_train + n_val: target = os.path.join(dataset_root, "val", label) else: target = os.path.join(dataset_root, "test", label) os.makedirs(target, exist_ok=True) shutil.copy(os.path.join(root, fname), os.path.join(target, fname))这段脚本的核心是os.walk遍历原始目录,把子目录名直接当作标签,比依赖文件名判断更稳。random.seed(42)确保每次运行划分结果一致,避免同一个epoch内训练集和验证集出现重叠样本。划分后顺手用len检查一下每个文件夹的图片数,确认没有因为源目录放错文件导致某个类别出现空目录。空目录会让ImageFolder在加载时报错,而且报错信息不够直观,容易让人误以为是代码问题。
数据泄漏是另一个值得警惕的坑:如果你把同一颗牙的不同角度照片拆进了训练集和测试集,模型在测试时相当于见过这个人,最终准确率会虚高。最稳妥的做法是按“患者”划分,也就是同一个人的多张照片必须全部进入同一个集合。如果你拿到的图片数据集没有按人组织,至少要接受一个现实:跨人泛化能力可能比测试分数要差,发布方案时把这个边界写清楚。
3. 搭一个让CPU也能跑的CNN:模型结构、损失函数与训练超参数
数据准备就绪后,开始核心环节:卷积神经网络的搭建与训练。很多新手一上来就套ResNet50这种大模型,结果显存爆掉、训练慢得离谱。牙齿健康识别本质上不是ImageNet那种千分类竞赛,类别差异明显,一个小而稳的CNN完全够用,还能流畅部署到没有GPU的电脑上。
3.1 为什么选CNN而不选传统机器学习:局部感受野的价值
如果退回到特征工程时代,做牙齿分类得先设计颜色直方图、纹理特征、边缘特征,还要处理光照变化带来的干扰。口腔照片的拍摄角度、光源色温、反光情况差异很大,手工特征稍微换一批数据就失灵。CNN通过局部感受野自动学习视觉特征,第一层卷积学边缘和纹理,中间层学局部形状,高层学整体结构,整个过程不需要人工设计特征。同时CNN的权重共享机制大幅减少了参数量,牙齿图片数据集通常只有几百到几千张,足够训练一个轻量级模型。
另一个对比对象是全连接网络。如果直接把224×224×3的像素拉平,输入维度超过15万,第一层全连接的参数量就达到千万级别,训练数据量根本喂不饱,过拟合几乎必然发生。CNN用3×3或5×5的卷积核在局部滑动,一个卷积核的参数量只有几十,全连接层只放在最后的分类头部分,整体参数量被压到极小。
3.2 模型结构设计:四层卷积加自适应平均池化
我常用的结构是四层卷积加全局平均池化的组合。这样设计的好处是,不管输入图片被Resize成224还是256,AdaptiveAvgPool2d都能把特征图压成固定长度,最后的全连接层输入维度恒定,模型不用改代码也能适配不同输入尺寸。
import torch.nn as nn class ToothCNN(nn.Module): def __init__(self, num_classes=2): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), # 输出 32x224x224 nn.ReLU(inplace=True), nn.MaxPool2d(2), # 输出 32x112x112 nn.Conv2d(32, 64, kernel_size=3, padding=1), # 输出 64x112x112 nn.ReLU(inplace=True), nn.MaxPool2d(2), # 输出 64x56x56 nn.Conv2d(64, 128, kernel_size=3, padding=1), # 输出 128x56x56 nn.ReLU(inplace=True), nn.MaxPool2d(2), # 输出 128x28x28 nn.Conv2d(128, 128, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 输出 128x14x14 ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), # 任意输入尺寸 -> 1x1 nn.Flatten(), # 128 维 nn.Dropout(0.5), # 防止过拟合 nn.Linear(128, 256), nn.ReLU(inplace=True), nn.Linear(256, num_classes) # 输出健康/不健康两类logit ) def forward(self, x): return self.classifier(self.features(x))模型每一层的参数设置如下表,方便你按需调整:
| 层 | 输出尺寸 | 参数量 | 设计意图 |
|---|---|---|---|
| 输入 | 3×224×224 | 0 | 标准图像输入 |
| Conv2d-1 | 32×224×224 | 896 | 提取低层边缘纹理 |
| MaxPool2d | 32×112×112 | 0 | 降低分辨率,扩大感受野 |
| Conv2d-2 | 64×112×112 | 18496 | 提取局部形状特征 |
| MaxPool2d | 64×56×56 | 0 | 下采样 |
| Conv2d-3 | 128×56×56 | 73856 | 提取高频纹理与病变特征 |
| MaxPool2d | 128×28×28 | 0 | 下采样 |
| Conv2d-4 | 128×14×14 | 147584 | 加深抽象特征 |
| MaxPool2d | 128×7×7 | 0 | 下采样 |
| AdaptiveAvgPool | 128×1×1 | 0 | 全局特征压缩 |
| Linear | 256 | 33024 | 特征到类别映射 |
| Linear | 2 | 514 | 二分类输出 |
整套模型参数量约27万,约为ResNet18的六十分之一。Dropout层放在分类头之前,训练时随机丢弃一半神经元,配合weight_decay能有效抑制过拟合。如果你觉得这个结构偏深,删掉最后一个Conv2d和对应池化层也能工作,只是准确率会稍微下降,适合数据量极小的情况。
3.3 训练配置:CrossEntropyLoss、Adam与学习率调度
模型定义好之后,训练配置决定它能否快速收敛。分类任务的标配是交叉熵损失,优化器用Adam,初始学习率0.001,这是经过大量项目验证的稳妥组合。牙齿数据集通常只有几百张图,batch size设32或64即可,太大容易让梯度方向过于平滑,太小则训练不稳定。
import torch import torch.optim as optim from torch.optim.lr_scheduler import StepLR model = ToothCNN(num_classes=2) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4) scheduler = StepLR(optimizer, step_size=8, gamma=0.1) epochs = 40 best_val_acc = 0.0 for epoch in range(epochs): model.train() running_loss = 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() # 每个epoch在验证集上评估 model.eval() correct, total = 0, 0 with torch.no_grad(): for images, labels in val_loader: outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() val_acc = correct / total scheduler.step() if val_acc > best_val_acc: best_val_acc = val_acc torch.save(model.state_dict(), "tooth_model.pth") print(f"Epoch {epoch+1:02d} | Loss {running_loss/len(train_loader):.4f} | Val Acc {val_acc:.4f}")这里的关键点有三个。一是model.train()和model.eval()必须成对出现,前者启用Dropout和BatchNorm的训练行为,后者关闭它们,漏掉eval模式会导致推理结果不稳定。二是Adam的weight_decay建议设1e-4而不是0,对正则化有明显帮助;StepLR每8个epoch把学习率乘0.1,让模型在训练后期用更小的步长精细调整权重。三是只保存验证集准确率最高的权重,而不是最后一个epoch的权重,因为训练末尾往往已经过拟合了。
训练过程中如果发现loss下降极慢,第一件事不是改模型结构,而是检查数据加载器是否正常工作。打印一批images和labels,确认图片张量范围在0-1、标签只有0和1两种取值。数据没喂对,调多少超参数都是白费。
4. 前端HTML对接Flask后端:让模型在浏览器里跑起来的完整链路
训练完的模型只是个黑匣子,必须给它包一层交互界面才算真正落地。这里的方案是Flask做后端、HTML页面做前端,浏览器上传图片,后端调用PyTorch模型推理,返回JSON结果给页面渲染。
4.1 HTML页面:图片上传、本地预览与结果展示
前端页面不需要花哨的框架,原生HTML加一个fetch请求就能完成。核心交互是三块:一个file input接收图片,一个img标签做本地预览,一个div展示识别结果。重点是把上传的图片用FormData封装,别用JSON格式传二进制,后端解析麻烦还容易出错。
<!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>牙齿健康识别系统</title> <style> body { font-family: sans-serif; max-width: 640px; margin: 40px auto; padding: 0 16px; } #preview { max-width: 320px; display: none; margin: 16px 0; border-radius: 8px; } .result-box { margin-top: 16px; padding: 16px; border-radius: 8px; } .healthy { background: #e6f4ea; color: #1e7b34; } .unhealthy { background: #fce8e6; color: #c5221f; } </style> </head> <body> <h2>牙齿健康识别</h2> <p>上传一张清晰的牙齿照片,模型将判断为健康或不健康。</p> <input type="file" id="imageInput" accept="image/*"> <br> <img id="preview" alt="图片预览"> <br> <button id="predictBtn">开始识别</button> <div id="result"></div> <script> const input = document.getElementById('imageInput'); const preview = document.getElementById('preview'); const btn = document.getElementById('predictBtn'); const result = document.getElementById('result'); input.addEventListener('change', function () { const file = this.files[0]; if (!file) return; preview.src = URL.createObjectURL(file); preview.style.display = 'block'; result.innerHTML = ''; }); btn.addEventListener('click', async function () { const file = input.files[0]; if (!file) { result.innerHTML = '<p>请先选择图片</p>'; return; } const formData = new FormData(); formData.append('image', file); const resp = await fetch('/predict', { method: 'POST', body: formData }); const data = await resp.json(); if (data.error) { result.innerHTML = '<p>识别失败:' + data.error + '</p>'; return; } const label = data.label === 'healthy' ? '健康' : '不健康'; const cls = data.label === 'healthy' ? 'healthy' : 'unhealthy'; result.className = 'result-box ' + cls; result.innerHTML = '<strong>' + label + '</strong>,置信度:' + (data.probability * 100).toFixed(2) + '%'; }); </script> </body> </html>这段代码的逻辑很直接:用户选择图片后,URL.createObjectURL生成一个本地预览地址,图片不用上传就能显示;点击识别按钮后,FormData携带文件通过fetch发到后端的/predict接口,response.json()解析后端返回值。这里有个细节:preview.src用对象URL之后,最好在下次选择图片时手动释放,也就是URL.revokeObjectURL,否则连续操作多张图片,浏览器内存会有小幅度泄漏。页面样式里用了颜色区分结果,绿色代表健康、红色代表不健康,比纯文字直观得多。
4.2 Flask后端:图片接收、预处理、推理与JSON返回
后端是整个链路的调度中心。它要做四件事:接收上传的文件流、把图片解码并转换为模型输入格式、调用模型前向传播、把结果序列化成JSON返回。要注意的是,Flask默认只处理文本表单,接收文件必须用request.files,而不是request.get_json()。
import io import torch from PIL import Image from flask import Flask, request, jsonify, render_template from torchvision import transforms app = Flask(__name__) model = ToothCNN(num_classes=2) model.load_state_dict(torch.load("tooth_model.pth", map_location="cpu")) model.eval() inference_transforms = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) @app.route("/", methods=["GET"]) def index(): return render_template("index.html") @app.route("/predict", methods=["POST"]) def predict(): try: file = request.files.get("image") if file is None or file.filename == "": return jsonify({"error": "未接收到图片文件"}), 400 # 统一转成RGB,避免PNG带alpha通道导致维度不匹配 img = Image.open(file.stream).convert("RGB") tensor = inference_transforms(img).unsqueeze(0) # 增加batch维度 with torch.no_grad(): outputs = model(tensor) probabilities = torch.softmax(outputs, dim=1) max_prob, pred = torch.max(probabilities, dim=1) label = "healthy" if pred.item() == 0 else "unhealthy" return jsonify({ "label": label, "prediction": int(pred.item()), "probability": float(max_prob.item()) }) except Exception as e: return jsonify({"error": str(e)}), 500 if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=False)代码里有三个值得细说的参数。一是torch.load的map_location指定为"cpu",确保服务器没有CUDA也能加载权重;如果训练时是在GPU上保存的,不写这个参数在无GPU机器上会直接报错。二是inference_transforms必须和训练时的验证集预处理完全一致,特别是Normalize的均值和方差,差一个数,模型输出概率就会明显偏移。三是推理阶段用softmax把logits转成概率再取最大值,这样既能拿到预测类别又能拿到置信度,前端的百分比展示依赖这一步。
还有一个容易被忽略的问题:img = Image.open(file.stream)不会直接读取完整数据,PIL是惰性解码的,实际像素在调用convert或transforms时才真正加载。因此务必在文件流关闭之前完成转换,别把file.stream传到别的地方再去读图片。上面代码里先convert("RGB")已经触发了完整解码,后续操作都安全。
4.3 本地启动与联调:最小命令和三个环境变量
部署到本机运行只需要安装四个依赖:flask、pillow、torch、torchvision。torch的CPU版安装包约200MB,如果不想下载完整CUDA版,可以在PyTorch官网选CPU版本安装,体积小一半,推理速度对本项目来说足够。
# 安装依赖(CPU版PyTorch更省空间) pip install flask pillow torch torchvision # 启动服务 python app.py启动后浏览器访问http://127.0.0.1:5000,看到页面即表示部署成功。这里有几个常见问题提前说:一是如果5000端口被其他进程占用,把app.run中的port改成5001,同时浏览器访问对应端口;二是务必通过Flask提供的地址访问页面,不要直接双击HTML文件用file://协议打开再调后端,浏览器会因跨域策略拦截fetch请求,报错信息是CORS相关的;三是Flask的debug=True适合开发阶段,正式给人演示时改成False,避免异常堆栈直接暴露在页面上。
联调阶段如果前端报错,先在浏览器开发者工具里看Network面板。选中上传请求,检查状态码是不是200,响应内容的JSON是否包含label和probability字段。后端报错会以500状态码返回,开发者工具的Console面板会显示错误堆栈,这是定位问题最直接的入口。
5. 常见问题排查:训练翻车与网页报错的五个真实案例
这个项目从数据到部署,每一步都有经典坑位。我按训练和部署两个阶段,把最常遇到的五个问题整理成“现象-原因-解决”的格式,你对照自己的情况排查会快很多。
5.1 训练阶段:样本不均衡、归一化错位与过拟合
坑一:loss正常下降,但验证集准确率始终停在60%左右,怎么看都像在“猜”。
原因是分类数据集中不健康样本数量远少于健康样本,模型学到的决策边界偏向多数类,几乎把所有图片都判成健康。解决方法是给少数类加权。最常见的做法是在CrossEntropyLoss里传weight参数,让少数类的梯度贡献更大:
class_weights = torch.tensor([1.0, 3.0]) # healthy权重低,unhealthy权重高 criterion = nn.CrossEntropyLoss(weight=class_weights)权重怎么定?先统计两类样本数,比如healthy有800张、unhealthy有300张,权重比例大致设为1.0对2.5左右。更精细的做法是用sklearn的compute_class_weight自动计算。除了改损失函数,也可以用WeightedRandomSampler在采样阶段平衡数据,但实现稍复杂,新手优先改weight参数。
坑二:训练时准确率95%以上,保存模型后单张图片测试却几乎全错。
原因是训练pipeline和推理pipeline的预处理不一致。最常见的是训练时用了normalize,推理时忘了,或者推理时归一化用了不同的均值和方差。模型在训练时看到的是标准化后的张量,推理给的是0-1原始像素值,分布完全错位,输出自然不可信。解决方法是把验证集用的transforms定义在公共文件里,训练脚本和部署脚本都从同一个地方导入,而不是各写一份。
坑三:训练loss降到0.01,验证集loss却在第20个epoch开始反弹。
这是典型的过拟合,模型把训练样本的特征背了下来,却学不到可泛化的模式。牙齿数据集通常只有几百张图片,模型加深后更容易陷入这种状态。解决手段有三个层面:数据层面用更强的数据增强,比如增加RandomResizedCrop模拟不同拍摄距离;模型层面加大Dropout比例到0.6,减少全连接层宽度;训练层面把weight_decay从1e-4调到1e-3,并且在验证集准确率连续5个epoch不提升时触发早停。早停逻辑写起来很简单,在训练循环里维护一个best_acc计数器即可。
5.2 部署阶段:请求路径404、RGBA通道与JSON类型错误
坑四:页面正常打开,点击识别按钮后网络请求返回404,控制台显示POST /predict not found。
原因是表单或fetch的请求路径与Flask路由不匹配。比如前端写的是/predict,后端装饰器是@app.route("/predict/", methods=["POST"]),多了一个斜杠,Flask就找不到对应路由。还有一种情况是前端写成相对路径predict,而当前页面URL带了子路径,导致请求发到了错误地址。解决方法是统一用绝对路径/predict,并确保前后端的methods都包含POST,同时检查Flask应用是否真的加载了包含decode路由的模块——有些项目把模型推理写在另一个文件里,忘记import这个文件,路由自然不存在。
坑五:上传PNG图片报错,提示通道数不对、或JSON返回TypeError: Object of type float32 is not JSON serializable。
PNG图片可能带alpha透明通道,形状是H×W×4,而模型第一层卷积期望3通道输入。虽然代码里写了convert("RGB"),但如果这个转换是条件判断里漏掉的,或者某些特殊PNG格式没有触发标准解码,就会出问题。JSON序列化报错则是概率值类型为torch.float32,Flask的jsonify不认识这种类型。解决方案是在后端固定写死img.convert("RGB"),并在返回前用float()包裹概率值、int()包裹类别索引,确保输出全是Python原生类型。这两个坑通常会同时出现,排查时先看PIL是否成功打开了图片,再看返回值的类型。
6. 用混淆矩阵和单图验证验收模型:把误判样本变成下一轮训练数据
训练结束不代表事情结束,验收才是真正决定这个方案可不可信的关键环节。只看准确率远远不够——如果数据不均衡,准确率会骗人。我一般会对测试集跑一次完整的混淆矩阵,再随机挑几张单图做手动验证,把误判样本单独收集起来。
import torch from sklearn.metrics import confusion_matrix, classification_report model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for images, labels in test_loader: outputs = model(images) _, preds = torch.max(outputs, 1) all_preds.extend(preds.tolist()) all_labels.extend(labels.tolist()) cm = confusion_matrix(all_labels, all_preds) tn, fp, fn, tp = cm.ravel() print(f"真健康:{tn} 假不健康:{fp} 漏检:{fn} 真不健康:{tp}")看混淆矩阵时有三个数字值得盯紧:漏检数(fn)把不健康牙齿判成健康,这是医疗辅助场景最不能接受的;假阳性数(fp)把健康判成不健康,会造成不必要的复查;以及每个类别的精确率和召回率。如果漏检数偏高,说明模型对早期龋齿这类特征不够明显的病变不敏感,需要补充这类样本或调整损失权重。把这些误判样本收集到一个misclassified目录,和原始数据集放在一起,下一轮训练直接加入,模型的短板会逐步补齐。
单图验证则是模拟真实使用场景:从测试集外拿一张照片,走一遍Flask的推理链路,看页面返回结果是否符合直觉。这一步能同时验证预处理代码、模型加载和前端展示三个环节是否稳定。我第一次做这个项目时,就是跳过这一步直接给同事演示,结果模型把一张牙冠修复后的照片判定为不健康,现场尴尬了三秒。后来总结的教训是:任何AI辅助方案在正式使用前,至少要拿20张没参与训练的照片做模拟验证,并记录置信度分布,低于0.75的预测结果宁可不显示,也不要给用户一个错误的确定性答案。希望这套经验能帮你在牙齿健康识别这个方向上少走几步弯路,直接落到能复现、能交付的状态。
本文还有配套的精品资源,点击获取