文章

用 4000 张图训一个牙科照片分类器:从 VL 大模型试错到 ResNet-18 落地

用 4000 张图训一个牙科照片分类器:从 VL 大模型试错到 ResNet-18 落地

做口腔诊断系统时遇到一个体验痛点:医生提交病例需要上传 10 类照片(X 光片、面部照片、口腔内照片),每次都要手动逐张选择分类、逐张上传。能不能一次拖入所有照片,系统自动识别分类?

这篇文章记录我从零开始的完整过程:先试 VL 大模型零样本分类碰壁,再用迁移学习训练专用小模型,最终部署上线的全链路实践。

问题定义

10 个分类,涵盖三个大类:

大类分类特征
X 光片侧位片 (ceph)、全景片 (composite)灰度医学影像
面部照片侧面、正面、正面微笑彩色人像照片
口腔内照片上颌、下颌、右侧、正面、左侧口腔特写,通常有开口器

直觉上差异很大——X 光片和彩色照片一眼就能区分。但细看有几对”混淆组”:

  • 侧位片 vs 全景片(都是灰度 X 光)
  • 正面 vs 正面微笑(差别只在嘴巴闭合/露齿)
  • 上颌 vs 下颌(咬合面照片结构相似)
  • 口内左侧 vs 右侧(镜像对称)

第一次尝试:VL 大模型零样本分类

最直觉的方案是调一个视觉语言模型(Vision-Language Model),用 prompt 告诉它分类规则,让它看图返回类别。

我用了阿里云百炼平台上的 qwen3.8-max(支持图片输入),写了一段详细的分类 prompt,包含每个类别的视觉特征描述。用 8 个患者、80 张图做了测试。

结果:73.6%,不可用

分类准确率问题
面部侧面/正面/口腔左右正100%跨大类无压力
正面微笑0%全部判成正面(闭嘴)
下颌12.5%几乎全判成上颌
侧位片62.5%部分误判为全景片

关键发现:

  1. 细粒度区分是 VL 模型的盲区——它理解”这是一张口腔照片”,但分不清上颌和下颌的牙弓形态差异
  2. 单张延迟 20-200 秒——图片越大越慢,即使压缩到 1024px 仍需 20-30s,10 张图要等 3-5 分钟
  3. 每张图几分钱的 API 费用——量少时可忽略,但作为生产功能不理想

VL 模型擅长开放域理解,但在封闭域精细分类上,它不如一个小模型”看过”几千张同场景数据后的判别力。

第二次尝试:训练专用小模型

数据来了免费的

关键洞察:系统的历史数据本身就是标注好的训练集。

文件存储在云端对象存储里,路径约定是 {分类}_{序号}_{时间戳}.jpg——这就是医生在上传时手动选的分类标签。我写了一个脚本扫描存储桶,按路径提取标签,下载并归一化(EXIF 方向修正、长边缩到 640px、统一 JPEG),得到了 500 个患者、约 5000 张图的数据集。

按患者切分,而非按图片

这是一个容易忽略但很关键的点:同一个患者的 10 张照片是同一相机、同一背景、同一光线拍的。如果随机按图片切分训练/测试集,同一患者的照片可能同时出现在两边,模型学的是”认出某个患者”而非”识别照片类型”,导致测试准确率虚高。

正确做法:用患者 ID 的哈希值决定 train/val/test 归属(80/10/10),保证同一患者的所有照片只在一个集里。这样测试集里全是模型从没见过的患者,数字可信。

模型选择:ResNet-18 + ImageNet 预训练

10 个分类、4000 张训练图——不需要 ViT 也不需要 ResNet-50。ResNet-18 有 11.7M 参数,ImageNet 预训练后微调,在这个数据量级上就是甜点。

关键决策记录:

1
2
3
# 骨干:ResNet-18,替换最后的全连接层
model = models.resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)
model.fc = nn.Linear(model.fc.in_features, 10)  # 10 分类

数据增强:有一个不能做的事

常规增强:随机裁剪缩放、轻微旋转(±10°)、颜色抖动——这些模拟了不同拍摄条件。

