【搞事情】利用PyQt为目标检测SSD300添加界面(四)

【原创文章】欢迎正常授权转载(联系作者)
【反对恶意复制粘贴,如有发现必维权】
【微信公众号原文传送门


​这篇文章将详细介绍利用多进程的实现—方案3(代码获取见文章末尾)。相比之前的稍微复杂一点,先看看demo的最终效果(视频)。


1 需求分析

首先看一下UI界面,界面上各个控件的详细信息如下表所示。
在这里插入图片描述

控件序号 控件的类别 Qobject_name 功能
1 QLabel label_imgshow 实时显示视频
2 QLabel label_imgshaow_res 显示抽帧检测效果
3 QTextEdit textEdit 显示检测的目标信息
4 QPushButton pushButton_open 打开视频文件
5 QLineEdit lineEdit_cameraIndex 设置视频流URL;支持IP摄像头;数字为当前设备中摄像头索引(例:本人笔记本自带摄像头为0)
6 QPushButton pushButton_start 开始检测并播放
7 QPushButton pushButton_pause 暂停检测及播放
8 QPushButton pushButton_end 停止检测及播放(全部重设)

结合上面的控件分析一下需求。方案3最初的设计需求是电脑的性能有限,无法做到实时检测并显示,我们希望做一个折中,不检测视频流中的每一帧图像,只是从中抽取部分来检测,检测时不打断画面的实时显示,在"后台"中尽可能多的检测视频流中的图像。之前的文章中也介绍过神经网络的预测过程是无法在线程中实现的,因此设计了一个如下图所示的方案,将图像检测神经网络放到一个子进程中负责在“后台”检测目标,主进程(UI进程)负责采集图像并实时显示在相应的控件上。
解决方案3流程
在实际的实现过程中面临以下几个关键点。

(1) 主进程(UI进程)采集到的图像数据如何传递给子进程检测?

Python多进程之间有一些简单的通讯方式,例如:Queue,好处是它是一个进程安全的队列,用户不需要关注变量的管理,但是实际用这种方式来传递图片,你就会发现速度慢呀!简直崩溃,最简单有效的还是通过“共享内存”,速度快很多(但是我还是觉得慢,我觉得主要是数据转来转去导致的),但需要对内存进行管理。

(2) 子进程检测的结果如何告知主进程并将结果显示出来?

最简单的就和上面一样,再使用一块“共享内存”来将绘制好检测结果的图片传递回主进程中,但是这显然不是最有效率的做法,毕竟使用“共享内存”耗时也挺长的,同时图片数据也挺大的,检测结果其实就是几个简单的数,没必要将整个图像都传递回去。我的处理方式是:主进程在“抽帧”时保存一个图像备份,子进程通过“Queue”将检测结果(相对图片数据小的多)传递回主进程,主进程收到结果后,将结果绘制在备份图像上并显示出来。

(3) 共享内存的管理。

使用共享内存时一定要注意这部分内存的管理,不能主进程“写”的同时你子进程在"读",否则数据不就错了嘛。严格点来说这里需要一个“互斥锁”(有兴趣的同学可以试试),我实在是比较懒,不想研究,直接创建了一个状态变量(“共享的”)来控制,主进程和子进程在读写“共享内存”前,通过判断状态变量的值来确定是否有“权利”使用该“共享内存”。


2 代码详解

(1) 构造函数

这部分需要注意的是:建立共享内存、检测子进程、消息接收线程,代码里面有详细的注释,这里不赘述。

def __init__(self, parent=None):
    super(MainWindow, self).__init__(parent)
    self.setupUi(self)# 图像大小
    self.img_shape = (480, 720)# 初始化界面
    self.label_imgshow.setScaledContents(True)          # 图片自适应显示
    self.label_imgshow_res.setScaledContents(True)      # 检测结果图片自适应显示
    self.img_none = np.ones((480, 720, 3), dtype=np.uint8)*255
    self.show_img(self.img_none)# SSD检测初始化
    self.weight_path = './ssd/weights/weights_SSD300.hdf5'
    self.weight_path = os.path.normpath(os.path.abspath(self.weight_path))
    self.obj_names = ['Aeroplane', 'Bicycle', 'Bird', 'Boat', 'Bottle',
                      'Bus', 'Car', 'Cat', 'Chair', 'Cow', 'Diningtable',
                      'Dog', 'Horse', 'Motorbike', 'Person', 'Pottedplant',
                      'Sheep', 'Sofa', 'Train', 'Tvmonitor']
    # 需要显示的目标list, 用于过滤
    self.include_class = self.obj_names
    
