发布时间:2023-04-20 文章分类:电脑百科 投稿人:王小丽 字号: 默认 | | 超大 打印

广东省工科赛—智能终端配送机器人:创作心路历程,踩过的坑和解决方案

  • 前言
    • 1、配送小车的成品图
    • 2、作品视频演示
  • 一、控制方案
    • 1、器件选择
    • 2、总体创作历程
  • 二、SMT32下位机控制程序和逻辑分析
    • 1、电机驱动
    • 2、速度环PID
      • (1)、编码器
      • (2)、增量式PID算法
      • (3)、野火上位机PID参数整定
    • 3、HWT101CT单轴陀螺仪
    • 4、SYN-6288语音模块
    • 5、舵机
  • 三、Jetson nano上位机程序和可视化界面
    • 0、遇到的问题和解决方案
      • (1)、Serial
      • (2)、端口号问题
      • (3)、调节摄像头曝光度问题
    • 1、扫码模块
    • 2、可视化界面
      • (1)、视觉调试界面
      • (2)、配送小车主界面
    • 3、颜色识别
      • (1)、区块识别
      • (2)、红绿灯识别
  • 四、电路设计

前言

最近在准备广东省工科赛的智能配送组赛项,在这里记录一下我和队友的创作心路历程和踩过的坑。配送小车的运行流程及赛道如下所示:

  1. 配送小车在黄色出发区等待出发。小车扫描二维码,根据二维码信息打开对应仓门,同时语音播报,放入物料后关闭仓门,配送小车出发。
  2. 配送小车出发前往紫色等待区,识别即将进入紫色等待区时配送小车减速,识别到即将离开紫色等待区时停车,确保精准停车。
  3. 在紫色等待区配送小车抬头识别红绿灯,当绿灯亮起时小车出发,并根据二维码信息左右平移至目标收货区(棕色)。
  4. 配送小车到达指定收货地点,停车并语音播报,扫描二维码,打开对应仓门并语音播报,拿取物料侯关闭仓门,小车自转180°。
  5. 配送小车平移至回到等待区前方,停车并向前出发,直到识别到黄色出发区,减速并控制黄色出发区在视角内居中。
  6. 配送小车回到出发区,配送任务完成。
    广东省工科赛 智能终端配送机器人:创作心路历程,踩过的坑和解决方案

1、配送小车的成品图

广东省工科赛 智能终端配送机器人:创作心路历程,踩过的坑和解决方案

广东省工科赛 智能终端配送机器人:创作心路历程,踩过的坑和解决方案

2、作品视频演示

Jetson nano智能物流配送小车

一、控制方案

1、器件选择

小车构成 名称
上位机 Jetson nano
下位机 STM32F103 STM32F407
舵机 DS3120数字舵机
底盘 四麦克纳姆轮移动底盘
电机 1:30减速比 带编码器减速电机
电机驱动 双路直流电机驱动
摄像头 USB工业摄像头
陀螺仪 JY901S九轴陀螺仪 HWT101CT航向角陀螺仪
语音模块 SYN-6288语音模块
扫码模块 Scanner V3.0嵌入式扫码模块

2、总体创作历程

既然是小车,那自然是从底盘开始干起,一开始我们采用的是STM32F103最小系统板作为底盘控制板,然后在实验室找了一块电源板,准备开始测试电机驱动。我们使用的电机是1:30减速比减速电机,电机驱动是韦恩斯塔克科技的双路直流电机驱动。
于是有了下面这张我们小车的雏形:
广东省工科赛 智能终端配送机器人:创作心路历程,踩过的坑和解决方案
浅浅地落地试了一下,效果还不错。测试完毕后,我开始着手设计我的上位机UI界面,并编写我的上位机程序。我采用Jetson nano作为上位机,与单片机进行通信,控制单片机完成各种动作。测试上下位机通信正常后,我再加入了颜色识别和扫码模块的程序,而后搭载于车上,进行了测试。可能由于配重问题或场地不平稳问题,导致小车无法正常直行和平移,东倒西歪,于是我加上了PID算法,使得小车能够稳定行驶,这个时候整块STM32F103系统板引脚基本都被插满了,看着也很乱,于是我换成了STM32F407作为底盘控制板使用。