但不能做水平翻转。原因:翻转会把”口内右侧照”变成”口内左侧照”的样子,标签却没变——等于在教模型犯错。这是领域特异的约束,容易被忽略。

1
2
3
4
5
6
7
8
TRAIN_TRANSFORM = transforms.Compose([
    transforms.RandomResizedCrop(224, scale=(0.8, 1.0)),
    transforms.RandomRotation(10),
    transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.05),
    # ⚠️ 不做水平翻转 — 会交换 left/right 语义
    transforms.ToTensor(),
    transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD),
])

训练配置

参数值理由
优化器AdamW, lr=3e-4自适应学习率,对微调友好
权重衰减1e-4L2 正则,抑制过拟合
学习率调度CosineAnnealingLR后期精细收敛
Batch size128M-chip Mac 上 MPS 后端跑得动
Epochs15best-checkpoint 机制兜底过拟合

全程在 MacBook Pro(Apple Silicon)上用 MPS 后端训练,每个 epoch 约 60 秒,总共不到 15 分钟。

冒烟测试的惊喜

第一次拿 840 张训练图跑 3 个 epoch(还没下完整数据集),val 就到了 94.1%——VL 大模型 0% 的正面微笑分类,小模型直接 100%。这证实了方向正确。

数据清洗:最耗时但最值得

训练完用模型反扫全量数据时发现了一个意外的问题:约 7% 的历史标签是错的。

错误模式:

  • 医生把全景片上传到了侧位片的槽位(可能界面上挨着,手滑)
  • 同一张图被传到了两个不同槽位(重复上传)
  • 手机拍的屏幕截图当成了 X 光原片上传
  • 下颌照片上传到了上颌的位置

这些”脏标签”会系统性地压低测试准确率——模型判对了,但因为标签本身是错的,被算成了”分类错误”。

清洗流程

  1. 模型反扫:用当前模型对全量数据推理,找出”预测 ≠ 标签”的嫌疑清单(约 340 张)
  2. 文件哈希去重:自动找出同图跨槽位的副本(发现 221 对),保留模型预测类别的那份,删另一份
  3. 人工复核嫌疑清单:逐张看图确认,错的删掉
  4. 二次扫描:清洗后重训模型,再扫一轮——更强的模型能发现上一轮漏掉的噪声

两轮清洗后,训练集从 4298 张降到 ~4000 张(净减 ~7%),但质量大幅提升。

清洗前后对比

指标清洗前清洗后
val 准确率91.6%98.5%
test 准确率90.2%94.3%
ceph recall73%100%
composite recall90%98%

test 和 val 的差距(98.5 vs 94.3)说明 test 里仍有少量漏网的脏标签在拖后腿——模型本身的真实能力已经接近 98%。

教训:数据质量 > 数据数量 > 模型复杂度。 花 1 小时清洗比花 1 天调模型结构有效得多。

部署:ONNX + 量化

为什么导出 ONNX

PyTorch 模型直接部署需要完整的 Python + torch 运行时(几百 MB)。导出为 ONNX 后:

  • 推理只依赖 ONNX Runtime(几十 MB),不需要 torch
  • 跨语言:Python、C++、JavaScript(浏览器)都能跑
  • 推理优化:ORT 自动做算子融合、内存复用等
1
2
3
4
torch.onnx.export(model, dummy_input, "classifier.onnx",
                  input_names=['image'], output_names=['logits'],
                  dynamic_axes={'image': {0: 'batch'}},
                  opset_version=17, dynamo=False)

注意 dynamo=False:PyTorch 2.x 默认使用新的 dynamo exporter,它会把权重拆到一个 .onnx.data 文件里。加这个参数可以得到一个自包含的单文件,部署时不怕漏拷。

量化:体积 vs 精度的权衡

变体体积准确率延迟/张
fp32 (原始)42.6 MB94.3%5.8ms
fp1621.3 MB94.3%5.7ms
int8 dynamic10.7 MB93.3%5.5ms
int8 static (含校准)10.7 MB93.3%1.7ms

fp16 是免费午餐:体积减半,精度和延迟完全不变。原因是 ResNet-18 的权重动态范围完全在 fp16 能表达的范围内,转换不引入任何舍入误差。

