一 、前言
今年打算参加恩智浦智能车的比赛,为了后续调试的方便,自己写了一个上位机图像显示的程序,主要功能是将智能车采集和处理后的二值化图像、边线数据、中线数据、电机pid数据、舵机pd数据通过串口传输到电脑中,然后将数据以图像或者波形的形式显示出来,方便调试。
上位机程序主要分为两部分:下位机部分和上位机部分,下面分别进行说明。
二、下位机
这辆智能车用的是K66芯片,B型车模,数据传递是DMA串口和普通串口分时复用,DMA部分代码用的是NXP官方库,其他部分用的是龙邱的库。
1.图像部分
由于图像部分数据较大,为了不影响主程序的运行,采用DMA串口的方式传输。官方库和那些商家的库写法差别很多,有些类似STM32中的HAL库,其中DMA部分,通过配置一些结构体来给DMA用到的寄存器赋值,传递完成后会自动调用回调函数(不是串口中断函数),可以通过编写回调函数,来控制DMA完成后的操作。
以下是DMA串口部分的实现代码:
void HC_DMA_ONE_START(void* buffer,unsigned int size)
{
uart_transfer_t xfer;
/* 发送数据的首地址 */
xfer.data = buffer;
/* 发送的长度 */
xfer.dataSize = size;
car_status.txOnGoing = true;
car_status.txBufferFull = true;
/* 开始发送 */
UART_SendEDMA(UART4_BASE_PTR, &g_uartEdmaHandle, &xfer);
/* 等待发送完成 发送完成后会进入回调函数 复位txOnGoing */
}
int UART_SendEDMA(UART_MemMapPtr base, uart_edma_handle_t *handle, uart_transfer_t *xfer)
{
edma_transfer_config_t xferConfig;
int status;
/* If previous TX not finished. */
if (kUART_TxBusy == handle->txState) status = kStatus_UART_TxBusy;
else
{
handle->txState = kUART_TxBusy;
handle->txDataSizeAll = xfer->dataSize;
/* Prepare transfer. */
EDMA_PrepareTransfer(&xferConfig, xfer->data, sizeof(uint8_t), (void *)UART_GetDataRegisterAddress(base),
sizeof(uint8_t), sizeof(uint8_t), xfer->dataSize, kEDMA_MemoryToPeripheral);
/* Store the initially configured eDMA minor byte transfer count into the UART handle */
handle->nbytes = sizeof(uint8_t);
/* Submit transfer. */
EDMA_SubmitTransfer(handle->txEdmaHandle, &xferConfig);
EDMA_StartTransfer(handle->txEdmaHandle);
/* Enable UART TX EDMA. */
UART_EnableTxDMA(base, true);
status = kStatus_Success;
}
return status;
}
2.数据部分
数据部分的数据量不大,且种类较大,所以采用普通的串口收发。需要发送的数据有下位机计算的中线值(长度可变)、边线值(长度可变)、电机PID值、舵机PD值、舵机PWM波。
数据发送程序的思路:设置静态变量的标志位,如果上位机传来相应指令就将标志位置1,其次是,先保证DMA图像部分的数据发送完成,随后在将串口设置位普通模式,发送其他数据。
void HC_DMA_ONE_START(void* buffer,unsigned int size)
{
uart_transfer_t xfer; /* 发送数据的首地址 */
xfer.data = buffer; /* 发送的长度 */
xfer.dataSize = size;
car_status.txOnGoing = true;//串口TX正在发送标志位
car_status.txBufferFull = true; //串口TX发送寄存器满标志位
UART_SendEDMA(UART4_BASE_PTR, &g_uartEdmaHandle, &xfer);/* 发送完成后会进入回调函数 */
}
void HC_DMA_TransferOrReceive(void)
{
static u8 HC_CMD_Flag=0;//指令标志
static u8 HC_CMD_Image_Flag=0;//上传图像指令标志
static u8 HC_CMD_Speed_Flag=0;//上传电机数据指令标志
static u8 HC_CMD_SD5_Flag=0;//上传舵机数据指令标志
u8 buffer[]="\r\n";//发送完成标志
if(Res!=0&&HC_CMD_Flag==0)//收到上位机指令
{
if(Res==IMAGE||Res==MOTOR_PID||Res==SD5_PD)//设置标志位
{
if(Res==IMAGE) HC_CMD_Image_Flag=1;
if(Res==MOTOR_PID) HC_CMD_Speed_Flag=1;
if(Res==SD5_PD) HC_CMD_SD5_Flag=1;
}
else HC_Command(Res);//执行其他指令
Res=0;//读完Res后清除内容
}
if((!car_status.txOnGoing)&&HC_CMD_Flag==1)//DMA图像传输完成
{
HC_UART4Init(HC_BAUD);//初始化串口4为普通串口模式
UART_PutStr(UART4,"\r\n");//发送完成标识
HC_SendIamge_Middle((u8*)Iamge_MIDdle,sizeof(Iamge_MIDdle));//DMA传输完成后传输线数据
HC_SendIamge_LIFE_SIDE((u8*)Iamge_LIFE_SIDE,sizeof(Iamge_LIFE_SIDE));
HC_SendIamge_RIGHT_SIDE((u8*)Iamge_RIGHT_SIDE,sizeof(Iamge_RIGHT_SIDE));
if(HC_CMD_Speed_Flag==1) HC_SendSpeedPID(); //传输电机PID参数
if(HC_CMD_SD5_Flag==1) HC_SendSD5_PD(); //传输舵机PWM和PD参数
HC_CMD_Flag=0;
}
if(HC_CMD_Image_Flag&&!car_status.txOnGoing)//传输图像
{
HC_CMD_Flag=1;
HC_SendImage();//上传图像
}
if(HC_CMD_Image_Flag==0&&(HC_CMD_Speed_Flag==1||HC_CMD_SD5_Flag==1))//保证DMA图像不为传输状态
{
if(HC_CMD_Speed_Flag) HC_SendSpeedPID();
if(HC_CMD_SD5_Flag) HC_SendSD5_PD();
HC_CMD_Flag=0;
}
}
3.通信协议
通信协议通过特定帧头和同一帧尾的方式来表示不同的数据发送和一串数据的结束,需要注意的是,帧头的设置应该保证不在数据中出现的前提下尽量简单,过于复杂会使上位机数据接受部分写的很复杂。
以下为所使用的通信协议,其中帧尾为“\r\n”,图像帧头为以‘I’开头的部分,分别发送。电机PID和舵机PWM及PD的3个值是一起发送的,通过不同的标志区分,且由于其中的数据有为float型,所以应当将数据*1000转化为普通的整型(16位)。
//---------------------------通信协议------------------------------------//
Image:"IE"+Pixle[0[1]+……+Pixle[LCDH-1[LCDW-1]+"\r\n"//图像协议
Iamge_MIDdle:"IM"+Iamge_MIDdle[0][1]……+Iamge_MIDdle[1][IMH-1]+"\r\n"//中线协议,发送(x,y);0为x,1为y
Iamge_LIFE_SIDE:"IL"+Iamge_LIFE_SIDE[0][1]+……+Iamge_LIFE_SIDE[1][IL-1]+"\r\n"//左边线协议,发送(x,y)
Iamge_RIGHT_SIDE:"IR"+Iamge_RIGHT_SIDE[0][1]+……+Iamge_RIGHT_SIDE[1][IR-1]+"\r\n"//右边线协议,发送(x,y)
Speed_PID:"MP"+(int16)Speed_P*1000+"MI"+(int16)Speed_I*1000+"MD"
+(int16)Speed_D*1000+"\r\n"//电机PID数据协议
SD5_PD:"SW"+(int16)SD5_Out+"SP"+(int16)Speed_P*1000+"SD"
+(int16)Speed_D*1000+"\r\n"//舵机PD数据协议
//---------------------------通信协议------------------------------------//
三、上位机
上位机采用python编写,环境为visual studio 2017。主要由 GUI设计、 串口通信、数据处理、图像和波形绘制四部分组成。用到的python第三方库有 pyqt5、pyserial、pygraph、threading、pyinstaller、pyuic5。
1.GUI设计
① QT是常用的图形界面设计库,功能十分强大,它有一个图形设计工具QT designer,只需要一些简单的拖动和设置就能自动生成相应的代码,百度能搜到有很详细的教程。
QT designer生成的文件是后缀为.ui的专属文件,可以通过pyuic5这个第三方库将它转化为.py文件。
pyuic5的使用方法和pip类似,安装好后将它放在系统环境变量python的文件夹里,在对应.ui的目录命令行下输入
pyuic 要转化成的py文件名字.py ui文件对应名字.ui
后缀不能必须要打上,就能转化为.py文件。
② 在主程序中便可直接用以下代码生成一个GUI。
def show_w():
'显示窗口'
app = QApplication(sys.argv) # 所有的PyQt5应用必须创建一个应用(Application)对象。
# sys.argv参数是一个来自命令行的参数列表。
w = Pyqt5_Serial() #对应的UI类
w.show() # show()方法在屏幕上显示出widget。一个widget对象在这里第一次被在内存中创建,并且之后在屏幕上显示。
sys.exit(app.exec_()) # 应用进入主循环。在这个地方,事件处理开始执行。主循环用于接收来自窗口触发的事件,
# 并且转发他们到widget应用上处理。如果我们调用exit()方法或主widget组件被销毁,主循环将退出。
# sys.exit()方法确保一个不留垃圾的退出。系统环境将会被通知应用是怎样被结束的。
if __name__ == '__main__':
show_w()
2.串口通信
串口通信用的是python的pyserial库,简单的代码就能实现串口的通信。
self.ser是通过self.ser = serial.Serial() 创建的一个串口变量,通过它的各种方法实现对串口参数,如波特率,停止位的配置,还有串口数据接收发送等功能。
串口通信的主要思路是:先查询系统的串口信息,然后在读取GUI界面中输入的信息,来选择串口和配置对应的参数,然后通过设置定时器来实现数据的定时发送和采集,采集的数据保存等待下一步的处理。
# 打开串口
def port_open(self):
self.ser.port = self.s1__box_2.currentText()#端口
self.ser.baudrate = int(self.s1__box_3.currentText())
self.ser.bytesize = int(self.s1__box_4.currentText())
self.ser.stopbits = int(self.s1__box_6.currentText())
self.ser.parity = self.s1__box_5.currentText()
try:
self.ser.open()
except:
QMessageBox.critical(self, "Port Error", "此串口不能被打开!")
return None
# 发送数据
def data_send(self):
if self.ser.isOpen():
#发送输入框内的数据
input_s += self.s3__send_text.toPlainText()
if input_s != "":
# 非空字符串
if self.hex_send.isChecked():
# hex发送
input_s = input_s.strip()
send_list = []
while input_s != '':
try:
num = int(input_s[0:2], 16)
except ValueError:
QMessageBox.critical(self, 'wrong data', '请输入十六进制数据,以空格分开!')
return None
input_s = input_s[2:].strip()
send_list.append(num)
input_s = bytes(send_list)
else:
# ascii发送
input_s = (input_s).encode('utf-8')
num += self.ser.write(input_s)
self.data_num_sended += num
self.lineEdit_2.setText(str(self.data_num_sended))
else:
pass
3.数据处理
数据处理部分的思路为:当GUI界面中勾选了有关图像和波形的选项时,就对数据进行分析,否则就将发送来的数据直接显示。
数据分析的基本思路为:先将数据存入缓存,防止遗失,再检测此次数据的完整性,即是否存在帧尾“\n\r”,存在则进行下一步缓存数据分类,不存在则说明未传递完成,先将这次数据添加到缓存数据,等到检测到帧尾后在进行处理。
数据优先级为(左高右低):图像数据–>中线–>边线–>电机pid–>舵机数据
①图像数据分析:检测到帧头,由于图像数据定长的,所以直接向后读取对应的位数,处理完成后删除临时缓存区从帧头位置到帧尾位置的数据,更新GUI中的图像。
②中、边线数据分析:检测到帧头,由于数据是不定长的,计算帧头到帧尾距离,在进行读取,完成后删除对应缓存数据,更新GUI中的图像。
③电机及舵机数据分析:检测到帧头,由于图像数据定长且位数固定,直接按照位置跳跃读取,其中注意由于这些数据是占16位的,而串口发送数据为8位,所以要进行位操作还原数据,按照通信协议应该将数据再进行对应还原。
if 0X4D in temp_data:#"M" ②电机数据部分
temp=temp_data.index(0X4D)#重定向
if(temp_data[temp+1]==0X50):#"P"
Speed_p.append(float((temp_data[temp+2]<<8)+temp_data[temp+3])/1000)#p
Speed_i.append(float((temp_data[temp+6]<<8)+temp_data[temp+7])/1000)#i
Speed_d.append(float((temp_data[temp+10]<<8)+temp_data[temp+11])/1000)#d
end=temp+11+3
del temp_data[temp:end]#删除已经显示图像数据
self.graphdata()#更新波形
receive_meg="finish"#接收完成
if 0X53 in temp_data:#"S" ③舵机数据部分
temp=temp_data.index(0X53)
if(temp_data[temp+1]==0X57):#"W" PWM
SD_PWM.append(float((temp_data[temp+2]<<8)+temp_data[temp+3]))#pwm
SD_P.append(float((temp_data[temp+6]<<8)+temp_data[temp+7])/1000)#p
SD_D.append(float((temp_data[temp+10]<<8)+temp_data[temp+11])/1000)#d
end=temp+11+3
del temp_data[temp:end]#删除已经显示图像数据
self.graphdata()#更新波形
receive_meg="finish"#接收完成
4.图像和波形绘制
①图像部分绘制用的是pyqt5,采用双缓冲技术,即先将要绘制的图像缓存好,后在绘制的时候一次性写入,可以提高绘图速度和显示质量。图像部分在子线程中绘制,以保证主的GUI界面流畅。
def thread_draw(self):
th_draw=threading.Thread(target=self.update)#实例化一个画图像线程
th_draw.start()#开始线程
paintEvent函数是pyqt5中已有的函数,可以通过重写来实现自定义绘图,当程序中调用update方法时,自动运行paintEvent完成绘图工作。
def paintEvent(self, QPaintEvent):
QP_Win=QPainter(self) #实例化一个画布。用窗口作为画布
QP_img=QPainter(self.pix_img) #实例化一个画布。用图片对象作为画布
#勾选图像显示 #勾选中线显示 #勾选边线显示
if self.Pixle.isChecked()or self.midline.isChecked()or self.sideline.isChecked():#GUI是否勾选
if self.Pixle.isChecked(): #勾选图像部分
self.drawPoints(QP_img) #画二值化图像
else:
self.draw_clear() #清除图像
self.drawPoints(QP_img)
if self.midline.isChecked():
self.draw_midorsidline(QP_img,mid_buff,Qt.blue,len(mid_buff[0])) #画中线
if self.sideline.isChecked():
self.draw_midorsidline(QP_img,ls_buff,Qt.yellow,len(ls_buff[0])) #画左边线
self.draw_midorsidline(QP_img,rs_buff,Qt.yellow,len(rs_buff[0])) #画右边线
QP_Win.drawPixmap(20,50, self.pix_img) #在画布上加载图片
def drawPoints(self,QPainter): #将缓存写入
pen = QPen(Qt.black, 6, Qt.SolidLine)
QPainter.setPen(pen)
if not image_buff==None:#缓存不为空
for i in range(LCDH):
for j in range(LCDW):
if image_buff[i][j]==0:# 0表示黑,1表示白
pen.setColor(Qt.black)
QPainter.setPen(pen)
else:
pen.setColor(Qt.white)
QPainter.setPen(pen)
QPainter.drawPoint(j*6+6,i*6+6)
②波形部分绘制用的是pygraph库,pygraph绘图库通过以下代码,可以运行官方写的例程,内容详细,很容易上手
import pyqtgraph.examples
pyqtgraph.examples.run()
以下代码是将pygraph绘图界面加入pyqt5的GUI中
self.pra_pid=pg.PlotWidget()#实例化一个绘图部件
self.horizontalLayout_2.addWidget(self.pra_pid)#显示在指定层中
self.pra_pwm=pg.PlotWidget()#实例化一个绘图部件
self.horizontalLayout_6.addWidget(self.pra_pwm)#显示在指定层中
绘制波形只需将下一点写入,pygraph就将自动连接上下点,以下是绘制一个下一点的函数
def graph_one_data(self,pw,box,buff):
len_buff=len(buff)
if len_buff>30:
pw.setXRange(max=len_buff+1,min=len_buff-40)
box.setData(buff)
pygraph还可以实现坐标轴根据鼠标滚轮缩放和移动。
五、生成可执行文件
最后用pyinstaller将程序打包为不需要python环境的可执行文件。
pyinstaller -Fw 主程序名字.py -i 图标名字.ico
其中 -F表示生成独立的exe文件(常用),-w表示不产生命令提示框,-i表示采用后面的文件作为图标。
最后要注意pyinstaller是不支持最新版本的pyqt5的,被这个坑了很久,在环境中可以正常运行,打包后就报错,解决办法:pyqt5退回之前的版本(5.8.2)。
pip uninstall pyqt5
pip install pyqt5==5.8.2
还有其他pyinstaller报错的可以参考这篇文章:https://www.jianshu.com/p/2ad620ff9b64