测试后发现小车虽然能够正常直行,但是平移的时候还是会有些许问题。经过反复测试后,发现是因场地不平稳导致麦轮悬空,从而走歪,于是我加了陀螺仪对小车进行矫正,一开始使用的是九轴陀螺仪JY901S,但是实际上路时发现效果不是很好,会有-3°至3°的误差。而且最难受的是,当你开始怀疑陀螺仪不精准是不是受到了磁场影响,于是你去掉了可能存在的磁场干扰。但是当你做完这些后准备重新校准时,你就麻烦大了,你需要将整辆车抬起来,绕两个方向旋转360°。我的这辆小车还是挺重的,转个一两次直接裂开。于是后面换了HWT101CT单轴陀螺仪解决了这个问题。后面又继续调试了一些视觉方面的程序,修改了一些bug后,也算是基本完成了。

到这里你可能会觉得总体思路挺顺畅的,其实在实现的过程中还有很多奇奇怪怪的问题和一些值得一提的坑,我会在下文一一道来。

二、SMT32下位机控制程序和逻辑分析

我的STM32下位机控制采用的是STM32F407ZGT6最小系统板,基于HAL库编写了减速电机、编码器、陀螺仪、语音模块灯模块的驱动程序,以及PID速度环的闭环系统。

1、电机驱动

首先放上一些简单的电机驱动程序,包括电机PWM设置和电机的使能设置。

/*---------------------------- 设置电机PWM ----------------------------*/
void Motor1_pwm_Set(int motor1_n)	// 电机1设置PWM
{
   HAL_TIM_PWM_Stop(&htim1, TIM_CHANNEL_1);
   Motor_PWM_sConfigOC.Pulse = motor1_n;
   HAL_TIM_PWM_ConfigChannel(&htim1, &Motor_PWM_sConfigOC, TIM_CHANNEL_1);
   HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1);
	//Motor1_pwm_num = (motor1_n / (float)1000)*100;
}
/*---------------------------- 电机使能 ----------------------------*/
void Motor1_up_enable(void)			// 电机1正转使能
{
	HAL_GPIO_WritePin(Motor_INA_GPIOx, Motor1_IN_GPIO, GPIO_PIN_RESET);
	HAL_GPIO_WritePin(Motor_INB_GPIOx, Motor1_IN_GPIO, GPIO_PIN_SET);
}
void Motor1_lost_enable(void)			// 电机1反转使能
{
	HAL_GPIO_WritePin(Motor_INA_GPIOx, Motor1_IN_GPIO, GPIO_PIN_SET);
	HAL_GPIO_WritePin(Motor_INB_GPIOx, Motor1_IN_GPIO, GPIO_PIN_RESET);
}
void Motor1_disable(void)			// 电机1失能
{
	HAL_GPIO_WritePin(Motor_INA_GPIOx, Motor1_IN_GPIO, GPIO_PIN_RESET);
	HAL_GPIO_WritePin(Motor_INB_GPIOx, Motor1_IN_GPIO, GPIO_PIN_RESET);
}

2、速度环PID

如上总体创作历程所述,为了解决配送小车行走偏移的问题,我先采用的是增量式PID算法,利用编码器实现配送小车4轮的速度闭环。

(1)、编码器

我们选购的编码器为AB双向增量式磁性霍尔编码器,其会将电机运转时的位移信息转变成一段连续的脉冲信号,利用脉冲个数可算得位移量。
在开始上手之前需要注意的事项

1、首先第一步还是要把传感器搞定,确保单位时间内获取的编码器脉冲值是正确的。
2、我们购买的编码器是霍尔编码器,即为磁电转换的编码器,而且我在装车前就已经将编码器外壳拆卸掉了,因此需要确保编码器不会受到外界磁场过强的干扰。
3、给编码器供电的电源板切记要和单片机共地,我就是因为没有和单片机共地导致信号不稳定,耗费了很长时间。
4、注意好CubeMx配置的几倍频。

下面是我做的一些编码器的笔记,记录下以后方便查阅,就是字有点小丑😅:
广东省工科赛 智能终端配送机器人:创作心路历程,踩过的坑和解决方案
如上所示,假如我50ms获取一次编码器的当前总值,那么用当前编码器的值除以编码器单圈脉冲数,就能得到50ms内电机转了几圈。
编码器单圈脉冲数的值 = 倍频数 × 电机线数 × 减速比
编码器值分析程序如下:

