python上位机开发经验总结01

  • Post author:
  • Post category:python




python上位机开发经验总结01

魔方机器人比赛中使用python写了上位机,总结一些经验待以后翻看。主要分4个方面,python变量与文件的处理,threading多线程模块使用经验,tkinter使用经验,其它零零碎碎的经验



python变量与文件的处理

主要是单文件内变量的处理经验与多文件内变量的处理经验。



全局变量与局部变量

python中的局部变量产生在函数内,在函数返回之后被释放,可以(但不建议)与全局变量同名。全局变量只能在模块(文件)级别使用,不能跨模块使用。

python中的全局变量在使用时有两个注意

  1. 全局变量需要在模块级别定义
  2. 在函数中修改全局变量时需要先用修饰词

    global

    声明

当函数中没有对变量作

global

修饰时,有两种情况

  1. 函数中只对变量作读取操作,则可以直接读取在模块级别定义的同名变量
  2. 函数中对变量作了写(修改)操作,则认为此变量为局部变量,即使变量与模块级别定义的变量同名,也会新建局部变量而不会去修改同名变量

如以下的例子

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绘制了一个界面,先上图

GUI

如果你还没有学习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个函数配合使用。这里贴上我的设计

GUI_design

具体怎么写就不赘述了,提几点小建议

  1. 在新建完Frame之后直接放置,这样排版和实现功能的代码可以分开
  2. 在开始绘制的时候再新建Frame,否则写多了容易找不到
  3. 另外,注意实例的命名格式,个人使用的是全拼大写开头,小驼峰表征位置和功能,例如

    FRAME_left



    ENTRY_release



    TEXT_status



PanedWindow配合LabelFrame

PanedWindow

个人认为这些划线框让界面看起来更有条理,它们的实现方式其实也非常简单,如标题一样用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指迭代深度,如果不够可以设置得更大



版权声明:本文为weixin_42846018原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。