简介:这份资源是一套基于深度学习的皮肤病识别系统完整项目包,面向计算机相关专业的毕业设计、课程设计与期末大作业场景,适合具备一定Python与深度学习基础的学生参考。项目采用卷积神经网络结合YOLO目标检测思路,实现皮肤病变图像的自动识别,并配套微信小程序客户端,覆盖服务端模型训练、推理与前端交互的完整链路。压缩包共49个文件,约285KB,以py脚本为主,涵盖模型定义、训练、预测与集成等模块,另有js、json、wxml、wxss等小程序页面与配置文件,以及png图片、csv标注数据和说明文档,目录按服务端与微信客户端划分,结构清晰。目前已有88人学习下载。读者可从中获取从数据预处理、模型训练到服务端部署与小程序对接的完整实现思路,理解病变区域标记与诊断结果展示的落地方式,并借鉴隐私加密与数据保护的处理策略,为同类医疗图像识别项目提供可复用的参考框架。
1. 皮肤病识别系统到底在识别什么:从一张手机照片到可解释的辅助结论
皮肤科门诊里最耗时的环节往往不是治疗,而是分诊和初筛。一个基层医生一天要看几十上百张皮疹照片,其中大部分是湿疹、银屑病、痤疮这类常见病,但总混着几例需要转诊的色素性病变。基于深度学习的皮肤病识别系统,要解决的就是这个「先分堆、再聚焦」的问题:输入一张皮损照片,输出若干候选诊断类别及置信度,把明显良性的留在基层,把可疑的推给上级。它适合两类人:一类是想把现成模型跑起来做辅助工具的临床信息化工程师,另一类是手里有几千张标注图、想自己训一版模型的算法同学。标题里的「系统」二字意味着它不是单个脚本,而是数据、模型、推理服务、前端交互串起来的一条链路。这一章先把边界划清楚,后面几章再拆每一步怎么落地。
需要先泼一盆冷水:皮肤病识别是典型的细粒度、长尾、强光照敏感任务。同一类银屑病在不同部位、不同肤色、不同相机白平衡下,像素分布差异可能比两类病之间的差异还大。所以任何声称「一个模型通吃所有皮肤病」的方案,在真实场景里都会翻车。靠谱的做法是先限定病种范围,比如只做十到二十类常见病,把数据质量和标注一致性做扎实,再谈模型结构。这也是我后面所有参数和步骤的前提。
2. 数据准备与标注规范:决定上限的不是网络结构而是这批图
2.1 为什么数据清洗比换 backbone 更值钱
我见过太多团队一上来就纠结用 ResNet 还是 EfficientNet,结果训练集里混着几十张带水印、带标尺、带诊断文字的图,模型很快学会「看到标尺就判恶性」这种捷径特征。皮肤病图像里最常见的污染源有三类:一是拍摄时带入的尺子、棉签、手套;二是电子病历截图自带的文字和边框;三是同一患者多角度连拍导致的近重复样本。近重复样本会让验证集指标虚高,因为训练集和验证集里其实是同一个皮损的不同角度。
清洗流程我一般分四步走:先按文件哈希去完全重复,再用感知哈希(pHash)去近重复,然后人工过一遍缩略图墙剔除带器械和文字的图,最后按患者 ID 而不是按图片做训练/验证划分。最后这条是血泪经验,按图片随机划分会让同一患者的图同时出现在两边,指标能虚高十几个点。
import imagehash from PIL import Image from collections import defaultdict def dedup_by_phash(paths, threshold=6): # threshold 越小越严格,6 左右对皮肤图比较合适 buckets = defaultdict(list) hashes = {} for p in paths: try: h = imagehash.phash(Image.open(p).convert("RGB")) except Exception: continue hashes[p] = h kept = [] for p, h in hashes.items(): dup = False for q in kept: if h - hashes[q] <= threshold: # 汉明距离 dup = True break if not dup: kept.append(p) return kept这段代码用感知哈希做近重复剔除,threshold 设 6 是经验值:太小漏删,太大误删不同皮损。注意 pHash 对旋转和裁剪不敏感,如果你的数据里有大量旋转图,得先做方向校正再算哈希。去重后建议保留一份被删清单,方便回溯是不是误删了稀有类别。
2.2 标注规范怎么定才不返工
皮肤病标注最大的坑是「一类多名」和「边界病例」。比如「湿疹」和「特应性皮炎」在很多数据集里混用,标注员如果各标各的,模型学出来就是一团浆糊。我的做法是先出一份标注手册,把每个类别的纳入标准、排除标准、典型图例、易混类别写清楚,然后让两个标注员独立标 200 张做一致性检验。如果 Cohen's kappa 低于 0.7,说明手册还不够细,回去改手册而不是硬标。
标注格式建议直接用 CSV 或 JSONL,字段至少包含:图片路径、患者匿名 ID、类别标签、标注员、标注时间、是否疑难。疑难样本单独放一个池子,训练时可以先不用,留作后续主动学习。类别不平衡是常态,常见病可能是罕见病的几十倍,这个后面在损失函数里处理。
2.3 划分数据集时最容易忽略的泄漏点
除了患者泄漏,还有两个隐蔽泄漏:一是同一批次拍摄的图(同一天、同一设备)如果被分到训练和验证两边,模型会学到批次相关的色彩偏移;二是数据增强如果在划分之前做,增强出的副本可能跨集。正确顺序是:先去重、再按患者分组、再划分、最后才在训练集内部做增强。验证集和测试集只做 resize 和归一化,不做翻转旋转,否则指标不可比。
3. 模型选型与训练:在有限数据上把迁移学习用到位
3.1 为什么我默认从 ImageNet 预训练权重起步
皮肤病公开数据集规模通常在几千到几万张,这个量级从零训练卷积网络基本没戏,会严重过拟合。常见做法是用 ImageNet 预训练权重做初始化,只替换最后的分类头。backbone 选择上,如果算力有限,EfficientNet-B0 或 B1 的性价比很高;如果追求精度且能上大显存,ConvNeXt-T 或 Swin-T 值得一试。但别迷信新结构,我做过对比,在清洗干净的数据上,ResNet-50 和 EfficientNet-B3 的差距往往在 1 到 2 个点以内,而数据清洗带来的提升可能是十几个点。
分类头我一般写成「Dropout + Linear」,Dropout 率 0.3 到 0.5。如果类别数少于 30,不加中间隐层反而更稳。损失函数用带类别权重的交叉熵,权重取类别频率的倒数再归一化,缓解长尾。优化器用 AdamW,学习率分两组:backbone 用 1e-5 到 3e-5,分类头用 1e-3,这样预训练特征不会被大学习率冲垮。
import torch import torch.nn as nn from torchvision import models def build_model(num_classes, backbone="efficientnet_b0", dropout=0.4): if backbone == "efficientnet_b0": net = models.efficientnet_b0(weights=models.EfficientNet_B0_Weights.IMAGENET1K_V1) in_feat = net.classifier[1].in_features net.classifier = nn.Sequential( nn.Dropout(dropout), nn.Linear(in_feat, num_classes) ) else: net = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) in_feat = net.fc.in_features net.fc = nn.Sequential(nn.Dropout(dropout), nn.Linear(in_feat, num_classes)) return net def make_optimizer(net, backbone_lr=2e-5, head_lr=1e-3): head_params, backbone_params = [], [] for name, p in net.named_parameters(): if "classifier" in name or "fc" in name: head_params.append(p) else: backbone_params.append(p) return torch.optim.AdamW([ {"params": backbone_params, "lr": backbone_lr}, {"params": head_params, "lr": head_lr} ], weight_decay=1e-4)这里的关键是分组学习率,backbone 小学习率微调,分类头大学习率快速收敛。weight_decay 设 1e-4 是常规值,数据量特别小可以加到 1e-3。注意 EfficientNet 的 classifier 是个 Sequential,索引 1 才是 Linear,替换时别搞错层。
3.2 数据增强要贴着皮肤图的特点来
通用增强里,随机水平翻转、小角度旋转、颜色抖动都可用,但有几条要克制:垂直翻转对皮肤图语义不友好(皮损上下颠倒不自然),大角度旋转会引入黑边,颜色抖动幅度过大会破坏色素性病变的颜色特征——而颜色恰恰是鉴别黑色素瘤的重要线索。我一般把颜色抖动的 brightness、contrast、saturation 都控制在 0.1 到 0.2,hue 几乎不动。
更值得做的是针对性增强:随机裁剪模拟不同拍摄距离,轻微高斯模糊模拟对焦不准,随机遮挡小块模拟毛发遮挡。这些比盲目堆 AutoAugment 更贴合真实分布。验证阶段只做 resize 到 224 或 300 加归一化,归一化参数用 ImageNet 的均值和方差即可,除非你的数据色彩分布和自然图像差得离谱。
3.3 训练循环里必须盯住的三个量
训练时我只看三个量:验证集上的宏平均 F1、每个类别的召回、以及混淆矩阵里最容易被混的两类。准确率在长尾数据上会骗人,一个把所有样本判成最常见类的模型也能有不错的准确率。宏平均 F1 对每个类别一视同仁,更能反映真实能力。如果某个罕见类召回长期为 0,要么是样本太少,要么是标注有问题,得回去查数据而不是调模型。
早停策略用验证集宏 F1,patience 设 8 到 10 个 epoch。学习率调度用余弦退火,配合前 3 个 epoch 的 warmup,能明显减少初期震荡。batch size 在显存允许下尽量大,32 或 64 都行,太小会让 BatchNorm 统计不稳。
4. 推理服务与前端交互:把模型变成医生愿意点开的工具
4.1 推理接口怎么设计才不拖后腿
模型训完只是半成品,要变成「系统」得有服务层。我用 FastAPI 起一个推理服务,接收图片上传,返回 top-k 类别和置信度。关键点有三个:一是图片预处理必须和训练时完全一致,包括 resize 的插值方式、归一化参数,任何不一致都会让指标掉一截;二是要设置信度阈值,低于阈值的统一返回「建议转诊」而不是硬给一个类别;三是加超时和限流,防止大图拖垮服务。
from fastapi import FastAPI, UploadFile, File from PIL import Image import torch, io from torchvision import transforms app = FastAPI() model = torch.load("skin_model.pt", map_location="cpu").eval() preprocess = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]), ]) @app.post("/predict") async def predict(file: UploadFile = File(...)): img = Image.open(io.BytesIO(await file.read())).convert("RGB") x = preprocess(img).unsqueeze(0) with torch.no_grad(): logits = model(x) probs = torch.softmax(logits, dim=1)[0] topk = torch.topk(probs, k=3) results = [{"label": str(i), "score": float(s)} for s, i in zip(topk.values, topk.indices)] if topk.values[0] < 0.5: # 阈值按验证集调,别拍脑袋 return {"advice": "低置信度,建议转诊", "candidates": results} return {"advice": "参考诊断", "candidates": results}预处理里的 Resize(256) + CenterCrop(224) 是 ImageNet 的标准流程,和训练时的 RandomResizedCrop 不完全一样,但验证阶段用中心裁剪是常规做法。阈值 0.5 只是示例,实际要在验证集上画置信度-准确率曲线来定,宁可多转诊也别漏诊。
4.2 前端展示要克制,别把概率当诊断
前端我建议只做三件事:上传图片、显示 top-3 候选及概率条、显示一句免责说明。不要做成「AI 诊断结果:银屑病」这种肯定句,概率条也要标注「模型置信度,非临床诊断」。医生对这个工具的信任是慢慢建立的,一次误导性的高置信度错误就可能让它被弃用。交互上支持拖拽上传和手机拍照上传,返回结果控制在两秒内,超过三秒医生就不愿意用了。
5. 避坑与排查:那些让指标和体验同时崩掉的细节
5.1 验证集指标很高,上线就拉胯
现象:离线宏 F1 到 0.9,真实使用中医生反馈错得离谱。原因通常是数据泄漏或分布偏移——训练验证同患者、同批次,或者线上图片的拍摄设备和训练集完全不同。解决:按患者划分、留一个时间上更晚的测试集、上线前用真实渠道的图做一次盲测。如果盲测掉太多,说明数据代表性不够,得补采。
5.2 模型对某个类别几乎全错
现象:混淆矩阵里某一类召回接近 0。原因可能是该类样本太少、标注标准和其他类重叠、或者该类图像有系统性特征(比如都带某种背景)。解决:先人工看几十张该类错分图,确认是标注问题还是特征问题;标注问题就重标,样本太少就做过采样或收集更多,特征问题就针对性增强。
5.3 推理结果每次刷新都不一样
现象:同一张图两次请求返回不同类别。原因多半是模型没设 eval 模式,Dropout 和 BatchNorm 还在训练态。解决:加载后立刻.eval(),并用torch.no_grad()包住推理。这个坑很隐蔽,因为训练态下结果只是「略有不同」,容易被忽略。
5.4 图片方向不对导致预测全乱
现象:手机拍的图在服务端被按 EXIF 旋转,模型看到的是倒的或侧的人像。原因:PIL 打开时不会自动应用 EXIF 方向。解决:用ImageOps.exif_transpose(img)先校正方向再预处理。这个坑在真实手机数据上出现频率极高。
5.5 置信度普遍偏低,全是转诊
现象:几乎所有图 top-1 都低于阈值。原因可能是归一化参数和训练不一致,或者模型欠拟合。解决:先核对预处理,再用训练集跑一遍看置信度分布,如果训练集也低,说明模型没学好,回去查训练配置。
6. 进阶技巧:用温度缩放和分级转诊把系统调到能用
模型输出的 softmax 概率往往过度自信或过度保守,直接拿来当阈值依据并不稳。温度缩放是个便宜好用的后处理:在验证集上拟合一个温度参数 T,对 logits 除以 T 再 softmax,能让概率分布更接近真实准确率。实现上就是一行除法加一个标量优化,几乎不增加推理成本。
import torch, torch.nn as nn def fit_temperature(logits, labels): # logits: N x C 的验证集输出,labels: N T = nn.Parameter(torch.ones(1)) optimizer = torch.optim.LBFGS([T], lr=0.01, max_iter=50) ce = nn.CrossEntropyLoss() def closure(): optimizer.zero_grad() loss = ce(logits / T, labels) loss.backward() return loss optimizer.step(closure) return T.item()拟合出的 T 大于 1 说明模型过度自信,除以 T 后概率被压平;小于 1 则相反。校准后再定阈值,转诊策略会稳很多。我一般把输出分成三档:高置信度直接给参考诊断,中置信度给候选并提示「需结合临床」,低置信度直接建议转诊。分档阈值在验证集上按「漏诊率优先」的原则调,宁可多转几个也别放过可疑恶性。
另一个值得投入的方向是分级转诊而不是单点分类。把病种按风险分成「良性常见」「需观察」「建议转诊」三层,模型先做三层分类,再做层内细分。这样即使层内分错,风险等级也不会错得太离谱,临床上更可接受。最后说个我自己的习惯:每次改完数据或模型,我都会固定用同一批 200 张「哨兵图」跑一遍,记录 top-1 和转诊率,任何一次改动如果让哨兵图结果大幅波动,先停下来查原因再继续。这个习惯帮我拦下过好几次数据泄漏和预处理不一致的问题。希望帮到你。
本文还有配套的精品资源,点击获取