
简介基于YOLOv8与PyQt5自适应界面设计的常见皮肤病辅助检测系统是一份面向计算机视觉与图形界面开发方向毕业设计的完整参考项目。系统以YOLOv8作为目标检测核心借助PyQt5构建可自适应屏幕尺寸的交互式界面支持静态图像与实时视频检测适用于皮肤病图像识别与辅助诊断场景。压缩包共含85个文件包括Python源码、PyQt5界面文件、训练完毕的模型权重、测试图片以及运行说明文档等整体大小约34.32MB目录结构清晰便于按模块查阅与二次开发。其中包含已完成训练的模型权重、训练与检测脚本、界面布局文件及依赖清单可帮助学习者快速梳理目标检测模型训练、模型加载与桌面应用集成的完整流程。已有183人学习下载适合需要参考完整毕设方案、快速搭建同类系统或深入研究YOLOv8与PyQt5结合应用的读者。1. 从皮肤照片到检测结果这套yolov8pyqt5系统能解决什么手机拍下一块发红的皮肤拖进桌面程序几秒后界面给出“疑似基底细胞癌置信度0.82”同时把病灶区域框出来右侧列表显示各个候选病种的概率——这就是标题里这套系统在做的事。yolov8负责检测病灶并分类pyqt5负责把推理过程包装成可拖拽图像、调阈值、看结果列表的桌面界面“自适应界面设计”则保证窗口无论怎么拉伸图像区和控制面板都不会互相挤压、变形。对正在选毕设题目的人这个组合很合适检测网络成熟、界面开发周期短、演示性强数据和代码都在本地比套在线API更有说服力。先说明白这类系统定位是辅助参考帮你定位疑似区域并给出类别预判不能直接当临床诊断依据。2. 把皮肤镜图变成yolov8能学的东西数据集整理、训练与损失曲线2.1 为什么是yolov8目标检测与皮肤病辅助检测的匹配关系“常见皮肤病辅助检测”落到工程上本质是给一张皮肤图像同时输出“病灶在哪里”和“像哪类病”。两个输出正好对应目标检测的定位分支和分类分支。如果改用整图分类模型模型只会告诉你“整张图整体像什么”等图上同时出现多个病灶、或病灶只占角落一小块时分类模型很容易忽略掉关键区域输出完全跑偏。所以这个方向选检测而不是分类是任务本身决定的不是炫技。选yolov8而不选Faster R-CNN这些经典模型主要理由是它把训练、验证、导出、推理串成一条完整链路数据格式统一预训练权重直接可用。毕设场景里时间有限Faster R-CNN光数据预处理和训练超参调优就要多花不少精力而yolov8可以在半天内把基线跑通。网上很多人在讨论yolov8网络结构图、head改进我建议把这些放在后面先让基础流程跑通再考虑动网络结构。环境配置这一步绕不开用虚拟环境装ultralytics把PyTorch装成匹配你显卡驱动和CUDA的版本然后跑一次官方示例验证安装成功再碰自己的数据。2.2 从公开皮肤数据集到YOLO格式标签转换脚本公开的皮肤镜数据集大多不是YOLO格式。以常用的ISIC系数据为例给到的是原图加分割mask外加一张元数据表里面记录每张图的诊断类别。而yolov8需要的是“每张图片对应一个同名txt每行一个检测框”。所以数据准备的第一步就是写转换脚本。这一步是整条数据链路的地基一旦类别编号或坐标算错后面训练出的模型基本不可信。import cv2 import numpy as np import pandas as pd from pathlib import Path img_dir Path(images) # 原图目录 mask_dir Path(masks) # 分割掩码目录 label_dir Path(labels) # 输出txt目录 label_dir.mkdir(exist_okTrue) meta pd.read_csv(HAM10000_metadata.csv) class_map {nv: 0, mel: 1, bkl: 2, bcc: 3, akiec: 4, df: 5, vasc: 6} for row in meta.itertuples(indexFalse): img_name row.image_id .jpg mask_name row.image_id _mask.png if not (img_dir / img_name).exists(): continue mask cv2.imread(str(mask_dir / mask_name), cv2.IMREAD_GRAYSCALE) if mask is None: continue # 如果mask尺寸和原图不一致先resize对齐 img cv2.imread(str(img_dir / img_name)) h, w img.shape[:2] if mask.shape[:2] ! (h, w): mask cv2.resize(mask, (w, h), cv2.INTER_NEAREST) ys, xs np.where(mask 127) # mask中病灶像素通常为255 if len(xs) 0: # 空mask直接跳过避免空标签 continue x_center ((xs.min() xs.max()) / 2) / w y_center ((ys.min() ys.max()) / 2) / h box_w (xs.max() - xs.min()) / w box_h (ys.max() - ys.min()) / h cls_id class_map[row.dx] line f{cls_id} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f} (label_dir / (row.image_id .txt)).write_text(line \n)这段脚本的核心逻辑是把mask里大于127的像素当作病灶前景取非零区域的外接矩形作为检测框再归一化到0到1之间最终按yolov8要求的“类别 中心x 中心y 宽 高”写进txt。类别编号来自class_map这个顺序必须和你后面训练用的data.yaml里的names完全一致否则训练正常但预测时类别会张冠李戴这也是这一篇里最常见的翻车点之一后面避坑章会单独讲。类别缩写含义class_map中的IDnv色素痣0mel黑色素瘤1bkl良性角化病2bcc基底细胞癌3akiec光化性角化病/鲍恩病4df皮肤纤维瘤5vasc血管性病变6脚本跑完后一定要做一次可视化抽查把txt里的框画回原图上人工看50张左右确认框落在病灶区域而不是黑边或背景上。我一般会再跑一个小脚本随机抽几个类别的样本叠加画框后保存成一张大图快速扫一眼有没有错位。这一步偷懒的话后面所有指标都可能虚高。2.3 训练参数怎么定imgsz、batch、epochs与显存边界数据准备好之后就要开始训练了。以GTX 1660 Ti这种6G显存的显卡为例常见做法是先用yolov8s起步把imgsz设为640batch设为8看能不能跑起来。如果显存报错就往下调。这里给一份我常用的训练命令yolo detect train \ dataskin.yaml \ modelyolov8s.pt \ epochs80 \ imgsz640 \ batch8 \ workers2 \ optimizerAdamW \ lr00.001 \ projectruns/detect \ nameskin_v8s参数选择要理解背后的原因而不是照抄。imgsz640是分辨率和显存的折中点皮肤镜图原图往往有近千像素宽缩到640会损失一部分小病灶细节但直接上10246G显存基本顶不住反而增加训练翻车概率。batch在1660 Ti这种卡上8通常是上限如果还报显存错误就直接降到4。epochs不要死磕8080到150之间都常见判断标准是验证集损失是否还在下降这点下一节会讲。还有两个容易被忽视的点一是yolov8s.pt会自动联网下载如果训练机不能联网先在能联网的机器上下好权重文件放到项目目录二是增强策略里的mosaic会显著拉高显存峰值6G卡上一旦训练途中报显存不足优先关闭mosaic增强而不是无脑调小batch。我用1660 Ti训练时还踩过一个坑workers设得太大数据加载线程和显存争抢资源训练速度反而下降。在Windows上workers一般设2就够了Linux可以调到4或更高。2.4 训练过程怎么盯results.csv画损失函数曲线图与权重选择训练完成后ultralytics会在runs/detect/skin_v8s目录下生成results.csv里面记录了每个epoch的训练损失和验证指标。不要只看最后的mAP真正决定模型是否健康的是损失曲线的走势。我一般用这个脚本把曲线画出来import pandas as pd import matplotlib.pyplot as plt df pd.read_csv(runs/detect/skin_v8s/results.csv) # 不同小版本列名有差异先打印确认 print(df.columns.tolist()) df[epoch] df.index 1 fig, axes plt.subplots(2, 2, figsize(12, 8)) axes[0, 0].plot(df[epoch], df[train/box_loss], labeltrain box_loss) axes[0, 1].plot(df[epoch], df[val/box_loss], labelval box_loss) axes[1, 0].plot(df[epoch], df[train/cls_loss], labeltrain cls_loss) axes[1, 1].plot(df[epoch], df[val/cls_loss], labelval cls_loss) for ax in axes.flat: ax.legend() ax.set_xlabel(epoch) plt.tight_layout() plt.savefig(loss_curves.png)画图前先打印列名是我这几年养成的习惯因为训练日志列在不同小版本里可能有细微差别比如有的版本是train/box_loss有的带括号后缀。判读曲线时重点关注三件事训练和验证损失同步下降说明模型在正常学习训练损失降、验证损失先降后升说明过拟合了可以提前停止或加大数据增强损失曲线大幅震荡不收敛多半是学习率太高把lr0调低一个数量级再跑。训练结束后目录里会有last.pt和best.pt两个权重best.pt是按验证集mAP挑出来的部署时优先用best.pt不要默认拿last.pt。3. pyqt5自适应界面的实现推理线程、图像显示与布局策略3.1 自适应界面不是resize事件堆代码布局器的思路很多人看到“自适应界面设计”第一反应是重写resizeEvent窗口尺寸一变就手动重算每个控件的坐标。这个思路在小界面里勉强能用但控件一多、窗口一拉伸坐标计算就会变成噩梦。PyQt5里真正合理的做法是用布局器你把排列规则告诉layout窗口变化时由layout自行计算每个控件的位置和尺寸。所谓自适应核心就是“合理的布局策略”而不是手写坐标重排逻辑。我拿到这个题目时会先把界面拆成三大块左侧图像显示区、右侧结果列表和置信度控制区。图像显示区要随窗口变化尽量变大结果列表保持适合阅读的宽度底部控制区固定高度。这个比例关系用QHBoxLayout加stretch参数就能表达不需要写任何一行resize代码。pyqt5安装本身没什么门槛pip install pyqt5一行搞定难的是理解布局器的使用方式——新手往往还在用setGeometry硬编码这类代码看起来自以为精准实际上一拖窗口就露馅。3.2 QThread跑yolov8推理为什么不能用while循环直接卡死界面直接在界面线程里调用model.predict()推理期间界面事件循环被阻塞表现就是窗口转圈、无响应、拖动时白屏。原因很简单PyQt5是事件驱动的推理是耗时操作它占着主线程不放界面就没机会重绘和响应鼠标。正确做法是把推理丢到QThread里用信号把结果传回主线程。import queue from PyQt5.QtCore import QThread, pyqtSignal class DetectThread(QThread): result_ready pyqtSignal(object) # 推理结果信号 def __init__(self, model_path, parentNone): super().__init__(parent) self.model None self.model_path model_path self.input_queue queue.Queue() # 待检测图像队列 def push_frame(self, frame): self.input_queue.put(frame.copy()) def run(self): from ultralytics import YOLO self.model YOLO(self.model_path) # 在线程内加载模型 while True: try: frame self.input_queue.get(timeout0.05) except queue.Empty: continue results self.model.predict(sourceframe, conf0.25, imgsz640) self.result_ready.emit(results)把YOLO模型放在run方法里加载是为了避免跨线程传递模型实例这个细节能规避很多诡异的崩溃。主线程只负责push_frame向队列投递图像子线程通过阻塞等待从队列取出图像推理再把结果emit出来。这种队列解耦设计的最大好处是主线程永不等待推理界面始终流畅。程序退出时记得给线程加一个停止标志并调用wait()否则关闭窗口后进程可能还在后台挂着。3.3 检测框叠加与置信度排序在QLabel上的绘制模型推理返回的是一个results对象列表需要自己把检测框和标签画回图像上再转成QPixmap显示。我习惯写一个独立的绘制函数方便同时复用于单张图片和摄像头帧def draw_boxes(frame, results, names): for r in results: for box in r.boxes: x1, y1, x2, y2 [int(v) for v in box.xyxy[0].tolist()] conf float(box.conf[0]) cls_id int(box.cls[0]) label_text f{names[cls_id]} {conf:.2f} cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(frame, label_text, (x1, y1 - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) return frame注意results是一个列表即使只传了一张图也要遍历一层box.xyxy是tensor转成list后才能正常做坐标解包。多病灶场景下皮肤图像上可能出现多个候选框我建议在右侧列表里按置信度降序排列让用户优先看最可疑的区域。这个排序逻辑在结果里加一行sorted就行但对使用体验的提升很明显。绘制完成后用OpenCV的cvtColor把BGR转成RGB再交给下一节的自适应图像控件显示。3.4 自适应布局的核心代码stretch与保持宽高比到了最关键的一步让图像在窗口拉伸时保持宽高比、不被压扁。PyQt5里QLabel有一个setScaledContents方法填True后图像确实会跟着控件缩放但会直接变形——皮肤镜图是方形一拉伸就变成扁图检测框也跟着错位。更稳的做法是自定义一个QLabel子类在paintEvent里手动绘制等比缩放后的图像from PyQt5.QtCore import Qt from PyQt5.QtGui import QPainter, QPixmap from PyQt5.QtWidgets import QLabel class ScaleLabel(QLabel): def __init__(self, text图像区, parentNone): super().__init__(text, parent) self._pixmap None self.setMinimumSize(80, 80) # 避免布局被压缩到不可用 self.setAlignment(Qt.AlignCenter) self.setStyleSheet(background: #1e1e1e; color: #ccc;) def setImage(self, pixmap): self._pixmap pixmap self.update() def paintEvent(self, event): if self._pixmap is None or self._pixmap.isNull(): super().paintEvent(event) return painter QPainter(self) scaled self._pixmap.scaled( self.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) x (self.width() - scaled.width()) // 2 y (self.height() - scaled.height()) // 2 painter.drawPixmap(x, y, scaled) painter.end()关键点在scaled方法里的Qt.KeepAspectRatio缩放后图像保持原始宽高比多出来的区域用背景色填充并居中显示。这比setScaledContents直接拉伸要费几行代码但效果完全不一样。注意不要在paintEvent里调用setPixmap否则控件尺寸会再次被图像大小影响形成循环触发窗口会疯狂闪烁。主窗口的布局骨架这样组织central QWidget() root QHBoxLayout(central) image_label ScaleLabel() table QTableWidget(0, 3) # 类别、置信度、位置 slider QSlider() # 置信度阈值滑块 root.addWidget(image_label, stretch3) root.addWidget(table, stretch1) threshold_row QHBoxLayout() threshold_row.addWidget(QLabel(置信度阈值)) threshold_row.addWidget(slider) root.addLayout(threshold_row) self.setCentralWidget(central)stretch参数是相对比例stretch3和stretch1的意思是窗口变宽时图像区分到三倍于表格的空间。这个比例不用精确够用就行。如果希望图像区和右侧面板之间能手动拖动分隔可以改用QSplitter替代QHBoxLayout两者思想一致都是把布局规则交给框架处理。到了这一步“自适应界面设计”才算真正落地。4. 训练与部署中的五个常见坑现象、原因、解决4.1 类别索引错位标签顺序与名字对不上现象训练一切正常验证集画出来的框位置也基本准确但仔细看类别明明是mel的图被大量标成bkl而且错得很有规律。原因数据集labels里编号0代表nv但data.yaml里的names列表把0写成了mel或者反向错位。yolov8训练时只看数字编号它不关心你的人类命名编号对不上自然就出现系统性误判。解决训练前认真核对class_map和data.yaml的names顺序我习惯把它们打印出来逐行对比。再用一次可视化抽检确认跑完训练后直接看ultralytics根目录下生成的confusion_matrix.png如果矩阵里某一类几乎全部串到另一类基本就是索引错位而不是模型没学好。4.2 样本不平衡模型把什么都预测成“色素痣”现象训练完mAP看着还行但真正打开界面随便传一张图几乎所有检测结果都只有“nv”这一类其他病种基本不出现检测列表没有参考价值。原因公开皮肤数据集的类别分布极度不均。像HAM10000这种规模的数据色素痣样本占了大头少数类可能只有一百多张。模型发现“全报成色素痣”就能拿到很低的损失自然偏向多数类。解决先做数据层面的重采样把少数类图片复制扩充或用小幅度几何增强撑起来这是最直接有效的。如果不想动数据就把预测时的置信度阈值从默认0.25调高到0.5左右能滤掉一部分低置信度的多数类输出。但衡量模型好坏不能只看整体mAP要按病种逐个算召回率少数类照样要盯住。4.3 显卡内存不足6G显存上限导致的训练崩溃现象训练在第一个epoch都没跑完就报CUDA out of memory有时刚启动几秒就崩有时跑到一半才崩。原因GTX 1660 Ti这类6G卡跑yolov8s加batch 8加imgsz 640显存已经接近临界点。崩在中途通常是因为打开了mosaic增强这一步会产生额外的图像拼接缓存显存峰值比不开时高出不少。解决按顺序往下调——先把batch降到4如果还崩就把mosaic增强关掉再不行把imgsz从640降到512最后直接换yolov8n这种更小的模型。注意验证阶段同样会跑前向计算显存不足不一定只发生在训练阶段所以这组参数要同时作用于训练和验证。调整动作建议值说明batch46G显存的保守起点mosaic增强关闭降低训练期显存峰值imgsz512小病灶会受影响但先跑通再优化模型yolov8n最后手段精度有所下降4.4 QPixmap加载失败中文路径与图像旋转现象Windows下用QFileDialog选了一张“皮肤照片.jpg”后图像区空白换成英文路径的图片就能正常显示。或者图片显示出来了但方向是横的。原因QPixmap直接传文件路径时在中文路径下会因编码问题解析失败这是Windows中文环境的经典坑。图片方向问题则是手机照片自带的EXIF旋转信息没有被QPixmap处理JPEG文件里存着的拍摄方向参数被直接忽略了。解决用PIL先把图片读进来再转成QImage再转QPixmap绕开路径解析问题同时让PIL按EXIF自动纠正方向from PIL import Image from PyQt5.QtGui import QImage, QPixmap def load_pixmap(path): img Image.open(path).convert(RGB) data img.tobytes() qimg QImage(data, img.width, img.height, img.width * 3, QImage.Format_RGB888) return QPixmap.fromImage(qimg)convert(RGB)会把图片统一成三通道width乘3就是一行像素的字节数QImage据此正确解析数据。这段代码在Windows上几乎不会出错比直接读路径省心。4.5 训练指标好看但测试不靠谱数据泄漏与脏图现象训练集损失很低验证集mAP50到了0.9以上但自己拿手机拍一张普通照片来测模型把纸片、阴影都当成病灶表现远不如指标那么漂亮。原因两种典型情况。一种是数据泄漏同一个病人的多张皮肤镜图被随机分进了训练集和验证集模型记住的是病人身份而不是病灶特征验证分数自然虚高。另一种是模型学到了伪影皮肤镜图里常见的黑色边框、标尺刻度、毛发遮挡都容易被模型当成捷径。解决按病人ID划分数据集而不是按图片文件随机划分。给一张同一个病人的两张图永远只进同一个数据子集from sklearn.model_selection import GroupShuffleSplit # 按lesion_id分组同一病变的多次拍摄不会拆到两个集合里 gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next( gss.split(meta_data, groupsmeta_data[lesion_id]) )处理完划分还要人工筛一遍训练图把带明显黑边、标尺刻度和大量遮挡的样本清出去或者靠数据增强把它们覆盖掉。记住一件事模型看到的特征和医生看的特征可能完全是两套验证集指标不会告诉你模型到底在看什么这个问题的答案要等到真机测试才能揭晓。5. 用遮挡敏感性测试验证模型真的“在看”皮损区域训练指标好看、真机表现差这个问题怎么在交付前提前发现我的习惯是跑一轮遮挡敏感性测试。思路很简单把检测框里的病灶区域遮住再推理一次看置信度变化多少。如果置信度几乎没掉说明模型在靠病灶周围的环境纹理下结论而不是真的识别了皮损本身这种模型在医院图片或不同设备拍摄的照片上很容易失手。import cv2 from ultralytics import YOLO model YOLO(best.pt) img cv2.imread(test_sample.jpg) results model.predict(sourceimg, conf0.25)[0] for box in results.boxes: x1, y1, x2, y2 [int(v) for v in box.xyxy[0].tolist()] masked_img img.copy() masked_img[y1:y2, x1:x2] 128 # 灰色块遮住病灶区域 r_masked model.predict(sourcemasked_img, conf0.25)[0] conf_before float(box.conf[0]) conf_after 0.0 for nb in r_masked.boxes: if int(nb.cls[0]) int(box.cls[0]): conf_after float(nb.conf[0]) break print(f原置信度: {conf_before:.3f} - 遮挡后: {conf_after:.3f})为什么用灰色128而不是纯黑或纯白因为黑色和白色本身是新的强信号可能引入额外干扰中等灰度的语义是“信息缺失”对模型的干扰最接近中性。判定标准我一般这么看遮挡后同类别的置信度掉到原来的三分之一以下说明模型确实依赖病灶区域如果0.8掉到0.7甚至还在0.7以上那就要警惕模型可能在用周围的皮肤纹理、背景反光甚至是黑边来凑判断。后来我交付皮肤检测模型之前都会跑一遍这个脚本每个病种挑十张图把遮挡前后的置信度变化存下来。这个习惯帮我挡掉了好几次“训练mAP很高、实际只能当玩具”的翻车。一个辅助检测系统与其让用户对五颜六色的mAP曲线不明觉厉不如用这个朴素自检给他看清楚系统到底在重点看哪里。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。