基于PyQt的本地图像语义分割工具箱:8个深度学习模型对比实践
简介基于Python与PyQt框架实现的图像语义分割软件内置八种主流分割模型附带完整工程源代码和详细说明文档主要面向计算机、人工智能、自动化、电子信息等专业的在校学生与开发者既可支撑毕业设计、课程设计也适合作为图像分割入门与算法对比的学习项目。压缩包共包含一百五十三个文件体积约八点七四兆其中三十七个源代码脚本负责核心逻辑与界面交互七个模型配置文件用于参数加载八十五个界面图标文件完善视觉交互另有帮助文档、样式表、测试图片、标签数据等方便按模块查阅与调试。目前已有两百四十八人学习下载作者上传前已完成功能测试保证了主要运行流程的可靠性。借助这套资料使用者可以直接启动并体验完整的图像分割流程通过切换不同模型观察分割精度与速度差异也能在现有代码基础上替换数据集、增加新模型适合二次开发与功能扩展。1. 先说清楚这是一个能“框选即出结果”的本地语义分割工具箱拿一张航拍图在软件左侧下拉框选MobileNet点开始分割几秒后逐像素的类别颜色就叠回原图上再切到ResNet50重跑一次边界的细节立刻看出差距。这就是基于PythonpyQt作为GUI框架的图像语义分割软件最常见的用法把mobilenet、resnet50等8个分割模型收进一个界面里轮着对比效果。它解决的痛点是模型选型和效果验收应该发生在可视化“回合制”里而不是每一轮都改命令行推理脚本。适合算法工程师做效果对比也适合遥感地物识别、工业质检的开发者验证哪个backbone更适合自己的数据和显存。2. 8个模型怎么组织、GUI怎么分工先搞清楚谁在干活拿到这类软件源码我一般先不看界面代码先看两个层模型层和界面层怎么解耦。模型层负责把8个网络统一成“输入一张图、输出一张mask”界面层只负责把mask画出来。谁把这两层写成了互相调用的“面条”谁的后半程就在改bug。2.1 8个模型不是8个孤立网络用backbone分割头工厂模式组织“8个模型”拆开看其实是几套backbone配上几套分割头。常见的一套配置会这样组织展示名backbone分割头典型用途MBV2-LRASPPMobileNetV2LR-ASPPCPU快速预览MBV3-DeepLabV3MobileNetV3-LargeDeepLabV3低显存GPUR50-DeepLabV3ResNet50DeepLabV3精度/速度均衡R50-PSPNetResNet50PSPNet大目标场景解析R101-DeepLabV3ResNet101DeepLabV3高精度离线推理XC-DeepLabV3XceptionDeepLabV3边界细化MBV2-UNetMobileNetV2UNet小样本数据R50-UNetResNet50UNet医学/遥感看到规律了吗选型其实是在三个维度上做权衡mobilenet系轻量、显存占用小适合机器上没有独立显卡的同事拿来做快速验证resnet50系精度上了一个台阶但推理延迟会明显上涨UNet在数据量小、目标形态规则的数据上反而比DeepLab系好调。实现上8个模型就是一个注册表界面拿到的是同一个统一接口MODEL_REGISTRY { MBV2-LRASPP: lambda n_cls: create_mbv2_lraspp(n_cls), MBV2-UNet: lambda n_cls: create_unet(backbonemobilenetv2, n_clsn_cls), R50-DeepLabV3: lambda n_cls: create_deeplabv3plus(backboneresnet50, n_clsn_cls), R50-PSPNet: lambda n_cls: create_pspnet(backboneresnet50, n_clsn_cls), # R101 / Xception / MBV3 等其余 4 个按同样方式补全 } def get_model(name: str, n_cls: int): if name not in MODEL_REGISTRY: raise KeyError(f未知模型名: {name}) return MODEL_REGISTRY[name](n_cls)逻辑说明dict的value是lambda外部只传“展示名”和“类别数”不关心内部是DeepLab还是PSPNetGUI层调用get_model拿到的都是同一个forward接口这样下拉框切换模型时界面代码一行不用改。参数说明里最值得在意的是n_cls很多源码把类别数写死成21或80换到自己数据上就崩所以工厂函数必须把n_cls作为显式参数。2.2 pyQt界面层与推理层的分工QThread是标配不是可选图像分割的推理动辄几百毫秒到几秒如果直接在按钮回调里跑model(x)界面会白屏转圈、移动窗口时拖影这是新手最容易写出来的翻车结构。pyQt是单线程事件循环耗时操作必须丢到工作线程。常见做法是把“推理函数”包进一个通用Workerclass InferWorker(QThread): finished pyqtSignal(object) def __init__(self, fn, *args, **kwargs): super().__init__() self.fn fn self.args args self.kwargs kwargs def run(self): try: result self.fn(*self.args, **self.kwargs) except Exception as e: result e self.finished.emit(result)逻辑说明run在子线程里执行真正的分割函数算完后通过finished信号把结果或异常交回主线程主线程的槽函数里只做画图、更新状态栏这类轻量操作。参数说明fn是任何可调用对象所以换模型、加个预处理后处理都不用改这个类。一个隐藏坑是Worker不能是局部变量否则函数退出后会被垃圾回收导致线程提前消失我习惯用self._workers列表保存引用。2.3 预处理管线为什么同一张图两个环境跑出来不一样8个模型虽然结构不同但吃进去的都是同一个格式等比缩放、右下padding、归一化、NCHW。归一化参数必须和权重训练时一致最常见的配置是ImageNet的mean/std。def preprocess(image: np.ndarray, size: int 512) - torch.Tensor: h, w image.shape[:2] scale size / max(h, w) nh, nw int(h * scale), int(w * scale) img cv2.resize(image, (nw, nh), interpolationcv2.INTER_LINEAR) pad_h, pad_w size - nh, size - nw img cv2.copyMakeBorder( img, 0, pad_h, 0, pad_w, cv2.BORDER_CONSTANT, value(127, 127, 127) ) img img.astype(np.float32) / 255.0 mean np.array([0.485, 0.456, 0.406], dtypenp.float32) std np.array([0.229, 0.224, 0.225], dtypenp.float32) img (img - mean) / std return torch.from_numpy(img.transpose(2, 0, 1))[None]逻辑说明先按最长边等比缩放到512把短边剩下的区域用灰色填充而不是直接cv2.resize成512x512这样推理生成的mask回贴到原图时坐标比例不会因为拉伸而错乱。参数说明size一般取512或1024取512时显存占用低、CPU也能跑取1024对小目标更友好但延迟成倍上涨。padding值(127,127,127)是灰度中值如果你训练时用的是零填充这里要改成0否则边缘会出现噪声类别的伪影。跑通了这条预处理管线8个模型的差异就只剩网络结构本身换模型时界面和前后处理都不用动。3. 把软件跑起来从环境准备到首次出图的完整链路这一章的节奏是先搭环境再跑通最小推理脚本最后才接GUI。按这个顺序走每一步的报错面都很小不会出现“界面崩了但不知道是模型还是线程的问题”这种处境。3.1 环境准备Python版本、pyQt、推理库的最小依赖这类项目最省心的组合是Python 3.9以上、PyQt5、PyTorch。网上python安装教程很多但装完之后最容易踩的是环境串了先装Anaconda又装官方Python命令行里python指向哪里全看PATH顺序。我一般直接用conda建独立环境conda create -n seg python3.9 -y conda activate seg pip install PyQt55.15 opencv-python4.6 numpy1.21 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118逻辑说明PyQt5选5.15系列是因为它在Windows/Linux/macOS三个平台的兼容性最好新项目也不必追PySide6。torch的index-url指定了CUDA 11.8的轮子机器没显卡时把最后一行换成CPU版即可。参数说明opencv-python不带contrib包做语义分割GUI完全够用少装一个头大的contrib依赖就少一处版本冲突。依赖装完先看一眼源码的目录结构。能交接的源码一般长这样文档说明也从这三个文件开始读模型工厂model_factory.py、GUI主窗口main_window.py、权重与类别映射weights/和class_names.txt。如果拿到的源码里没有class_names.txt文档里也没写类别顺序那跑出来的颜色大概率是乱的——这一点在4.5里还会展开。3.2 先不管GUI用最小脚本验证模型加载与单图推理GUI还没调通之前先把推理链路跑通。这一步能过滤掉大量“界面报错”其实是“模型没加载对”的假问题。import cv2 import numpy as np import torch from model_factory import get_model from preprocess import preprocess device cuda:0 if torch.cuda.is_available() else cpu model get_model(R50-DeepLabV3, n_cls21).eval().to(device) ckpt torch.load(weights/r50_dlv3plus.pt, map_locationdevice) model.load_state_dict(ckpt, strictFalse) img cv2.imread(demo.jpg)[:, :, ::-1] # BGR - RGB x preprocess(img, size512).to(device) with torch.no_grad(): out model(x) logits out if isinstance(out, torch.Tensor) else out[out] mask logits.argmax(dim1)[0].cpu().numpy().astype(np.uint8) palette np.random.randint(0, 255, (21, 3), dtypenp.uint8) overlay palette[mask] cv2.imwrite(mask_vis.png, overlay[:, :, ::-1]) print(mask shape:, mask.shape, unique classes:, np.unique(mask))逻辑说明get_model先从注册表拿网络再load_state_dict填权重。这一步最容易翻车的点是训练时权重外层带module.前缀多卡训练产物或键名不匹配strictFalse能跳过缺失的层但控制台必须确认加载成功的条数如果显示几百条全failed那就是backbone对错了。参数说明n_cls21是常见VOC格式换成自己的数据前先确认类别文件mask最后转成uint8因为后面上色和叠加都要用这张索引图float会白白多占4倍内存。为什么这段脚本值得在GUI之前跑因为GUI里所有报错都被pyQt的异常机制包了一层错误信息往往又长又绕。命令行先验证“模型确实能出图”GUI阶段就只剩界面逻辑的问题。3.3 在pyQt里把mask画回界面信号回传、缩放显示与叠加画到界面上的思路是worker把原图和mask一起回传主线程把mask按调色板转成彩色图再和原图按比例叠加。import cv2 import numpy as np from PyQt5.QtGui import QImage, QPixmap from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QMainWindow, QLabel class SegApp(QMainWindow): def start_infer(self): worker InferWorker(self.run_seg, self.ori_img.copy()) worker.finished.connect(self.show_result) self._workers.append(worker) # 防止被垃圾回收 worker.start() def run_seg(self, img): x preprocess(img, size512).to(self.device) with torch.no_grad(): out self.model(x) logits out if isinstance(out, torch.Tensor) else out[out] mask logits.argmax(dim1)[0].cpu().numpy().astype(np.uint8) return img, mask def show_result(self, data): img, mask data color_mask self.palette[mask] blend cv2.addWeighted(img, 0.6, color_mask, 0.4, 0) h, w, ch blend.shape qt_img QImage(blend.data, w, h, ch * w, QImage.Format_RGB888) self.label.setPixmap( QPixmap.fromImage(qt_img).scaled( self.label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) )逻辑说明run_seg在子线程跑返回的是RGB的numpy数组show_result在主线程收到后先按调色板上色再cv2.addWeighted把原图和彩色mask按6:4混合。addWeighted的好处是原图纹理还在能直接判断边界贴合得好不好如果只想看纯mask把alpha参数改成(1,0,0)就行。参数说明img.copy()传进去避免主线程后续又修改同一份数组QImage构造时的ch * w是每行字节数这里ch3漏了它图片会花掉。到这一步软件已经从“能跑脚本”变成“能在界面点按钮出图”。剩下的是让它从“能用”变成“好用”这就是第4章那些坑的意义——这些坑我几乎每个版本都踩过一轮。3.4 源代码与文档说明让项目可交接的最后一块拼图标题里带了“源代码文档说明”说明这个项目默认是要交付给别人的。我的经验是模型文件和配置文件比代码更容易让接手的人崩溃。一个合格的项目文档至少要写清楚三件事权重放哪个目录、类别顺序对应什么、GUI操作步骤与依赖版本。README里把这三块单独列出来新同事或者后来的自己都能在两小时内跑起来。4. 实战避坑显存泄漏、mask错位、界面卡死的5个真实翻车点这一章是全文的血泪部分。5条按现象、原因、解决的顺序写前三条是每个pyQt分割软件都会踩的经典坑后两条是国产化落地和真实数据里才暴露的问题。4.1 切换三四次模型后直接OOM显存缓存没人管现象软件刚启动一切正常切了三四次模型点哪张图都报CUDA out of memory重启软件又好了。原因模型切换时旧网络的显存没有释放。Python的引用计数虽然会自动回收但self.model指向新模型后旧的张量如果还被某些中间变量引用或者显存碎片没还给驱动都会积累。解决切换模型处统一走一个入口显式释放def switch_model(self, name: str): if self.model is not None: self.model.cpu() del self.model if torch.cuda.is_available(): torch.cuda.empty_cache() self.model get_model(name, self.n_cls).eval().to(self.device)逻辑说明先把模型拷回CPU再del比直接del干净能确保显存上不再有计算图残留empty_cache把缓存块还给驱动。参数说明对CPU机器empty_cache不生效加个torch.cuda.is_available()判断即可。我还习惯在切换前后各打印一次torch.cuda.memory_allocated()数值差超过100MB就该查代码了。4.2 分割结果边缘和原图对不上resize回贴的坐标错位现象mask大致对得上但物体边缘总是偏了那么几个像素小目标甚至整块飘走。原因推理用的图像经过了等比缩放和padding回贴mask时忘了把512x512映射回原图尺寸直接把mask当成原图分辨率用。解决记录preprocess里的scale值回贴时把mask先按比例缩小再去掉paddingdef postprocess(mask, orig_h, orig_w, size512): scale size / max(orig_h, orig_w) nh, nw int(orig_h * scale), int(orig_w * scale) mask mask[:nh, :nw] mask cv2.resize(mask.astype(np.uint8), (orig_w, orig_h), interpolationcv2.INTER_NEAREST) return mask逻辑说明mask是索引图插值必须用INTER_NEAREST用线性插值会在类别边界上混出不存在的新类别编号这就是“边缘出现彩色噪点”的来源。参数说明mask只有0到n_cls-1的整数用NEAREST保证回贴后每像素仍是合法类别。4.3 点“开始分割”界面整个卡死主线程被模型吃满现象按钮按下去窗口变白、标题栏转圈几秒到几十秒后才恢复期间连关闭按钮都点不动。原因推理函数直接在按钮的clicked信号里同步执行pyQt主线程被torch的forward占满事件循环无法处理任何重绘和消息。解决回到2.2的InferWorker推理放子线程、回传只走信号。如果勤快到不想建类也可以用Python原生线程池from concurrent.futures import ThreadPoolExecutor pool ThreadPoolExecutor(max_workers1) def on_click(self): self.future pool.submit(self.run_seg, self.ori_img) self.future.add_done_callback(self.on_done) def on_done(self, fut): img, mask fut.result() self.show_result((img, mask))逻辑说明add_done_callback的回调是在线程池线程里执行的不能直接在这里new QImage。我的做法是回调里只取结果、存进成员变量再通过QTimer.singleShot(0, ...)把画图动作调度回主线程。参数说明max_workers1保证同一时间只有一次推理在跑避免连续点击后多个推理乱序回显——这个乱序问题比界面卡死更难排查。4.4 Windows下读图报错但文件明明存在中文路径与cv2.imread现象同一份代码在Linux上读图正常Windows上cv2.imread返回None程序后续所有操作全部炸掉。原因cv2.imread底层走的是C函数对Windows的中文路径和宽字符支持很弱项目目录或文件名带“测试”“影像”这类字就正中雷区。解决改用numpy读字节再解码def imread_unicode(path: str) - np.ndarray: data np.fromfile(path, dtypenp.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR)逻辑说明np.fromfile按字节流读入绕开cv2内部的文件名解析再由cv2.imdecode从内存解码成BGR图像。参数说明imdecode的IMREAD_COLOR会强制转成三通道分割模型需要RGB调用后再做一次[:, :, ::-1]即可。4.5 大图一推就CUDA out of memory整图进网络不是好习惯现象demo.jpg能跑换了一张几千万像素的测绘大图推理时直接爆显存。原因preprocess把长边缩成512或1024但有些代码为了“保细节”会直接把原图resize成4096往网络里塞显存和计算量都按平方涨。解决要么限制最长边要么用滑窗切块推理再加权拼接。切块的思路是固定窗口512x512、步长256相邻块重叠一半推理后重叠区取概率均值而不是硬取argmax这样接缝处的条带感会小很多。滑窗代码不难写但要在文档说明里明确告诉用户软件默认整图缩放推理超大图请勾选“分块模式”。5. 进阶技巧把推理从“能出图”调到“好用”界面能跑起来之后下面这些习惯帮我把同一个软件从“演示版”推到了“能天天用”的状态。第一个做法是固定输入尺寸后做半精度推理。模型切换下拉框里加一个“FP16”复选框forward前先model.half()、输入张量也转half在支持FP16的显卡上速度能快三分之一以上显存占用几乎减半。代价是某些backbone的BatchNorm层在half下精度抖动遇到分割结果出现雪花噪点时把这个开关默认关掉就好。配合torch.no_grad()和固定输入尺寸还能用torch.compile进一步压榨推理延迟——注意torch.compile预热慢不要在切换模型时反复编译把编译后的模型缓存在内存里就行。第二个做法是给调色板写死。很多人用np.random.randint生成调色板每次启动颜色都变前一张图里红色的“道路”下一张变成蓝色说服力大打折扣。我会把类别名和RGB逐行写在class_names.txt里程序启动时读一遍这样不管谁打开软件、哪一天打开同一个类别的颜色永远一致截图汇报时不用反复解释。第三个是我自己的排查习惯每次切换模型、每次推理完成都在状态栏打印一次显存占用和耗时。耗时超过预期两倍时先看是不是CPU在跑再看是不是开启了滑窗模式这两类是耗时增长的绝对主因显存只增不减时重点查4.1的释放路径。我现在的固定流程是拿到一个新数据集先拿8个模型里最轻的MBV2-LRASPP跑一遍确认数据加载、预处理和类别映射没问题再换R50-DeepLabV3做精度基准其余模型按需对比最后用写死的调色板出一批对比图存档。这套流程让我少走了很多弯路也希望帮到你。本文还有配套的精品资源点击获取