# -----------检测子进程--------------
    # 子进程返回结果使用
    self.queue = Queue()
    # 多进程之间的共享图片内存,参数‘I’表示数据类型为 int 
    # 后一个参数为内存的大小,这里Python不提供多个维度数据的共享内存
    # 只有数组类型满足使用需求,因此主进程中需先将图像数据变为数组的样子,在子进程中再恢复
    self.img_share = RawArray('I', self.img_shape[0] * self.img_shape[1] * 3)
    # 标识当前进程的状态,非0:保持检测;0:停止检测
    self.process_flg = RawValue('I', 1)
    # 当前图像共享内存 img_share 的状态,非0:主进程使用中;0:子进程使用中
    self.img_get_flg = RawValue('I', 1)
    # 创建检测子进程
    self.detector_process = Process(target=detector_process,
                                    args=(self.img_share,
                                          self.img_shape,
                                          self.process_flg,
                                          self.img_get_flg,
                                          self.queue))
    self.detector_process.start()    # 进程开始
# -------------------------------------------------------------# -----------接收检测结果的线程--------------
    # 主要考虑到Queue的get方法可能会阻塞,如果直接在计时器函数中调用
    # get会导致UI“假死”,卡着不动。
    # 虽然也可以设置阻塞时间,但是建议还是建立线程接收处理Queue中的结果
    # 接收检测结果的线程
    self.recv_thread = Recv_res(parent=self, queue=self.queue)
    # 连接信号        
    # 这个信号用于通知UI响应显示,接收线程中只负责接收转发结果,后面代码中有详细介绍
    self.recv_thread.res_signal.connect(self.show_res)
    self.recv_thread.start()
# -------------------------------------------------------------# 视频文件路径
    self.camera_index = 0
    self.FPS = None# 初始化计时器
    self.timer = QTimer(self)               # 更新计时器
    self.timer.timeout.connect(self.timer_update)       # 超时信号连接对应的槽函数# 等待加载模型
    self.textEdit.setText('正在加载模型,请稍后......')
    self.pushButton_start.setEnabled(False)
    self.pushButton_open.setEnabled(False)
    self.pushButton_pause.setEnabled(False)
    self.lineEdit_cameraIndex.setEnabled(False)# 暂停初始化为不暂停
    self.pause = False

(2) 检测子进程目标函数

下面是检测子进程的目标函数,检测子进程开始后执行的就是这个函数,首先是初始化SSD并加载权重,之后进入帧循环检测,子进程的消息(包括检测结果)通过Queue传递返回,包括两部分:状态量和消息内容,设置状态量的目的是为了下一步针对不同的消息做相应的处理。

def detector_process(img_share, img_shape, process_flg, img_get_flg, res_queue):
    """
    SSD检测子进程目标函数
    :param img_share: 待检测图像数据,共享内存
    :param img_shape: 待检测图像的大小 (h, w)
    :param process_flg: 子进程状态量  0:退出检测进程;非0:保持检测
    :param img_get_flg: 共享图像内存 的状态量     0:子进程占用共享内存   1:主进程占有内存
    :param res_queue: 返回检测结果的通道
    :return:
    """
    # 初始化SSD
    weight_path = './ssd/weights/weights_SSD300.hdf5'
    weight_path = os.path.normpath(os.path.abspath(weight_path))
    obj_names = ['Aeroplane', 'Bicycle', 'Bird', 'Boat', 'Bottle',
                      'Bus', 'Car', 'Cat', 'Chair', 'Cow', 'Diningtable',
                      'Dog', 'Horse', 'Motorbike', 'Person', 'Pottedplant',
                      'Sheep', 'Sofa', 'Train', 'Tvmonitor']
    include_class = obj_names
    ssd = SSD_test(weight_path=weight_path, class_nam_list=obj_names)# 通知UI 模型加载成功
    res_queue.put((3, '模型加载成功'))# 构建检测循环
    while True:
        # print('process_flg:{}  img_get_flg:{}'.format(process_flg.value, img_get_flg.value))
        # 判断检测器状态,是否退出
        if process_flg.value == 0:
            print('安全退出检测进程!')
            res_queue.put((0, '检测进程已安全退出!'))
            break# 判断共享内存当前状态是否可以安全读取数据
        if img_get_flg.value == 0:
            # print('开始检测!')
            try:
                img = np.array(img_share[:], dtype=np.uint8)
                img_scr = np.reshape(img, (img_shape[0], img_shape[1], 3))# SSD检测
                preds = ssd.Predict(img_scr)
                # 结果过滤
                preds = filter(obj_names, preds, inclued_class=include_class)
