python上位机开发经验总结01
魔方机器人比赛中使用python写了上位机,总结一些经验待以后翻看。主要分4个方面,python变量与文件的处理,threading多线程模块使用经验,tkinter使用经验,其它零零碎碎的经验
python变量与文件的处理
主要是单文件内变量的处理经验与多文件内变量的处理经验。
全局变量与局部变量
python中的局部变量产生在函数内,在函数返回之后被释放,可以(但不建议)与全局变量同名。全局变量只能在模块(文件)级别使用,不能跨模块使用。
python中的全局变量在使用时有两个注意
- 全局变量需要在模块级别定义
-
在函数中修改全局变量时需要先用修饰词
global
声明
当函数中没有对变量作
global
修饰时,有两种情况
- 函数中只对变量作读取操作,则可以直接读取在模块级别定义的同名变量
- 函数中对变量作了写(修改)操作,则认为此变量为局部变量,即使变量与模块级别定义的变量同名,也会新建局部变量而不会去修改同名变量
如以下的例子
flag = 1
def false_reset_flag():
flag = 0
def right_reset_flag():
global flag
flag = 0
false_reset_flag()
print(flag)
right_reset_flag()
print(flag)
以上程序的执行结果为
1
0
并且,把变量传入函数之后进行修改也是不可行的,python会将传入的变量复制作为局部参数。但是如果在程序中需要很多flag(标志变量),那么逐一定义并修改全局变量是非常不优雅的,我们可以采用传入实例,调用实例中的函数修改自己的变量的方法。终究,python还是面向对象的语言。如以下例子
flag = 1
class FlagClass(object):
def __init__(self):
self.flag = 1
def reset_flag(self):
self.flag = 0
def false_reset(x):
x = 0
def reset(x):
x.flag = 0
false_reset(flag)
print(flag)
flag_class = FlagClass()
reset(flag_class)
print(flag_class.flag)
以上程序的执行结果为
1
0
文件间的变量处理
上面其实已经提到了,python最优雅的编程方式是面向对象编程,模块(文件)间的处理也是一样。最好把每个模块中的东西全部封装成类,实现模块之间的解耦。但是有的时候(尤其是完成一个特定的算法和工作流程)面向过程的编程更为方便,这时候我们就需要去处理模块之间的很多flag(标志变量)了。
首先我们要明确一个核心思想:模块之间是不能相互写(修改)变量的。如果从其它模块
import
了某个变量,实际上是把这个变量复制过来,作为一个本模块内的“局部变量”。所以我们模块间修改变量的策略也是和封装成类差不多:把一个模块看成一个庞大的实体,在模块内定义修改自己变量的函数,在其它模块中调用这个函数。如以下例子
# module1.py
flag = 1
def reset_flag():
global flag
flag = 0
# 下面是需要调用的函数,里面会修改flag的值来标志有没有完成
......
# module2.py
from module1 import flag, reset_flag
print('flag')
if flag:
print('动作完成,重置标志')
reset_flag()
from module1 import flag
print('flag')
运行module2得到的结果如下
1
动作完成,重置标志
0
threading模块使用经验
threading是python提供的在底层的_threading基础上封装好的使用方便的多线程模块,但是这个模块并不太完善。
之所以说它使用方便,是因为它可以方便地创建线程,且线程中可以直接修改全局变量(像上面说的一样用
global
声明即可);说它不方便是因为它没有办法很好地管理线程(我的意思是很简单方便,有能力自己设计机制管理的大佬请忽略),如果你的线程中运行的是一个死循环,那么一旦线程开始,就处于失控状态,再也无法结束。
分享我本次使用threading模块的2个经验。
管理线程
下面是我在本次工程中管理线程的经验,虽然并不是一个非常优雅的方法,但是还算好用。核心思想是定义一个线程的flag,定义成全局变量还是定义在类里可以根据实际情况决定。把死循环改为一个带判断的while循环,那么结束线程时只需修改flag的值即可。如以下例子
class COM(object):
def __init__(self, port, baud):
self.port = port
self.baud = int(baud)
self.com = serial.Serial()
self.recvBuffer = Queue(maxsize=10)
self.__recv_running = True # 标志接收线程是否运行
self.recvThread = threading.Thread()
def open(self):
try:
self.com = serial.Serial(self.port, self.baud)
except Exception:
print('Open COM failed: {}'.format(self.port))
def close(self):
if self.com is not None and self.com.isOpen():
self.com.close()
self.stop_receive()
def isOpen(self):
return self.com.isOpen()
def activate_receive(self):
def receive_loop():
while self.__recv_running:
temp = self.com.read()
if temp in serial_dict:
self.recvBuffer.put(serial_dict[temp])
time.sleep(0.02)
self.recvThread = threading.Thread(target=receive_loop)
self.recvThread.start()
def stop_receive(self):
self.__recv_running = False
以上是我在本次工程中定义的串口类,其中接收串口数据就是用了单独的一个线程。在类中我定义了
__recv_running
变量来标志接收线程是否运行,在线程的
target
函数中每次循环都取判断
__recv_running
的值,需要结束线程时只需调用
stop_receive
修改
__recv_running
的值,那么接收线程执行完当前循环就会自动结束。
当然你应该发现了,修改标志变量值之后需要等待线程中本次循环结束,线程才能结束,无法强行杀死线程。一方面,这样结束线程可能效率不高;另一方面,这样结束线程对程序的冲击最小。
定义线程
在定义线程时,最好把线程的函数定义在启动的函数内,这样程序的可读性有很大的提高。这一点在上面的代码中就可以看出,这里我把它截取出来
def activate_receive(self):
def receive_loop():
while self.__recv_running:
temp = self.com.read()
if temp in serial_dict:
self.recvBuffer.put(serial_dict[temp])
time.sleep(0.02)
self.recvThread = threading.Thread(target=receive_loop)
self.recvThread.start()
tkinter使用经验
这次的工程中我用tkinter绘制了一个界面,先上图
如果你还没有学习tkinter,并且时间充裕的话,我建议你使用pyQt。tkinter虽然上手非常快(从学开始到编写完这个界面我花了2天),但是它所有的排版都需要你手动敲代码,相比于pyQt的画图而言,就显得太不人性化了。
本次工程中,关于tkinter的资料搜索量是最大的,总结了以下几点经验。
在这次的工程中我参考了以下网页:
Python 实现串口调试助手
tkinter显示视频
这个问题面向百度和CSDN搜索了很久,没有一个非常好的解答。最后在
python摄像头视频显示到TK窗口改良版
找到的代码基础上改良了一下,很好地实现了视频显示。因为找到的代码是需要付积分下载的,原本我并不想放出源码,但是抱着python全部开源的精神,在此我放出源码并配上讲解,希望各位帅哥美女留下一个赞再走。
import cv2 as cv
from PIL import Image, ImageTk
import threading
from queue import Queue
# 放置画布
canvases = []
for i in range(4):
canvases.append(tk.Canvas(FRAME_cameras, bg='#ffffff', height=240, width=320))
x = [0, 0, 1, 1]
y = [0, 1, 0, 1]
for i in range(4):
canvases[i].grid(row=x[i], column=y[i], sticky='nsew')
# 下面的代码解决视频流的显示和关闭
show_cam_flag = True
def stop_show_cam_thread():
"""
shut down the show_cameras thread
:return: None
"""
global show_cam_flag
show_cam_flag = False
# show camera flows
def show_cameras():
def cc():
imgQueue = Queue(maxsize=10) # 使用队列存储读取的图像,解决视频闪烁的问题
global show_cam_flag
while show_cam_flag:
for i in range(4):
ret, frame = cameras[i].read()
cv_image = cv.cvtColor(frame, cv.COLOR_BGR2RGB)
image = Image.fromarray(cv_image)
image_file = ImageTk.PhotoImage(image)
imgQueue.put(image_file)
canvases[i].create_image(0, 0, anchor='nw', image=image_file, tags='c1')
if imgQueue.full():
imgQueue.get() # 队满则出队一个,防止内存占用太大程序崩溃
time.sleep(0.04)
del imgQueue
show_cam_flag = True # 关闭视频流后重新置一,避免无法再次打开
t = threading.Thread(target=cc)
t.start()
核心的显示思想是使用Canvas来显示图片(因为用Label不断刷新图片,内存会过大,程序会崩溃),用单独的一个线程不断地读取摄像头并刷新Canvas显示的图片。因为OpenCV读取的图像格式没有办法直接显示,所以用其它的库处理了一下。
需要注意的是,如果对显示的图片不加以保存的话,每次刷新图片时之前的图片就会被释放,导致显示的视频闪烁。最早找到的代码把图片保存在了字典中(我不太懂为什么要这样操作,可能是试出来的吧),这样做显然是很不优雅的,而且不定期进行删除操作,一定会导致内存过大程序崩溃(和Label就没有区别了)。所以我定义了一个队列
imgQueue
来存储图片,每个循环都会检查队列并处理溢出的图片,最终在关闭视频流时删除这个队列。这应该是非常优雅的方法了,欢迎各位交流更好的方法。
Frame的使用以及各种摆放方式
这一次我主要使用了tkinter的三种排版方式:tkinter.pack(), tkinter.grid(), tkinter.place()。tkinter的排版还是比较麻烦的,全部使用place肯定非常不优雅。想要整齐的排版,就需要多建立一些Frame,然后3个函数配合使用。这里贴上我的设计
具体怎么写就不赘述了,提几点小建议
- 在新建完Frame之后直接放置,这样排版和实现功能的代码可以分开
- 在开始绘制的时候再新建Frame,否则写多了容易找不到
-
另外,注意实例的命名格式,个人使用的是全拼大写开头,小驼峰表征位置和功能,例如
FRAME_left
,
ENTRY_release
,
TEXT_status
PanedWindow配合LabelFrame
个人认为这些划线框让界面看起来更有条理,它们的实现方式其实也非常简单,如标题一样用PanedWindow配合LabelFrame即可。可参考如下代码
PAN_com = tk.PanedWindow(FRAME_r_up, orient=tk.VERTICAL, height=180)
PAN_init = tk.PanedWindow(FRAME_r_up, orient=tk.VERTICAL, height=180)
FRAME_com = tk.LabelFrame(PAN_com, text="串口设置")
FRAME_init = tk.LabelFrame(PAN_init, text="转换初始化")
PAN_com.add(FRAME_com)
PAN_com.pack(side=tk.LEFT)
PAN_init.add(FRAME_init)
PAN_init.pack(side=tk.RIGHT)
然后把要加的东西加到LabelFrame里就行了,即上面的
FRAME_com
和
FRAME_init
里。
下拉框与输入框
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vQuEECl8-1601652622895)(./pictures/PanedWindow.png)]
还是这张图,里面的下拉框和输入框是不是也很简介美观,下面是实现它们的方法。
下拉框
tkinter.Combobox()个人认为是不大好用的,我使用了tkinter.ttk中的Combobox(),可以这样实现
from tkinter import ttk
port_list = list(list_ports.comports())
serial_com_list = []
# varPort = tk.StringVar()
COMBO_com_com = ttk.Combobox(FRAME_com, width=8, height=2, justify=tk.CENTER)
for port in port_list:
tmp_port = list(port)
serial_com_list.append(tmp_port[0])
COMBO_com_com.grid(row=0, column=1)
COMBO_com_com['values'] = serial_com_list
def refresh_com():
global port_list, serial_com_list
port_list = list(list_ports.comports())
serial_com_list = []
for port in port_list:
tmp_port = list(port)
serial_com_list.append(tmp_port[0])
COMBO_com_com['values'] = serial_com_list
FRAME_com.after(500, refresh_com)
refresh_com()
这一段代码实现了COM:后面的下拉窗口,可以动态显示可用的串口,每500ms刷洗一次。后面还会讲使用tkinter.StringVar()实现动态显示的方法。
输入框
输入框使用tkinter.Entry即可,读取时使用
tkinter.Entry.get()
,设定默认值可以使用
tkinter.Entry.insert()
,例如
ENTRY_init_release = tk.Entry(FRAME_init, show=None, width=4)
ENTRY_init_release.insert(0, 25)
ENTRY_init_release.grid(row=0, column=1)
tkinter.after()实现定时器
tkinter.after()函数实际上是等待一段时间后执行一个函数,但是可以通过函数本身的嵌套去实现一个定时器的功能,在stringVar()中一起介绍
动态的显示:StringVar()
有时候你会想在界面上显示一个实时变动的东西,这时候你就需要一个变量去放置你要显示的东西。我使用了一个很好用的类tkinter.StringVar。下面给出使用tkinter.after和tkinter.StringVar实现计时器的方法
global time_count
run_time = -1
def start_time_count():
"""
开始计时界面的定时刷新,此定时器可用time_count来停止
:return:
"""
global time_count
global run_time
run_time += 1
var_time.set('%.2f' % (run_time / 100))
time_count = FRAME_time.after(10, start_time_count)
PAN_time = tk.PanedWindow(FRAME_r_down, orient=tk.VERTICAL, height=100, width=364)
FRAME_time = tk.LabelFrame(PAN_time, text="运行计时")
PAN_time.add(FRAME_time)
PAN_time.pack()
var_time = tk.StringVar()
LABEL_time = tk.Label(FRAME_time, textvariable=var_time, font=('Arial', 40), fg='red')
LABEL_time.pack()
var_time.set('0.00')
run_time = -1
可以通过函数
start_time_count()
来启动计时器,使用
FRAME_time.after_cancel(time_count)
来结束。
带滑条的Text
如果你觉得创建Text再创建滑条太麻烦,那么可以像我一样使用tkinter.scrolledtext中的同名类来创建带滑条的Text窗口。可参考如下代码
import tkinter as tk
from tkinter import scrolledtext
# FRAME_status
FRAME_status = tk.Frame(FRAME_r_down)
FRAME_status.pack()
TEXT_status = scrolledtext.ScrolledText(FRAME_status, width=49, height=8, font=('Consolas', 10))
TEXT_status.pack()
TEXT_status.insert(tk.END, 'Welcome to use Beihang Robotics CubicRobot\n')
TEXT_status.see(tk.END)
其中
TEXT_status.see(tk.END)
可以使Text窗聚焦在底部
好看的字体设置
tkinter几乎所有地方都可以设置字体,例如以下:
LABEL_time = tk.Label(FRAME_time, textvariable=var_time, font=('Arial', 40), fg='red')
BUTTON_start = tk.Button(FRAME_main, text='开始', font=('黑体', 16), width=10, height=2, command=main_start)
TEXT_status = scrolledtext.ScrolledText(FRAME_status, width=49, height=8, font=('Consolas', 10))
在此推荐一种好看的英文字体,适合用在提示窗信息中:Consolas
tkinter修改关闭窗口操作
有时候为了保证程序的正常退出(如需要手动关闭线程),需要修改点击窗口右上方’X’号时触发的函数。tkinter中点击’X’产生的事件为
'WM_DELETE_WINDOW'
,想要修改可以参考如下方法
# 重写关闭窗口函数,先关闭线程再关闭窗口
def terminate():
# 关闭视频流线程
stop_show_cam_thread()
# 若串口打开,关闭串口
try:
com.close()
except NameError:
pass
# 等待线程关闭再关闭窗口,实测有效
root.after(1000, root.destroy)
root.protocol('WM_DELETE_WINDOW', terminate)
其它零零碎碎的经验
OpenCV实现点击’X’关闭窗口
一般来说OpenCV都是通过按键盘来关闭(waitKey),但是这样的方法放在一个成熟的GUI中就有失优雅,于是我在Stack Overflow上找到了解决方法,可以看以下例子,也可以参见网址
OpenCV Python: How to detect if a window is closed?
total_frames = 50
cv2.cv.NamedWindow("Dragonfly Simulation")
cv2.cv.StartWindowThread()
for i in range(total_frames):
# do stuff
img_name = # something
img = cv2.cv.LoadImage(img_name)
cv2.cv.ShowImage("Dragonfly Simulation", img)
cv2.cv.WaitKey(2)
cv2.cv.DestroyWindow("Dragonfly Simulation")
cv2.cv.WaitKey(1)
# rest of code
以上是原网址的代码示例
while get_points_cnt < 9:
cv.imshow(get_points_window, image)
cv.waitKey(50)
if cv.getWindowProperty(get_points_window, 0) < 0:
TEXT_status.insert(END, 'Window shut down before finishing setting sampling points\n')
TEXT_status.see(END)
return
以上是我本次使用的代码,简单地说就是每次刷新图像的循环都对
cv2.getWindowProperty(window_name, 0)
进行判断,如果窗口已经关闭,这个函数的返回值为-1,如果窗口已经关闭,则打断循环即可
打包文件
写完的代码可以用pyinstaller打包成exe文件,可以让你的程序在没有python环境的电脑上运行。这里只记录一个常用的打包方式
pyinstaller -F -i logo.ico -w main.py -p sub1.py -p sub2.py
-F是指打包成单独的exe文件,而不是很多dll依赖文件的文件夹。
-i指exe文件的图标
-w指生成窗口程序
-p后是子模块
pyinstaller错误:RecursionError: maximum recursion depth exceeded
打包程序是遇到了这个问题,意思是递归深度超过限制。在Stack Overflow上找到了解决方法:
pyinstaller creating EXE RuntimeError: maximum recursion depth exceeded while calling a Python object
大概意思就是,使用的某一个包在疯狂递归,导致超过了python的栈深度限制。解决方法是首次运行pyinstaller命令,提示出错后,生成了一个
module_name.spec
文件,记事本打开这个文件,加上下面两行即可
import sys
sys.setrecursionlimit(5000)
这里的5000指迭代深度,如果不够可以设置得更大