基于CNN的牙齿健康识别:从数据集到Web部署全流程

发布时间:2026/9/28 16:43:44
基于CNN的牙齿健康识别:从数据集到Web部署全流程
简介这份资源是一套基于Python与PyTorch实现的牙齿健康识别项目采用卷积神经网络完成图像分类并配套HTML网页交互界面适合深度学习入门者、计算机视觉方向学生及想快速搭建图像识别Demo的开发者参考。压缩包共269个文件以262张jpg牙齿图片构成数据集另含3个py脚本、3个txt说明与1个html页面整体约13.89MB体积轻便便于本地运行。项目流程清晰先运行脚本生成数据集文本再训练模型并保存权重与日志最后启动服务生成网页访问地址在浏览器中即可完成识别交互。数据集按类别分文件夹存放覆盖健康与异常牙齿样本训练日志记录每个epoch的验证集损失与准确率方便观察收敛过程。目前已有108人学习适合作为课程设计、毕业项目或CNN实战练手素材。1. 牙齿健康识别项目到底在做什么从一张牙片到一个 Web 判断结果牙科诊所里最常见的场景不是治疗而是「这张片子到底有没有问题」的初筛。患者拍完口腔全景片或口内照医生要逐颗牙看龋坏、牙结石、牙龈红肿一天几十张下来眼睛都花了。这个项目要解决的就是这件事用 Python 搭一个卷积神经网络把牙齿图片分成健康和不健康两类再套一个 HTML 网页界面让非技术的人也能上传图片、点一下按钮、看到结果。它适合三类人想找一个完整 CNN 落地案例的 Python 学习者需要给诊所或教学做演示的开发者以及手里已经有一批牙齿图片、想快速验证「能不能自动分」的从业者。标题里带的数据集是关键——没有标注好的牙齿图片后面所有训练都是空谈。整套链路是「图片数据集 → 预处理 → CNN 训练 → 模型保存 → Web 后端加载 → HTML 前端上传 → 返回判断」每一环都有具体的坑下面按落地顺序拆开讲。2. 数据集怎么准备牙齿图片的采集、清洗与标注边界2.1 牙齿图片数据集的三个现实来源与取舍拿到「含图片数据集」这几个字很多人第一反应是直接开训。但牙齿图片和猫狗分类完全不是一回事它的类间差异极小——健康牙龈和轻度发炎的牙龈在低分辨率下几乎一个颜色。常见的数据来源有三种一是公开的口腔医学影像数据集这类数据标注规范但数量有限且多数是 X 光片而非口内照二是从牙科教学材料或公开图库里筛优点是清晰度高缺点是健康/不健康的标签往往要靠自己判断三是自己用手机或内窥镜在诊所采集数量能上去但光照、角度、色温极不稳定。我一般会优先用公开数据打底再用自采数据做补充。原因是公开数据的标签可信度高能保证模型至少学到「什么算异常」的基本特征而自采数据用来覆盖真实场景的光照和角度。如果只用自采数据很容易出现模型记住了某台设备的偏色换一张图就翻车。选来源时看三个指标单类样本数是否过 300、图片短边是否大于 224 像素、标签是否由具备牙科背景的人确认过。前两个决定模型能不能收敛第三个决定你的准确率是不是自欺欺人。2.2 用 Python 做数据清洗与统一尺寸原始图片直接丢进网络是最常见的翻车点。牙齿图片里经常混着带水印的、纯黑的、拍糊的还有尺寸从 200×150 到 4000×3000 不等的。下面这段脚本做三件事过滤掉过小和过暗的图、统一缩放到 224×224、按类别分目录存放。import os import cv2 import shutil import numpy as np SRC_DIR raw_images # 原始图片按 healthy / unhealthy 分子目录 DST_DIR dataset # 清洗后的输出目录 IMG_SIZE 224 # CNN 常用输入尺寸 MIN_SIDE 200 # 短边低于此值直接丢弃 MIN_BRIGHTNESS 40 # 平均亮度低于此值视为废图 for label in [healthy, unhealthy]: src_label_dir os.path.join(SRC_DIR, label) dst_label_dir os.path.join(DST_DIR, label) os.makedirs(dst_label_dir, exist_okTrue) for fname in os.listdir(src_label_dir): fpath os.path.join(src_label_dir, fname) img cv2.imread(fpath) if img is None: continue # 读不出来的损坏文件 h, w img.shape[:2] if min(h, w) MIN_SIDE: continue # 太小放大后全是马赛克 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if np.mean(gray) MIN_BRIGHTNESS: continue # 太暗牙齿细节丢失 resized cv2.resize(img, (IMG_SIZE, IMG_SIZE)) cv2.imwrite(os.path.join(dst_label_dir, fname), resized) print(清洗完成)逻辑上先按类别遍历保证输出目录结构和输入一致这样后面 Keras 的flow_from_directory能直接读。MIN_SIDE设 200 是因为短边再小缩放到 224 时插值会引入大量伪影模型学到的可能是插值噪声而不是牙齿特征。MIN_BRIGHTNESS设 40 是经验值牙齿图片整体偏亮低于这个值基本是拍摄失败。这两个阈值不要照抄先跑一遍统计一下你数据集的亮度分布再定。提示清洗脚本一定要先备份原始数据。我见过有人把DST_DIR写成SRC_DIR一轮下来原图全被覆盖成 224 的缩略图后悔药都没得吃。2.3 训练集/验证集划分与类别不平衡处理牙齿数据几乎必然不平衡——健康样本远多于不健康样本因为没人会专门去拍健康的牙。如果直接按 8:2 随机划分验证集里不健康样本可能只有十几张准确率波动极大。常见做法是分层抽样保证两个集合里健康/不健康比例一致再对不健康类做适度增强。import splitfolders # 按 8:2 分层划分seed 固定保证可复现 splitfolders.ratio( dataset, outputsplit, seed1337, ratio(0.8, 0.2), group_prefixNone )splitfolders会自动保持类别比例输出split/train和split/val。seed固定是为了每次划分结果一致否则调参时你分不清是模型变了还是数据变了。如果两类差距超过 3:1我会在训练时用class_weight补偿而不是简单复制不健康样本——复制会让模型对那几张图过拟合。3. 卷积神经网络怎么搭从结构选型到训练参数3.1 为什么牙齿识别不适合从零搭一个深网络卷积神经网络原理说起来不复杂卷积核在图上滑动提取局部特征池化降维全连接做分类。但牙齿图片有个特点——判别区域非常局部可能只是牙缝里一小块发黑或者牙龈边缘一条红线。这意味着网络需要足够大的感受野去覆盖整颗牙又需要足够细的浅层特征去抓那条红线。从零搭一个十几层的网络在几百到几千张牙齿图上几乎必然过拟合。我一般直接用迁移学习拿在 ImageNet 上预训练好的骨干网络换成二分类输出冻结前面大部分层只训练最后几层。这样即使数据只有几百张也能在几个 epoch 内看到像样的准确率。标题里强调「卷积神经网络训练」迁移学习就是最稳的落地方式。选骨干时看两点输入尺寸是否匹配你的 224×224参数量是否在你的机器上跑得动。轻量骨干适合 CPU 推理和 Web 部署稍重的骨干准确率上限更高但部署时加载慢。牙齿识别这种二分类任务轻量骨干通常够用。3.2 用 Keras 搭一个可训练的牙齿分类模型下面这段代码用迁移学习搭模型骨干换成任意 Keras 自带的预训练网络即可。数据增强写在生成器里避免手动扩图占磁盘。import tensorflow as tf from tensorflow.keras import layers, models from tensorflow.keras.preprocessing.image import ImageDataGenerator IMG_SIZE 224 BATCH 16 # 训练集增强牙齿图片对水平翻转敏感度低可以开 train_gen ImageDataGenerator( rescale1./255, rotation_range15, width_shift_range0.1, height_shift_range0.1, zoom_range0.1, horizontal_flipTrue, brightness_range[0.8, 1.2] # 模拟不同拍摄光照 ) val_gen ImageDataGenerator(rescale1./255) train_data train_gen.flow_from_directory( split/train, target_size(IMG_SIZE, IMG_SIZE), batch_sizeBATCH, class_modebinary ) val_data val_gen.flow_from_directory( split/val, target_size(IMG_SIZE, IMG_SIZE), batch_sizeBATCH, class_modebinary ) # 迁移学习加载预训练骨干不含顶层 base tf.keras.applications.MobileNetV2( input_shape(IMG_SIZE, IMG_SIZE, 3), include_topFalse, weightsimagenet ) base.trainable False # 先冻结只训练分类头 model models.Sequential([ base, layers.GlobalAveragePooling2D(), layers.Dropout(0.3), layers.Dense(1, activationsigmoid) # 二分类 ]) model.compile( optimizertf.keras.optimizers.Adam(1e-3), lossbinary_crossentropy, metrics[accuracy] ) model.summary()base.trainable False是第一阶段的关键只训练最后的全连接层学习率可以设大一点。Dropout(0.3)是防过拟合的常规手段牙齿数据少的时候可以加到 0.5。brightness_range这个增强对牙齿图片特别有用因为真实场景里手机闪光灯和诊室灯拍出来的亮度差很多不加这个增强模型换个光源就懵。3.3 训练参数怎么设学习率、epoch 与早停第一阶段的训练脚本如下重点是回调函数。from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint, ReduceLROnPlateau callbacks [ EarlyStopping(monitorval_loss, patience5, restore_best_weightsTrue), ModelCheckpoint(best_model.h5, monitorval_accuracy, save_best_onlyTrue), ReduceLROnPlateau(monitorval_loss, factor0.5, patience3, min_lr1e-6) ] history model.fit( train_data, validation_dataval_data, epochs30, callbackscallbacks )patience5表示验证损失连续 5 个 epoch 不降就停这是防止过拟合的第一道闸。ReduceLROnPlateau在损失停滞时把学习率砍半比手动调稳得多。ModelCheckpoint只存验证准确率最高的那一版避免最后几轮过拟合的权重被保存下来。第一阶段跑完后如果验证准确率卡在某个值上不去可以解冻骨干的后几层做微调学习率降到 1e-5 量级。微调时一定要用小学习率否则预训练学到的特征会被冲掉等于白搭。注意class_modebinary时flow_from_directory按目录名的字母顺序决定 0 和 1。healthy 和 unhealthy 谁在前谁在后直接影响你后面 Web 端输出的含义训练完务必打印train_data.class_indices确认一遍。4. 从模型到 WebHTML 前端与 Python 后端的对接4.1 用 Flask 把训练好的模型包成接口模型训练完是个.h5文件Web 页面没法直接调它。中间需要一个后端服务接收前端上传的图片预处理成 224×224喂给模型再把概率返回去。Flask 是最轻的选择几十行就能跑起来。from flask import Flask, request, jsonify, render_template import numpy as np import cv2 from tensorflow.keras.models import load_model app Flask(__name__) model load_model(best_model.h5) # 启动时加载一次别每次请求都加载 app.route(/) def index(): return render_template(index.html) # 返回 HTML 页面 app.route(/predict, methods[POST]) def predict(): file request.files[image] img cv2.imdecode(np.frombuffer(file.read(), np.uint8), cv2.IMREAD_COLOR) img cv2.resize(img, (224, 224)) img img.astype(float32) / 255.0 img np.expand_dims(img, axis0) # 加 batch 维度 prob float(model.predict(img)[0][0]) label 不健康 if prob 0.5 else 健康 return jsonify({label: label, score: round(prob, 4)}) if __name__ __main__: app.run(host0.0.0.0, port5000)load_model放在模块顶层服务启动时加载一次。如果写在predict函数里每次请求都要重新读几百 MB 的权重页面会卡到没法用。np.expand_dims那一步最容易漏模型要的是(batch, 224, 224, 3)单张图是(224, 224, 3)不加维度会直接报错。prob 0.5是二分类的默认阈值实际用的时候可以根据场景调——宁可误报也别漏报的话阈值往下压。4.2 HTML 页面怎么写上传、预览与结果展示前端不需要框架一个原生 HTML 文件就够。核心是三块文件选择、图片预览、结果区域。!DOCTYPE html html langzh-cn head meta charsetutf-8 title牙齿健康识别/title /head body h2上传牙齿图片/h2 input typefile idfileInput acceptimage/* brbr img idpreview stylemax-width:300px; display:none; br button onclicksubmitImage()开始识别/button p idresult/p script const fileInput document.getElementById(fileInput); const preview document.getElementById(preview); // 选图后本地预览不用等上传 fileInput.onchange () { const file fileInput.files[0]; if (file) { preview.src URL.createObjectURL(file); preview.style.display block; } }; async function submitImage() { const file fileInput.files[0]; if (!file) { alert(请先选择图片); return; } const formData new FormData(); formData.append(image, file); const resp await fetch(/predict, { method: POST, body: formData }); const data await resp.json(); document.getElementById(result).innerText 判断结果${data.label}置信度 ${data.score}; } /script /body /htmlURL.createObjectURL做本地预览用户选完图立刻能看到不用等服务器返回。fetch提交的是FormData后端用request.files接字段名要和formData.append的第一个参数一致这里都是image。结果区域直接显示标签和置信度置信度低于 0.6 的时候建议前端加一句「建议人工复核」避免把模棱两可的结果当成定论。4.3 本地跑通与部署时的路径问题本地跑的时候Flask 默认只监听 127.0.0.1同一局域网的其他设备访问不了。host0.0.0.0改成监听所有网卡手机连同一个 WiFi 就能打开测试。部署到服务器时模板文件必须放在templates目录下render_template(index.html)才能找到这是 Flask 的硬约定放错位置就是 500 错误。模型文件路径建议用绝对路径或基于__file__拼接别用相对路径。用python app.py在项目根目录跑没问题一旦用 gunicorn 或 systemd 启动工作目录变了相对路径就找不到模型报错信息还特别含糊。5. 避坑与排查牙齿识别项目里最容易翻车的五件事5.1 准确率 95% 但一用就废现象验证集准确率很高拿真实诊所的图一测几乎全判成健康。原因训练集和真实数据的分布差太远训练集可能是清晰的公开图真实场景是手机随手拍、有反光、有口水。解决把真实场景的图哪怕只有几十张也混进验证集重新评估训练时加强亮度、模糊、对比度增强让模型见过「脏」数据。5.2 上传图片后接口报 500现象前端点识别后端日志显示数组维度错误。原因model.predict收到的输入形状不对多半是漏了np.expand_dims或者 OpenCV 读图返回了 None文件格式不支持。解决在predict里先判断img is None再打印img.shape确认加维度那一步写成固定代码别靠记忆。5.3 模型文件加载慢到超时现象服务启动要等一两分钟前端请求超时。原因每次请求都调load_model或者模型本身太大。解决load_model提到全局只执行一次如果骨干太重换成轻量骨干重新训练Web 场景下推理速度比那一点点准确率重要。5.4 健康和不健康标签反了现象明显有问题的牙被标成健康。原因flow_from_directory按字母序分配 0/1训练时的正类和你 Web 端prob 0.5对应的类不一致。解决训练完打印class_indices把映射关系写死在代码注释里Web 端判断前先确认一遍。5.5 换台机器就跑不起来现象本地好好的部署到服务器报缺库或版本冲突。原因TensorFlow、OpenCV、NumPy 版本没锁。解决用pip freeze requirements.txt导出环境部署时按这个文件装TensorFlow 和 NumPy 的大版本兼容性尤其要注意别用最新版硬凑。6. 让识别结果更可信阈值调整与人工复核的配合模型输出的是一个 0 到 1 的概率0.5只是数学上的中点不是业务上的最佳分界。牙齿识别这种场景漏判把不健康判成健康的代价远高于误判所以阈值应该往下压。我一般会先在验证集上画一条不同阈值下的召回率曲线找到召回率能到 0.9 以上、精确率还不至于崩的那个点通常落在 0.3 到 0.4 之间。import numpy as np from sklearn.metrics import precision_recall_curve # y_true: 验证集真实标签, y_prob: 模型输出的概率 precision, recall, thresholds precision_recall_curve(y_true, y_prob) # 找召回率 0.9 时精确率最高的阈值 mask recall[:-1] 0.9 best_idx np.argmax(precision[:-1][mask]) best_threshold thresholds[mask][best_idx] print(建议阈值:, round(best_threshold, 3))这段代码的逻辑是先把所有阈值下的精确率和召回率算出来然后只在召回率达标的那部分里挑精确率最高的。recall[:-1]去掉最后一位是因为precision_recall_curve返回的数组长度比thresholds多一个。跑出来的阈值直接替换 Web 端的0.5判断逻辑不用改。除了阈值还有一个更实用的技巧把置信度分成三档。高于高阈值的直接给结论低于低阈值的提示「图片不清晰请重拍」中间那段标成「疑似异常建议人工复核」。这样既不放过可疑样本也不会让模型在它没把握的时候硬给一个答案。牙齿识别最终是要辅助人不是替代人把不确定的部分交回给医生才是这个项目能真正落地的姿态。我自己做这类项目最大的教训是别在模型结构上反复折腾数据质量和阈值调整带来的提升往往比换一个更深的网络大得多。先把数据洗干净、把真实场景的图混进验证集、把阈值调到符合业务再考虑要不要换骨干。希望帮到你。本文还有配套的精品资源点击获取