​
                h, w = img_shape[:2]
                res = decode_preds(obj_names, preds, w=w, h=h)  # 列表# 管道返回检测结果
                res_queue.put((1, res))except:
                print('图片检测失败')
                res_queue.put((2, '当前图像检测失败!'))
            finally:
                # 释放图像共享内存占用,让主进程写入新的图像
                img_get_flg.value = 1

(3) 消息接收线程类

线程创建后会先进入构造函数,启动后执行run函数。主要的功能就是接收子进程通过Queue传递回来的消息,并通知UI做出相应的处理。接收到检测子进程退出的消息后,该线程也跳出循环结束生命周期。

class Recv_res(QThread):
    """
    检测结果接收线程
    """
    res_signal = pyqtSignal(list)
    @debug_class('Recv_res')
    def __init__(self, parent, queue:Queue):
        """
        构造函数
        :param parent: 父实例 QObj ,Qt中父实例析构相应的子线程会安全退出,不用人工处理
        :param queue: 管道
        """
        super(Recv_res, self).__init__(parent=parent)
        self.queue = queue
​
    def run(self):
        while True:
            flg, res = self.queue.get()
            print(flg, res)
            if flg == 0:    # 对应检测子进程已安全退出
                print('接收线程已安全退出!')
                self.res_signal.emit([0, res])
                break
            else:
                self.res_signal.emit([flg, res])

(4) 计时器超时槽函数

该函数主要是按时读取视频流中的图像并显示在控件上,每次判断检测共享内存的状态,如果子进程释放则将当前帧数据写入共享内存中,之后改变状态变量的值(释放对共享内存的占有)。

def timer_update(self):
    """
    计时器槽函数
    :return:
    """
    if self.cap.isOpened():
        # 读取图像
        ret, self.img_scr = self.cap.read()
        # ### 视频读取完毕
        if not ret:
            # 计时器停止计时
            self.timer.stop()
            # 不检测
            self.img_get_flg.value = 1
            # 对话框提示
            QMessageBox.information(self, '播放提示', '视频已播放完毕!')
            # 释放摄像头
            if hasattr(self, 'cap'):
                self.cap.release()
                del self.cap
            # 释放‘开始’按钮
            self.pushButton_start.setEnabled(True)
            # 禁止暂停并初始化其功能
            self.pause = False
            self.pushButton_pause.setText('暂停')
            self.pushButton_pause.setEnabled(False)
            # 释放视频流选择
            self.pushButton_open.setEnabled(True)
            self.lineEdit_cameraIndex.setEnabled(True)
            return# 图像预处理
        self.img_scr = cv2.resize(self.img_scr, (self.img_shape[1], self.img_shape[0]))
        # 转为RGB
        self.img_scr = cv2.cvtColor(self.img_scr, cv2.COLOR_BGR2RGB)if hasattr(self, 'detector_process'):
            # ### 抽帧
            if self.img_get_flg.value == 1:
                # print('开始抽帧')
                self.img_temp = self.img_scr.copy()         # 用于显示检测结果
                self.img_share[:] = self.img_scr.reshape(-1).tolist()  # 抽帧保存在中间缓存
                self.img_get_flg.value = 0  # 不再抽取 直到检测完成
                # print('结束抽帧')# 显示图像
        self.show_img(self.img_scr)# 响应UI
        QApplication.processEvents()
    else:
        self.textEdit.setText('数据流未打开!!!\n请检查')
        self.resst_detector()

(5) 窗口关闭事件函数

在关闭窗口之前需要关闭子进程,否则子进程会一直在后台运行,开一次软件创建一个,多次重复后电脑越来越卡。打开任务管理器后后发现有好多名叫“Python”的进程,这些就是创建后却没关闭的子进程。因此,在窗口关闭事件函数下改变检测进程的状态变量值,使子进程能够正常退出。

def closeEvent(self, a0):
    """
    关闭窗口时间函数
    :param a0:
    :return:
    """
    self.process_flg.value = 0          # 退出子进程
    self.detector_process.join()

其他函数就不写了,非常简单。


由于本人能力有限,欢迎批评指正。
可以加我的QQ(115229182)交流,请注明来意。

关注下方公众号,回复关键字即可获取下载地址。
  • 本文配套源代码下载地址:

    回复“SSD界面3”获取。


如果你读后有收获,欢迎关注我的微信公众号
上面有更多完全免费教程,我也会不定期更新
ღ ღ ღ 打开微信扫描下方二维码关注 ღ ღ ღ

在这里插入图片描述

发布了13 篇原创文章 · 获赞 24 · 访问量 1万+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章