【原创文章】欢迎正常授权转载(联系作者)
【反对恶意复制粘贴,如有发现必维权】
【微信公众号原文传送门】
这篇文章将详细介绍利用多进程的实现—方案3(代码获取见文章末尾)。相比之前的稍微复杂一点,先看看demo的最终效果(视频)。
控件序号 | 控件的类别 | 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进程)负责采集图像并实时显示在相应的控件上。
在实际的实现过程中面临以下几个关键点。
Python多进程之间有一些简单的通讯方式,例如:Queue,好处是它是一个进程安全的队列,用户不需要关注变量的管理,但是实际用这种方式来传递图片,你就会发现速度慢呀!简直崩溃,最简单有效的还是通过“共享内存”,速度快很多(但是我还是觉得慢,我觉得主要是数据转来转去导致的),但需要对内存进行管理。
最简单的就和上面一样,再使用一块“共享内存”来将绘制好检测结果的图片传递回主进程中,但是这显然不是最有效率的做法,毕竟使用“共享内存”耗时也挺长的,同时图片数据也挺大的,检测结果其实就是几个简单的数,没必要将整个图像都传递回去。我的处理方式是:主进程在“抽帧”时保存一个图像备份,子进程通过“Queue”将检测结果(相对图片数据小的多)传递回主进程,主进程收到结果后,将结果绘制在备份图像上并显示出来。
使用共享内存时一定要注意这部分内存的管理,不能主进程“写”的同时你子进程在"读",否则数据不就错了嘛。严格点来说这里需要一个“互斥锁”(有兴趣的同学可以试试),我实在是比较懒,不想研究,直接创建了一个状态变量(“共享的”)来控制,主进程和子进程在读写“共享内存”前,通过判断状态变量的值来确定是否有“权利”使用该“共享内存”。
这部分需要注意的是:建立共享内存、检测子进程、消息接收线程,代码里面有详细的注释,这里不赘述。
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
下面是检测子进程的目标函数,检测子进程开始后执行的就是这个函数,首先是初始化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
线程创建后会先进入构造函数,启动后执行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])
该函数主要是按时读取视频流中的图像并显示在控件上,每次判断检测共享内存的状态,如果子进程释放则将当前帧数据写入共享内存中,之后改变状态变量的值(释放对共享内存的占有)。
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()
在关闭窗口之前需要关闭子进程,否则子进程会一直在后台运行,开一次软件创建一个,多次重复后电脑越来越卡。打开任务管理器后后发现有好多名叫“Python”的进程,这些就是创建后却没关闭的子进程。因此,在窗口关闭事件函数下改变检测进程的状态变量值,使子进程能够正常退出。
def closeEvent(self, a0):
"""
关闭窗口时间函数
:param a0:
:return:
"""
self.process_flg.value = 0 # 退出子进程
self.detector_process.join()
其他函数就不写了,非常简单。
由于本人能力有限,欢迎批评指正。
可以加我的QQ(1152291782)交流,请注明来意。
本文配套源代码下载地址:
回复“SSD界面3”获取。