简介:这是一套基于Python与PyQt构建的图像语义分割桌面软件源码,面向计算机、人工智能、通信、自动化等专业的在校学生与开发者,可用于毕业设计、课程设计、作业或项目初期立项演示,也适合具备一定基础的小白进阶学习。资源包共153个文件,约8.74MB,以37个py源码文件为核心,辅以85个svg界面图标、7个yaml配置、3个md说明文档及qss样式、ui界面、qrc资源等,结构完整、便于二次开发。软件支持mobilenet、resnet50等8种模型,覆盖从界面交互到模型推理的完整流程。目前已有248人学习下载。代码均经测试运行成功,答辩评审平均分达96分,下载后可按README说明快速上手,并在此基础上修改扩展功能,适合作为语义分割方向的实践参考与学习模板。
1. 从一张截图到可运行软件:Python+PyQt 图像语义分割工具到底解决什么问题
你手里有一张街景照片,想把里面的人、车、路面、天空逐像素分开,最直接的办法是跑一个语义分割模型。但模型跑完输出的是张灰度掩码图,每个像素值对应一个类别 ID,直接看根本分不清谁是谁。这时候就需要一个 GUI 工具,把原图和彩色掩码并排显示,最好还能切换模型、调参数、导出结果。基于 Python + PyQt 的图像语义分割软件就是干这个的:它把 MobileNet、ResNet50 等 8 个骨干网络封装成统一接口,用 PyQt 搭出桌面界面,让不写代码的人也能完成分割推理。适合做课程设计、算法验证、标注辅助的从业者,也适合想学 PyQt 工程化落地的 Python 开发者。热词里 python gui、pyqt、mobilenet、resnet50 都指向同一个需求:把模型装进界面,而不是停在 notebook 里。
2. 八个模型怎么选:MobileNet 与 ResNet50 的取舍逻辑
2.1 骨干网络差异与推理速度实测对比
语义分割模型通常由骨干网络(backbone)和分割头(head)组成。骨干负责提取特征,分割头负责上采样还原分辨率。MobileNet 系列用深度可分离卷积替代标准卷积,参数量和计算量大幅下降;ResNet50 用残差连接解决深层网络退化问题,特征表达能力更强但计算量大约是 MobileNetV2 的 4 到 5 倍。在 512×512 输入下,用同一台无独立显卡的笔记本 CPU 推理,MobileNetV2 单帧约 0.3 到 0.5 秒,ResNet50 约 1.5 到 2.5 秒。如果做实时预览或低配设备部署,优先选 MobileNet;如果追求精度且能接受等待,选 ResNet50。8 个模型里通常包含 MobileNetV2、MobileNetV3、ResNet50、ResNet101 及其变体,选型时先看你的硬件和延迟容忍度,再看数据集复杂度。
2.2 用统一接口封装 8 个模型的代码骨架
不管选哪个骨干,对外都暴露同样的predict(image) -> mask接口。下面是一个模型工厂的骨架,用字典注册 8 个模型,避免在 GUI 里写一堆 if-else。
# model_factory.py import torch import torchvision.models.segmentation as seg_models # 注册表:模型名 -> 构造函数 MODEL_REGISTRY = { "mobilenet_v2": lambda num_classes: seg_models.deeplabv3_mobilenet_v3_large( weights=None, num_classes=num_classes ), "resnet50": lambda num_classes: seg_models.deeplabv3_resnet50( weights=None, num_classes=num_classes ), # 其余 6 个模型按同样方式注册,保持键名与 GUI 下拉框一致 } def build_model(name: str, num_classes: int, ckpt_path: str = None): if name not in MODEL_REGISTRY: raise ValueError(f"未知模型: {name}") model = MODEL_REGISTRY[name](num_classes) if ckpt_path: state = torch.load(ckpt_path, map_location="cpu") model.load_state_dict(state) model.eval() return model逻辑说明:MODEL_REGISTRY把模型名映射到构造函数,GUI 下拉框的选项直接从这个字典的键生成,新增模型只需加一行。build_model负责实例化并加载权重,eval()关闭 dropout 和 batchnorm 的训练行为。参数说明:num_classes必须和训练时一致,否则最后一层卷积维度对不上;ckpt_path为 None 时使用随机权重,仅用于界面联调,正式推理必须传入训练好的权重。常见做法是把权重文件放在weights/目录下,文件名与模型名对应,加载时自动匹配。
2.3 预处理与后处理的参数对齐
模型推理前要把 PIL 图像转成 tensor 并归一化,推理后要把输出 logits 转成类别掩码再映射成颜色。这两步的参数必须和训练时完全一致,否则精度会莫名其妙下降。
# inference.py import numpy as np import torch from torchvision import transforms # 与训练一致的归一化参数 preprocess = transforms.Compose([ transforms.Resize((512, 512)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) # 21 类 VOC 调色板,按类别 ID 索引 PALETTE = np.array([ [0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0], [0, 0, 128], [128, 0, 128], [0, 128, 128], [128, 128, 128], [64, 0, 0], [192, 0, 0], [64, 128, 0], [192, 128, 0], [64, 0, 128], [192, 0, 128], [64, 128, 128], [192, 128, 128], [0, 64, 0], [128, 64, 0], [0, 192, 0], [128, 192, 0], [0, 64, 128] ], dtype=np.uint8) def predict(model, image): tensor = preprocess(image).unsqueeze(0) # 增加 batch 维度 with torch.no_grad(): output = model(tensor)["out"] # DeepLabV3 输出是字典 mask = output.argmax(dim=1).squeeze(0).cpu().numpy() color_mask = PALETTE[mask] # 类别 ID 映射为 RGB return mask, color_mask逻辑说明:preprocess中的 Resize 尺寸、Normalize 均值方差必须与训练脚本一致,这是最常见的精度翻车点。output["out"]是因为 torchvision 的 DeepLabV3 返回 OrderedDict,如果换成分割头不同的模型,这里要相应调整。argmax(dim=1)在类别维度取最大值得到每个像素的类别 ID。参数说明:PALETTE的行数必须大于等于num_classes,否则索引越界;如果自定义数据集类别数不是 21,需要同步修改调色板。推理时用torch.no_grad()关闭梯度,能省约 30% 显存并提速。
3. PyQt 界面搭建:从下拉框到图像显示的完整链路
3.1 主窗口布局与信号槽连接
PyQt 的核心是信号槽机制:用户操作控件触发信号,槽函数响应。主窗口用 QVBoxLayout 纵向排列,顶部放模型选择下拉框和推理按钮,中间用 QLabel 显示原图和分割结果,底部放状态栏显示推理耗时。
# main_window.py from PyQt5.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QComboBox, QPushButton, QLabel, QFileDialog, QStatusBar) from PyQt5.QtGui import QPixmap, QImage from PyQt5.QtCore import Qt import time class MainWindow(QMainWindow): def __init__(self, model_names): super().__init__() self.setWindowTitle("图像语义分割工具") self.resize(1000, 600) self.model = None central = QWidget() self.setCentralWidget(central) layout = QVBoxLayout(central) # 顶部控制栏 top_bar = QHBoxLayout() self.model_combo = QComboBox() self.model_combo.addItems(model_names) # 从模型注册表键名填充 self.btn_load = QPushButton("加载图片") self.btn_run = QPushButton("开始分割") top_bar.addWidget(self.model_combo) top_bar.addWidget(self.btn_load) top_bar.addWidget(self.btn_run) layout.addLayout(top_bar) # 图像显示区 img_layout = QHBoxLayout() self.label_origin = QLabel("原图") self.label_result = QLabel("分割结果") for lb in (self.label_origin, self.label_result): lb.setAlignment(Qt.AlignCenter) lb.setMinimumSize(400, 400) lb.setStyleSheet("border: 1px solid #ccc;") img_layout.addWidget(self.label_origin) img_layout.addWidget(self.label_result) layout.addLayout(img_layout) self.setStatusBar(QStatusBar()) self.btn_load.clicked.connect(self.on_load) self.btn_run.clicked.connect(self.on_run) self.current_image = None def on_load(self): path, _ = QFileDialog.getOpenFileName(self, "选择图片", "", "Images (*.png *.jpg *.jpeg)") if path: self.current_image = path pixmap = QPixmap(path).scaled(400, 400, Qt.KeepAspectRatio) self.label_origin.setPixmap(pixmap) def on_run(self): if not self.current_image: self.statusBar().showMessage("请先加载图片") return # 实际推理逻辑在下一节接入 self.statusBar().showMessage(f"当前模型: {self.model_combo.currentText()}")逻辑说明:QComboBox.addItems直接接收模型名列表,保证界面选项与后端注册表同步。on_load用QFileDialog选图并缩放显示,Qt.KeepAspectRatio防止图片变形。on_run先做空值检查再触发推理,避免用户没选图就点按钮导致崩溃。参数说明:setMinimumSize(400, 400)保证图像区域不会因窗口缩小而消失;scaled的宽高是显示尺寸,不影响实际推理用的原图分辨率。
3.2 把推理结果渲染到 QLabel 的完整代码
推理返回的是 numpy 数组,要转成 QImage 再转 QPixmap 才能显示。这一步的坑在于 numpy 数组的内存布局和 QImage 的通道顺序。
# 在 MainWindow 中补充 import numpy as np from PyQt5.QtGui import QImage, QPixmap def numpy_to_qpixmap(self, arr: np.ndarray) -> QPixmap: """arr: HxWx3 uint8 RGB""" h, w, ch = arr.shape # QImage 需要连续内存,且通道顺序为 RGB bytes_per_line = ch * w qimg = QImage(arr.data, w, h, bytes_per_line, QImage.Format_RGB888) return QPixmap.fromImage(qimg.copy()) # copy 防止底层内存被回收 def on_run(self): if not self.current_image: self.statusBar().showMessage("请先加载图片") return from PIL import Image from model_factory import build_model from inference import predict model_name = self.model_combo.currentText() self.statusBar().showMessage(f"正在加载 {model_name} ...") self.model = build_model(model_name, num_classes=21, ckpt_path=f"weights/{model_name}.pth") image = Image.open(self.current_image).convert("RGB") t0 = time.time() mask, color_mask = predict(self.model, image) cost = time.time() - t0 pixmap = self.numpy_to_qpixmap(color_mask) self.label_result.setPixmap(pixmap.scaled(400, 400, Qt.KeepAspectRatio)) self.statusBar().showMessage(f"{model_name} 推理完成,耗时 {cost:.2f}s")逻辑说明:numpy_to_qpixmap中QImage直接引用 numpy 数组的内存,如果不copy(),数组被垃圾回收后 QPixmap 会显示花屏或崩溃,这是 PyQt 图像显示的血泪经验。on_run里先加载模型再推理,耗时统计包含模型加载时间,如果只想统计推理时间,把t0移到predict之前。参数说明:Format_RGB888对应三通道 RGB,如果输入是 RGBA 要改成Format_RGBA8888;bytes_per_line必须显式传入,否则宽不是 4 的倍数时图像会错位。
3.3 模型切换时的资源释放与线程安全
在 GUI 里直接跑推理会阻塞主线程,界面卡死。正确做法是把推理放到 QThread 里,通过信号把结果传回主线程更新界面。同时切换模型时要释放旧模型,避免内存越用越多。
# worker.py from PyQt5.QtCore import QThread, pyqtSignal from PIL import Image from inference import predict class InferenceWorker(QThread): finished = pyqtSignal(object, float) # color_mask, cost failed = pyqtSignal(str) def __init__(self, model, image_path): super().__init__() self.model = model self.image_path = image_path def run(self): try: image = Image.open(self.image_path).convert("RGB") import time t0 = time.time() _, color_mask = predict(self.model, image) self.finished.emit(color_mask, time.time() - t0) except Exception as e: self.failed.emit(str(e))逻辑说明:QThread子类重写run,在里面执行耗时推理,完成后emit信号。主线程连接finished信号到更新界面的槽函数,连接failed到错误提示。参数说明:pyqtSignal(object, float)中 object 用于传 numpy 数组,float 传耗时。切换模型时先self.worker.wait()等待旧线程结束,再del self.model并调用torch.cuda.empty_cache()(如果用了 GPU),否则显存不会立即释放。
4. 避坑与排查:8 个模型跑起来才会遇到的 5 个问题
4.1 现象:切换模型后推理结果全是黑色
原因:新模型权重没加载成功,load_state_dict时 key 不匹配但被strict=False静默忽略,或者权重文件路径写错导致加载了随机权重。解决:加载权重时打印missing_keys和unexpected_keys,确认没有大量缺失;检查weights/目录下文件名是否与下拉框选项完全一致,注意大小写。
4.2 现象:界面点击按钮后卡死十几秒
原因:推理在主线程执行,PyQt 事件循环被阻塞,窗口无法重绘。解决:按 3.3 节用 QThread 把推理放到子线程,主线程只负责界面更新。如果不想引入线程,至少在耗时操作前调用QApplication.processEvents(),但这只是权宜之计,长时间任务仍会卡顿。
4.3 现象:分割结果颜色和预期类别对不上
原因:调色板PALETTE的索引顺序与训练时类别 ID 映射不一致,或者模型输出通道数大于调色板行数导致越界。解决:确认训练时类别 ID 到名称的映射表,按同样顺序排列调色板;在predict里加断言assert mask.max() < len(PALETTE),越界时直接报错而不是显示错误颜色。
4.4 现象:ResNet50 模型加载时报显存不足
原因:ResNet50 参数量大,如果同时保留了之前 MobileNet 的模型实例,显存叠加导致 OOM。解决:切换模型前先del旧模型并调用torch.cuda.empty_cache();如果显存仍然不够,把输入尺寸从 512 降到 320,或者改用 CPU 推理并接受更慢的速度。
4.5 现象:打包成 exe 后找不到权重文件
原因:PyInstaller 打包后工作目录变了,相对路径weights/xxx.pth指向临时解压目录。解决:用sys._MEIPASS获取打包后的资源根目录,权重文件通过--add-data参数打包进去,代码里用os.path.join(sys._MEIPASS, "weights", name)拼接路径。开发阶段和打包阶段用同一套路径解析函数,避免两套逻辑。
5. 进阶技巧:用 ONNX 导出把推理速度再压一截
PyTorch 模型在 CPU 上推理有框架开销,导出成 ONNX 后用 onnxruntime 跑,MobileNet 类模型通常能再快 20% 到 40%。导出时注意动态轴设置,否则换输入尺寸会报错。
# export_onnx.py import torch from model_factory import build_model model = build_model("mobilenet_v2", num_classes=21, ckpt_path="weights/mobilenet_v2.pth") dummy = torch.randn(1, 3, 512, 512) torch.onnx.export( model, dummy, "mobilenet_v2.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch", 2: "height", 3: "width"}, "output": {0: "batch", 2: "height", 3: "width"}}, opset_version=11 )逻辑说明:dynamic_axes把 batch 和空间维度设为动态,这样同一个 onnx 文件可以处理不同尺寸输入。opset_version=11兼容性较好,如果用到较新的算子可以升到 12 或 13。导出后用onnxruntime.InferenceSession加载,输入输出名与导出时一致。参数说明:dummy的尺寸只影响导出时的图结构,不影响动态轴的实际推理尺寸;如果导出后精度下降明显,检查是否有算子不被支持而回退到了 CPU 实现。
验证 ONNX 和 PyTorch 输出是否一致,用同一张图分别推理,比较 argmax 后的掩码差异像素占比,低于 0.1% 可以认为无损。
import numpy as np import onnxruntime as ort sess = ort.InferenceSession("mobilenet_v2.onnx") # 假设 tensor 是预处理后的 numpy 数组,形状 1x3x512x512 onnx_out = sess.run(["output"], {"input": tensor.numpy()})[0] torch_out = model(torch.from_numpy(tensor.numpy()))["out"].detach().numpy() diff = (onnx_out.argmax(1) != torch_out.argmax(1)).mean() print(f"掩码差异像素占比: {diff:.4%}")我一般会在导出后跑这个对比脚本,差异超过 1% 就回去查算子兼容性,而不是直接替换推理后端。踩过一次坑:某版本 opset 下 interpolate 算子行为不一致,导致上采样结果偏移半个像素,掩码边缘全错。后来固定 opset 版本并加了这个对比步骤,再没翻车过。希望帮到你。
本文还有配套的精品资源,点击获取