float girth = 77*3.1415f;			// 轮周长(mm)
float encoder_mc = 4*11*30;		// 编码器单圈脉冲数
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)	// 定时器中断回调函数
{
    if (htim == &htim7) //50ms调用一次
    {
			enc_1 = -(int32_t)__HAL_TIM_GET_COUNTER(&htim3);	// 获取编码器1值(enc_1/50ms)
			__HAL_TIM_SetCounter(&htim3, 0);		// mcu编码器接口置0
			Motor1_now_Speed = (((float)enc_1 / encoder_mc)*girth/50)*1000;	//计算轮1当前速度(mm/s)
	}
}

(enc_1 / encoder_mc)求出了50ms内的圈数,乘以轮周长girth则得到50ms内走的位移,再除以50乘以1000则得到一秒单位时间内的位移。

(2)、增量式PID算法

增量式PID公式如下图所示:
广东省工科赛 智能终端配送机器人:创作心路历程,踩过的坑和解决方案

关于PID算法我是在b站up主421施公队的从不懂到会用!PID从理论到实践视频学的,视频里讲的PID算法个人认为通俗易懂,是个相当不错的视频。
关于增量式PID算法程序如下所示:

int threshold = 800;	// 我电机定时器的ARR设置为1000,所以我这里限幅在-800至800的范围内
Error = target_Speed  - now_Speed;		// 用目标速度减去当前速度得到当前的误差值
P_Error = Error - Last_Error;
I_Error = Error;
D_Error = Error - 2*Last_Error + Last_Last_Error;
add = KP * P_Error + KI * I_Error + KD * D_Error;	// 对应上图公式
PWM += add;		// 增加正数或负数的PID输出
Last_Last_Error = Last_Error;	// 上一个误差在下一次变为上上个误差
Last_Error = Error;		// 当前误差在下一次变为上一个误差
if(PWM > 800)     PWM = 800;	// 限幅
else if(PWM < -800)     PWM = -800;		// 限幅

如上程序所示,限幅为800,那么为什么会有一个-800呢?其实这也是PID的特性之一,后面程序当判断为负值时反向旋转轮子,并把值置为正数。如下程序所示(以电机1为例):

if(motor1_pwm >= 0){Motor1_up_enable();}	 // 判断PWM为正数,正转且可直接赋值
else if(motor1_pwm < 0){Motor1_lost_enable();motor1_pwm = -motor1_pwm;}		// 判断PWM为负数,反转且将PWM正负翻转
Motor1_pwm_Set(motor1_pwm);     // 电机1赋pwm

(3)、野火上位机PID参数整定

关于最重要的PID参数的整定,我请教了师兄后了解到了野火的上位机和正点原子的无线调试器,于是我参考野火手册上手写了一套野火上位机的远程调试程序,但是不知道什么原因,无线调试器一直无法实现电脑端向单片机端发送数据😅。因此无线调试时只能看到波形,然后改代码远程烧录。
具体的上位机介绍和部分例程在野火的上位机文档里也有,有需要的也可以去参考野火上位机的文档。如下为野火的上位机程序:

/*==============================↓上下位机通信↓============================== */
/**
  * @brief  设置pid
  */
void set_p_i_d(float p, float i, float d)
{
  	KP = p;    // P
	KI = i;    // I
	KD = d;    // D
}
/**
  * @brief 计算校验和
  * @param ptr:需要计算的数据
  * @param len:需要计算的长度
  * @retval 校验和
  */
uint8_t check_sum(uint8_t init, uint8_t *ptr, uint8_t len)
{
  uint8_t sum = init;
  while(len--)
  {
    sum += *ptr;
    ptr++;
  }
  return sum;
}
/**
  * @brief 设置上位机的值
  * @param cmd:命令
  * @param ch: 曲线通道
  * @param data:参数指针
  * @param num:参数个数
  * @retval 无
  */
void set_computer_value(uint8_t cmd, uint8_t ch, void *data, uint8_t num)
{
  uint8_t sum = 0;    // 校验和
  num *= 4;           // 一个参数4个字节
  static packet_head_t set_packet;
  set_packet.head = FRAME_HEADER;     // 包头 0x59485A53
  set_packet.len  = 0x0B + num;      // 包头
  set_packet.ch   = ch;              // 设置通道
  set_packet.cmd  = cmd;             // 设置命令
  sum = check_sum(0, (uint8_t *)&set_packet, sizeof(set_packet));       // 计算包头校验和
  sum = check_sum(sum, (uint8_t *)data, num);                           // 计算参数校验和
  HAL_UART_Transmit(&huart1, (uint8_t *)&set_packet, sizeof(set_packet), 0xFFFFF);    // 发送数据头
  HAL_UART_Transmit(&huart1, (uint8_t *)data, num, 0xFFFFF);                          // 发送参数
  HAL_UART_Transmit(&huart1, (uint8_t *)&sum, sizeof(sum), 0xFFFFF);                  // 发送校验和
}
/**
* @brief 读取上位机的值
  * @param cmd:命令
  * @param ch: 曲线通道
  * @param data:参数指针
  * @retval 无
  */