int8 再砍半到 10.7MB,但丢了 1pt。对于将来浏览器端部署(用户需要下载模型),10.7MB 比 21.3MB 又小一倍,1pt 的精度差异可以接受。

一个容易被误导的认知:体积缩小 ≠ CPU 推理变快。fp16 模型在 CPU 上推理时,ORT 会把权重反量化回 fp32 再算——省的是加载时间和内存占用,不是计算时间。int8 确实快(1.7ms vs 5.8ms),因为 int8 乘加有专用指令。但当推理本身只占请求总耗时的 5%(其余是网络 IO)时,这个加速用户无感。

服务集成

分类器作为一个 ONNX session 加载到现有的 Python FastAPI 服务中(与人脸打码模型共存),新增一个 /classify 端点。预处理必须和训练时完全一致:

1
EXIF 方向修正 → 短边缩到 256px → 中心裁剪 224×224 → ImageNet 均值方差归一化 → NCHW

任何一步不一致(比如漏了 EXIF 修正),都会导致线上推理结果和训练时不同——我在调试时就踩过这个坑:一张有 EXIF rotation tag 8 的照片,不做方向修正的话旋转了 90°,模型直接把 frontal_smile 判成了 frontal。

前端交互

最终实现的用户流程:

  1. 拖入或多选多张照片
  2. 前端生成缩略图(canvas 压到 640px),base64 送后端分类
  3. 置信度 ≥ 0.7 且目标槽位为空 → 自动填入对应卡片
  4. 置信度低或槽位已占 → 进入”待确认”托盘,用户拖放或点选放置
  5. 直接拖文件到某个卡片 = 跳过分类上传(保留手动习惯)

把分类和上传做成并行:分类请求和 presigned URL 上传同时发起,用户看到的延迟约等于网络往返(~500ms),而非之前逐张手选的重复操作。

复盘与思考

VL 大模型不是银弹

“万能模型+好 prompt” 的思路在开放域任务上很强,但在封闭域精细分类上,一个只有 11M 参数但”见过”你数据的小模型,能以 100-500 倍的速度、更高的准确率碾压它。在已有领域数据时,训练专用模型几乎总是更好的选择。

你的历史数据是金矿

很多系统里已经积累了大量”人工标注过”的数据,只是标注行为发生在业务流程里(用户选了一个分类再上传),不像传统 ML 项目那样有独立的标注环节。识别这些”隐性标注”并加以利用,是很多业务系统引入 ML 的捷径。

数据清洗的 ROI 最高

整个项目最大的准确率跳跃不是来自模型调参或更大的骨干网络,而是来自清洗掉 7% 的脏标签(val 91.6% → 98.5%)。”用模型反扫数据找脏标签”是一个实用的 pattern:

  1. 先在脏数据上训一版
  2. 用这版模型预测全量数据
  3. “预测 ≠ 标签” 的样本大概率是标签错了(模型在大量正确样本里学到了真实 pattern)
  4. 人工复核清单、清洗、重训

这个循环可以迭代多次,每次模型更强、找到的残余噪声更多。

预处理一致性是隐形杀手

训练和推理的预处理必须逐步骤对齐。我遇到的坑:

  • EXIF 方向:手机拍的照片可能带 rotation tag,不处理就是旋转后的像素
  • 缩放算法:PIL 的 LANCZOS 和 OpenCV 的 INTER_AREA 在边缘处理上有微小差异
  • 色彩空间:有的图是 RGBA 或灰度,必须统一转 RGB

建议:把预处理封装成一个函数,训练和服务端共享同一份代码。

最终数字

指标值
模型ResNet-18 fp16
体积21.3 MB
训练数据~4000 张(清洗后)
训练时间15 分钟(Apple Silicon MPS)
val 准确率98.5%
test 准确率94.3%(含残余脏标签;干净子集 ~98%)
推理延迟6ms/张(CPU ONNX Runtime)
VL 大模型对照73.6%,20-30s/张

从”能不能做”到”上线可用”,大约两天的工作量(含数据下载、两轮清洗、VL 实验、训练、部署、前端集成)。瓶颈不是算力也不是模型,而是数据清洗的人工复核时间。

本文由作者按照 CC BY 4.0 进行授权