void Read_Computer(UART_HandleTypeDef *husart)
{
  packet_head_t packet;
  packet.cmd = rx_buffer[CMD_INDEX_VAL];
  packet.len  = COMPOUND_32BIT(&rx_buffer[LEN_INDEX_VAL]);     
  packet.head = COMPOUND_32BIT(&rx_buffer[HEAD_INDEX_VAL]);    
  if (packet.head == FRAME_HEADER)    
  {
      switch(packet.cmd)
      {
        case SET_P_I_D_CMD:		// 设置PID值
        {
          uint32_t temp0 = COMPOUND_32BIT(&rx_buffer[13]);
          uint32_t temp1 = COMPOUND_32BIT(&rx_buffer[17]);
          uint32_t temp2 = COMPOUND_32BIT(&rx_buffer[21]);
          float p_temp, i_temp, d_temp;
          p_temp = *(float *)&temp0;
          i_temp = *(float *)&temp1;
          d_temp = *(float *)&temp2;
          set_p_i_d(p_temp, i_temp, d_temp);    // 设置P I D
        }
        break;
        case SET_TARGET_CMD:
        {
			// 设置各电机期待值
        }
        break;
        case START_CMD:		// 开始按键
        {
					car_forward();
					//car_left();
					//car_right();
					run_ornot = 1;
        }
        break;
        case STOP_CMD:		// 停止按键
        {
					run_ornot = 0;
					Motor1_disable();
					Motor2_disable();
					Motor3_disable();
					Motor4_disable();
					Motor1.ESC_Output_PWM = 0;
					Motor1.Last_Error = 0;
					Motor1.Last_Last_Error = 0;
					Motor2.ESC_Output_PWM = 0;
					Motor2.Last_Error = 0;
					Motor2.Last_Last_Error = 0;
					Motor3.ESC_Output_PWM = 0;
					Motor3.Last_Error = 0;
					Motor3.Last_Last_Error = 0;
					Motor4.ESC_Output_PWM = 0;
					Motor4.Last_Error = 0;
					Motor4.Last_Last_Error = 0;            
        }
        break;
        case RESET_CMD:		// 复位单片机
        {
          // HAL_NVIC_SystemReset();      
        }
        break;
      }
  }
}

然后只需在获取编码器后调用set_computer_value发送当前速度值,和在串口接收的回调函数中添加Read_Computer函数获取上位机指令就ok了。

3、HWT101CT单轴陀螺仪

为了解决麦轮悬空而引起的偏移问题,我决定加上一个陀螺仪来矫正。
值得注意的点是,一开始我们选用了九轴陀螺仪JY901S,但是其实这类型的陀螺仪并不适用于我的这辆配送小车。首先我的需求只有单纯的Z轴上的角度,其次这款陀螺仪要矫正起来非常麻烦,而且容易受磁场干扰,车上的空间非常有限,磁场强的东西又非常多,比如Jetson nano和电机,还有个磁场最猛的扬声器在车上。后面选用了HWT101CT单轴陀螺仪解决了这一问题,首先这块陀螺仪可以满足我单Z轴上的需求,其次它有一个较好的外壳,包括线也是有比较好的保护,而且它受磁场干扰后,只要离开磁场就能自动回到正常状态,不用我搬着整台车翻来覆去地矫正。

链接为陀螺仪说明书:HWT101CT通信协议
广东省工科赛 智能终端配送机器人:创作心路历程,踩过的坑和解决方案
广东省工科赛 智能终端配送机器人:创作心路历程,踩过的坑和解决方案从0x52开始,以0为初始值算起的16位和17位为角度输出的低8位和高8位。如下图所示:
广东省工科赛 智能终端配送机器人:创作心路历程,踩过的坑和解决方案

陀螺仪角度获取程序如下所示:

// 陀螺仪串口接收
uint8_t value_IMU;			// 串口输入值
uint8_t getBuffer[22];		// 存放数据的数组
uint8_t Enter[] = "\r\n";
float IMU_DATA;		// 角度值
uint8_t IMU_DATAL;	// 数据位低8位
uint8_t IMU_DATAH;	// 数据位高8位
volatile float IMU_Z = 0.00f;
volatile float IMU_init_data = 0.00f;		// 陀螺仪初始角度
volatile char first_flag = 0;						// 判断第一次录入陀螺仪角度
uint8_t countOfGetBuffer = 0;	// 位数累计
char flag = 0;	// 判断接收开始
char now_uart_flag = 0;		// 当前输入值
char last_uart_flag = 0;	// 上一个输入值
void USART3_IRQHandler(void)
{
  /* USER CODE BEGIN USART3_IRQn 0 */
  /* USER CODE END USART3_IRQn 0 */
  HAL_UART_IRQHandler(&huart3);
  /* USER CODE BEGIN USART3_IRQn 1 */
	last_uart_flag = now_uart_flag;		// 上一次的串口输入值
	now_uart_flag = value_IMU;			// 当前的串口输入值
	if(last_uart_flag == 0x55 && now_uart_flag == 0x52)		// 协议判断输入角度值
	{
		//printf("IMU\r\n");
		flag = 1;		// 开启接收程序
	}
	//printf("%c", value_IMU);
	if(flag == 1)
	{
		getBuffer[countOfGetBuffer++] = value_IMU; 
		if(countOfGetBuffer == 18)		// 位长
		{
			flag = 0;
			//while(HAL_UART_Transmit(&huart3, (uint8_t*)getBuffer, countOfGetBuffer, 5000)!= HAL_OK);
			//while(HAL_UART_Transmit(&huart3, (uint8_t*)Enter, 4, 5000)!= HAL_OK);
			IMU_DATAL = getBuffer[16];
			IMU_DATAH = getBuffer[17];
			IMU_DATA = (((short)IMU_DATAH<<8) | IMU_DATAL);		// 高8位低8位
			IMU_Z = ((float)IMU_DATA/32768)*180-180;	// 把0°至360°范围改为-180°至180°
			if(first_flag == 0)		// 单片机复位第一次获取陀螺仪值设置为初始正向
			{
				IMU_init_data = IMU_Z;
				first_flag = 1;
				printf("Start:IMU_init_data:%.2f\r\n", IMU_init_data);
			}	
			//printf("IMU_init_data:%.2f\r\n", IMU_init_data);
			memset(getBuffer, 0, countOfGetBuffer);		// 清空数组
			countOfGetBuffer = 0;	// 位数清零
		}
	}
	HAL_UART_Receive_IT(&huart3, (uint8_t *)&value_IMU,1);
  /* USER CODE END USART3_IRQn 1 */
}

4、SYN-6288语音模块

语音模块一样也是用串口通信,程序如下,商家提供的库函数的协议程序,不过是寄存器操作,所以影响不大:

/*---------------------- 语音模块串口播放 ----------------------*/
void USART2_SendData(u8 data)
{
	while((USART2->SR & 0X40) == 0);
	USART2->DR = data;
}
void USART2_SendString(u8 *DAT, u8 len)
{
	u8 i;
	for(i = 0; i < len; i++)
	{
		USART2_SendData(*DAT++);
	}
}
/*---------------------- 语音播报API定义 ----------------------*/
//Music:选择背景音乐,0:无背景音乐,1~15:选择背景音乐
//v[0~16]:朗读音量
//m[0~16]:背景音乐音量
//t[0~5]:语速
void SYN_FrameInfo(u8 Music, u8 *HZdata)
{
  /****************需要发送的文本**********************************/
  unsigned  char  Frame_Info[50];
  unsigned  char  HZ_Length;
  unsigned  char  ecc  = 0;  			//定义校验字节
  unsigned  int i = 0;
  HZ_Length = strlen((char*)HZdata); 			//需要发送的文本长度
  /*****************帧固定配置信息**************************************/
  Frame_Info[0] = 0xFD ; 				//构造帧头FD
  Frame_Info[1] = 0x00 ; 				//构造数据区长度的高字节
  Frame_Info[2] = HZ_Length + 3; 		//构造数据区长度的低字节
  Frame_Info[3] = 0x01 ; 				//构造命令字:合成播放命令
  Frame_Info[4] = 0x01 | Music << 4 ; 	//构造命令参数:背景音乐设定
  /*******************校验码计算***************************************/
  for(i = 0; i < 5; i++)
  {
    ecc = ecc  (Frame_Info[i]);
  }
  for(i = 0; i < HZ_Length; i++)
  {
    ecc = ecc  (HZdata[i]);
  }
  memcpy(&Frame_Info[5], HZdata, HZ_Length);
  Frame_Info[5 + HZ_Length] = ecc;
  USART2_SendString(Frame_Info, 5 + HZ_Length + 1);
}
void YS_SYN_Set(u8 *Info_data)
{
  u8 Com_Len;
  Com_Len = strlen((char*)Info_data);
  USART2_SendString(Info_data, Com_Len);
}

只需要简单的调用即可,如下所示:

void speaker0(void)
{
	SYN_FrameInfo(1,(u8 *)"[v6][m6][t5]¸高德地图持续为您导航");
	HAL_Delay(3000);
}

5、舵机

本俩配送小车共搭载了3个舵机,一个用于摄像头的抬头低头,两个用于打开和关闭仓门。
首先要将定时器周期配置为20ms,然后给出的PWM高电平应为0.5ms至2.5ms,即PWM占空比为2.5%至12.5%。我的ARR值设置为1000,因此修改范围为25~125。
舵机PWM占空比设置程序如下所示:

/*---------------------- 舵机PWM设置 ----------------------*/
// PWM占空比设置范围:25 ~ 125
void Servo1_pwm_Set(int Servo1_n)
{
   HAL_TIM_PWM_Stop(&htim2, TIM_CHANNEL_1);
   Servo_PWM_sConfigOC.Pulse = Servo1_n;
   HAL_TIM_PWM_ConfigChannel(&htim2, &Servo_PWM_sConfigOC, TIM_CHANNEL_1);
   HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
}

三、Jetson nano上位机程序和可视化界面

对于Jetson nano上位机程序的编写,我使用的是Python语言,主要功能视觉用的是OpenCV,界面设计用的是PyQt5。先从我使用Jetson nano编写程序时遇到的问题介绍起吧。

0、遇到的问题和解决方案

(1)、Serial

在安装python的serial库后遇过一个问题,在引用serial,即import serial时出现了报错:

AttributeError: module 'serial' has no attribute 'Serial'

解决方案
一开始只使用了pip3 install serial安装serial库,少安装了一个pyserial。
正确的安装方法:

pip3 install serial
pip3 install pyserial

(2)、端口号问题

如果按照nano系统默认的端口配对,当接入多个端口时,拔插一个端口后名字就会改变,非常麻烦,需要一直改程序,或者不拔插端口,很不现实。
于是我决定定死端口,给要用的端口配对好名称,对应摄像头ID配对好摄像头接口名称,只要除摄像头外的接口插在对应的端口上就可以防止以上问题发生。
因此,解决方案
(1)、打开终端

udevadm info /dev/videoxx(/dev/ttyUSBxx)	# 查看当前端口所在接口的信息
cd /etc/udev/rules.d
sudo gedit serial.rules

进入文档,以下为我的配置案例,可参考使用:

ACTION=="add",KERNEL=="video*",ATTRS{idVendor}=="0bc8", ATTRS{idProduct}=="5880", MODE:="0777", SYMLINK+="car_video"
ACTION=="add",KERNELS=="1-2.1.1:1.0",SUBSYSTEMS=="usb",MODE:="0777",SYMLINK+="car_scanner"
ACTION=="add",KERNELS=="1-2.2:1.0",SUBSYSTEMS=="usb",MODE:="0777",SYMLINK+="car_mcu"
ACTION=="add",KERNELS=="1-2.4:1.0",SUBSYSTEMS=="usb",MODE:="0777",SYMLINK+="car_gyro"

退出后输入以下命令完成配置

sudo /etc/init.d/udev restart

重新拔插一次端口就ok了。

(3)、调节摄像头曝光度问题

一开始我在windows使用opencv能够正常切换摄像头自动和手动曝光,但是来到nano的ubuntu系统上就不能用了,也可能是opencv的版本问题,后面我解决的方法是在终端上对摄像头模式进行调节。
首先需要安装pexpect库:

pip3 install pexpect

在程序里调用如下:

# 自动调节
child = pexpect.spawn('v4l2-ctl -d /dev/car_video  -c exposure_auto=3')
time.sleep(1)
# 手动调节
child = pexpect.spawn('v4l2-ctl -d /dev/car_video  -c exposure_auto=1')
time.sleep(1)
child = pexpect.spawn('v4l2-ctl -d /dev/car_video  -c exposure_absolute=50')
time.sleep(1)

v4l2-ctl -d /dev/car_video -c exposure_auto=xx为终端命令。
exposure_auto=3为自动调节,1为手动调节,exposure_absolute=50指曝光值为50。
以下为摄像头改变为手动曝光模式:

def video_modeltoled(self):
    self.video.release()
    child = pexpect.spawn('v4l2-ctl -d /dev/car_video  -c exposure_auto=1')
    time.sleep(1)
    child = pexpect.spawn('v4l2-ctl -d /dev/car_video  -c exposure_absolute=1')
    time.sleep(1)
    while True:
        self.video = cv2.VideoCapture("/dev/car_video")           # 定义识别红绿灯摄像头对象
        if self.video.isOpened() is True:
            print('摄像头已打开。')
            break
        elif self.video.isOpened() is False:
            print('未接入摄像头。')

1、扫码模块

首先需要配置好扫码模块的功能,配置完成后,则开始编写扫码模块调用程序。
如下所示,我的扫码模块是串口通信的,因此在运行主程序前先要判断扫码模块是否连接成功。

while True:         # 判断串口是否正常连接,且防止报错
    try:
        self.scanner_ser = serial.Serial("/dev/car_scanner", 115200, timeout=10)  # 定义串口对象
        if self.scanner_ser.isOpen():  # 判断串口是否成功打开
            print("扫码模块连接成功。")
            break
        else:
            print("扫码模块连接失败。")
    except:
        print("扫码模块未接入 或 扫码模块串口端口号错误。")
        pass

进入while后将判断扫码模块是否连接成功,如果连接成功则退出while,否则将不停地循环while,直至确认连接成功为止。当终端打印出 扫码模块连接失败。扫码模块未接入 或 扫码模块串口端口号错误。 时,试着拔插扫码模块,即可成功连接,程序退出while,正常运行。
而后开始扫码工作:

print('进入扫码模式。')
self.textEdit.setPlainText("进入扫码模式。")
while True:         # 等待扫描二维码
    ret, img = self.video.read()	# 使用摄像头获取图像
    scanner_data = self.scanner_run()	# 运行扫码模块信息获取函数
    if scanner_data == b'11':
        print('二维码信息为 11 。')
        self.textEdit.append("二维码信息为 11 。")
        self.QR_label.setText('11')
        self.mcu_ser.write('$a'.encode('utf-8'))
        self.end_num = 1
        break
    elif scanner_data == b'12':
        print('二维码信息为 12 。')
        self.textEdit.append("二维码信息为 12 。")
        self.QR_label.setText('12')
        self.mcu_ser.write('$a'.encode('utf-8'))
        self.end_num = 2
        break
    elif scanner_data == b'21':
        print('二维码信息为 21 。')
        self.textEdit.append("二维码信息为 21 。")
        self.QR_label.setText('21')
        self.mcu_ser.write('$b'.encode('utf-8'))
        self.end_num = 1
        break
    elif scanner_data == b'22':
        print('二维码信息为 22 。')
        self.textEdit.append("二维码信息为 22 。")
        self.QR_label.setText('22')
        self.mcu_ser.write('$b'.encode('utf-8'))
        self.end_num = 2
        break
self.textEdit.append("打开Qt界面。")

该程序为主程序中的一部分,当中有些是和扫码模块无关的,在这里稍微解释一下:

  1. 关于self.scanner_run()函数下文会挂上程序。
  2. self.textEdit和self.QR_label都为配送小车主界面的控件,分别用于显示终端打印信息和二维码信息。
  3. self.mcu_ser则为单片机串口对象,此处发送$a为打开1号仓门, $b为打开2号仓门。
  4. self.end_num定义目的地变量。
  5. 使用ret, img = self.video.read()目的是确保摄像头图像获取通道不会一直堵塞着,导致后续获取的图像有所延时。当然这里也可以在前面使用self.video.release()直接关闭摄像头,也可防止出现这种问题,后面完成扫码后再次打开摄像头即可。因为此处资源占用也不多,不会导致程序运行过慢,所以我也是直接调取摄像头来解决该问题。

scanner_run程序如下所示:

def scanner_run(self):      # 扫码模式
     scanner_data = self.scanner_ser.read(4)
     scanner_data = scanner_data.splitlines()
     if scanner_data:
         scanner_data = scanner_data[0]
     return scanner_data

2、可视化界面

(1)、视觉调试界面

赛场调试时间紧迫,如果视觉方面出现什么错误,每次都要重新跑一遍才能发现错误出在哪里那将浪费很多的时间,因此我编写了一个上位机视觉调试qt界面,如下所示。
广东省工科赛 智能终端配送机器人:创作心路历程,踩过的坑和解决方案
视觉调试界面功能

  1. 右侧第二行3个按键分别为3种识别或者说摄像头的模式,自动曝光则是摄像头切换自动曝光模式,手动曝光则是人为调节曝光度,颜色识别模式则是识别色块或是红绿灯的模式。
  2. 右侧第三行可实现在手动曝光模式下切换曝光值,输入曝光值后按确认即可。
  3. 右侧颜色区块选择是我在写程序的时候默认保存了一些阈值范围,方便快速切换到某个区域,再进行微调。
  4. 下面6行拖动条则是调节HSV阈值的,H范围为0-180,S范围为0-255,V范围为0-255。

广东省工科赛 智能终端配送机器人:创作心路历程,踩过的坑和解决方案
左边为摄像头拍摄的图像,右边为颜色识别后的图像。

(2)、配送小车主界面

如下图所示,当扫描完二维码,并获得下位机反馈回来的出发信号时,上位机打开此配送小车主界面。
广东省工科赛 智能终端配送机器人:创作心路历程,踩过的坑和解决方案
配送小车主界面功能

  1. 界面左上角图像为小车的颜色识别图像,在其右侧为摄像头拍摄后进行一定比例缩小的原图像。这两张图像伴随小车行进实时变换,方便在运行中和编写逻辑程序后确认程序是否存在bug,也方便修改bug时的调试。
  2. 界面左下角为配送小车扫描到的二维码信息,进行可视化便于调试。
  3. 界面右下角为配送小车主程序运行时的信息打印,同样也是便于调试和检测程序是否存在bug。

3、颜色识别

以下程序可调转至该链接有详细的解析:OpenCV python(三)【图像预处理:颜色空间转换】 && 颜色识别

(1)、区块识别

挂几个函数:

def transform(self, image):      # 颜色空间转换函数
    # 颜色空间转换,image为导入的RGB图像
    image_g = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)     # GRAY图像
    image_hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)    # HSV图像
    return image_g, image_hsv
def color(self, image_hsv, color_mod):     # 颜色识别函数
    if color_mod is 1:
        image_hsv = image_hsv[142:192 , 0:255]
        image_color = cv2.inRange(image_hsv, (109, 78, 77), (157, 180, 180))   # HSV图像二值化(紫色)
    elif color_mod is 2:
        image_hsv = image_hsv[72:182 , 0:255]
        image_color = cv2.inRange(image_hsv, (60, 70, 95), (96, 255, 255))   # HSV图像二值化(绿色)
    elif color_mod is 3:
        if self.brown_model is 1:
            if self.end_num is 1:
                    image_hsv = image_hsv[122:192 , 155:255]
            elif self.end_num is 2:
                    image_hsv = image_hsv[122:192 , 0:100]
        else:
            pass
    # 。。。。。。等等
        image_color = cv2.inRange(image_hsv, (0, 95, 36), (13, 255, 255))   # HSV图像二值化(棕色1)
    image_color = cv2.erode(image_color, (3, 3), iterations=5)       # 腐蚀
    image_color = cv2.dilate(image_color, (3, 3), iterations=5)      # 膨胀
    return image_color

(2)、红绿灯识别

上述的调节曝光度则是为现在红绿灯识别做准备,一般环境下看红绿灯效果如下:
广东省工科赛 智能终端配送机器人:创作心路历程,踩过的坑和解决方案
上图亮的是红灯,但是完全看不出来红色,后面和师兄聊过后,师兄建议我试试看调节曝光度看看,于是我尝试了一下,如下图所示:
广东省工科赛 智能终端配送机器人:创作心路历程,踩过的坑和解决方案
识别效果很不错,容错率很高,效果如下:
广东省工科赛 智能终端配送机器人:创作心路历程,踩过的坑和解决方案

四、电路设计

以下为我们设计的单片机扩展板和电源板。
广东省工科赛 智能终端配送机器人:创作心路历程,踩过的坑和解决方案
广东省工科赛 智能终端配送机器人:创作心路历程,踩过的坑和解决方案

以上为智能终端配送机器人的创作思路和部分代码,可能或多或少也会存在一些问题,如有错误的话还请指正。过段时间整理好后再开源。