【OpenCV+STM32】二维云台颜色识别及追踪

python 复制代码
        hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)  # 转换到 HSV 空间,便于颜色识别

实现目标:

  • Python + OpenCV 识别色块并追踪目标

  • 通过串口发送舵机控制指令给 STM32F103C8T6

  • STM32(标准库)通过定时器输出 PWM 控制两个舵机(180° + 360°)

目标分解:

1.Python + OpenCV识别到红色物体并把坐标通过串口通信发送给STM32,STM32把坐标进行回传判断是否正确接收

2.STM32控制两个舵机从0度到180度以相同速度转动

3.STM32控制舵机和通信相融合,能通过两个变量控制舵机转的角度,并把角度显示在OLED屏幕上

4.修改Python和STM32通信的消息,把原来图像中心坐标改为控制两个舵机转动的角度,由Python程序控制舵机转动的角度

5.修改Python程序,根据红色物体中心的坐标和图像的偏移量控制舵机转动的角度,调整映射关系

tips:每一步完成之后对文件进行备份,STM32的文件可备份成压缩包,python文件复制一份,都放进所建的工程文件中,防止之后修改时污染源文件

用电安全:

上电顺序,单片机,串口,舵机 (先对小功率进行供电)

断电顺序,舵机,串口,单片机 (先断掉大功率用电器)

烧录时DAP-lLINK出现了短路,故使用时DAP-lLINK应通过隔离器和电脑USB口相连

单片机接到面包板上时,舵机是5V供电(用可控电源),而OLED是3.3V供电,两部分应该共地

可控电源对舵机进行供电时,应在与舵机相连之前调到5V,断电连接,在上电;防止直接上电不确定电压烧毁舵机

单片机不能同时用Mico-USB和SWD下载端供电

代码部分:

Python部分

一.库导入

python 复制代码
import cv2          # OpenCV:用于摄像头采集、图像处理
import numpy as np  # NumPy:用于数组运算和数值处理
import serial       # pySerial:用于与 STM32 串口通信
import time         # time:用于延时,等待串口稳定

二.串口初始化

python 复制代码
port_name = "COM8"          # STM32 所连接的串口号(根据电脑实际情况修改)
baud_rate = 115200          # 串口通信波特率,需与 STM32 端一致,115200快且稳定最常用

try:
    ser = serial.Serial(    # 创建串口对象并尝试打开串口
        port_name,
        baud_rate,
        timeout=0.1         # 读取超时时间(非阻塞读取),读取串口数据最多等待 0.1 秒,超过时间返回空数据
    )
except serial.SerialException as e:  # 捕获串口打开异常,可能串口号不存在,串口被占用,没有权限,把这个错误保存到变量e中
    print(f"串口打开失败: {e}")
    exit()                  # 串口打开失败直接退出程序

time.sleep(2)               # 等待 STM32 复位和串口初始化完成,很多 STM32 板子串口打开 → 自动复位,复位期间不接收数据;系统需要时间初始化串口,立刻发数据容易丢
print(f"使用串口: {port_name}")  # 打印当前使用的串口

except,Python 的关键字,表示:捕获异常;serial.SerialException,这是 pySerial 库定义的一种异常类型,专门用来表示 串口相关错误;as e,把"异常对象"保存到变量 e,e 里面包含了 错误的详细信息

exit(),它是 Python 提供的一个函数,作用:终止当前程序运行

三.摄像头初始化

python 复制代码
cap = cv2.VideoCapture(0)   # 打开默认摄像头(0 表示第一个摄像头)

cv2.VideoCapture,OpenCV 提供的 视频输入接口,作用连接摄像头,或打开视频文件,并提供 read() 方法读取帧;0表示 第 0 个摄像头,一般是:笔记本内置摄像头,或第一个 USB 摄像头;cap 是一个对象,里面保存了:摄像头连接状态,缓冲区,视频参数(分辨率、帧率等)

四.舵机角度初始化

python 复制代码
servo1_angle = 90           # 舵机1初始角度(Y 方向,居中)
servo2_angle = 90           # 舵机2初始角度(X 方向,居中)

五.STM32通信函数

1️⃣发送舵机角度到STM32

python 复制代码
def send_to_stm32(servo1_angle, servo2_angle):
    # 按约定格式组织串口数据,末尾加换行符
    message = f"S1{servo1_angle},S2{servo2_angle}\n"  #以 换行符作为一条指令结束标志,如果不加,STM32 不知道什么时候一条命令结束,会解析失败或卡住,串口通信最重要的就是"分帧"

    try:
        ser.write(message.encode())  # 将字符串编码后写入串口,message 是字符串(str),串口只能发 字节(bytes),.encode()把字符串转成字节流(默认 UTF-8)
        ser.flush()                  # 强制立即发送缓冲区数据,舵机控制不能延迟
        print(f"发送到 STM32: {message.strip()}")  # 打印发送内容,.strip()去掉 \n,打印更干净
    except serial.SerialException as e:
        print(f"串口写入失败: {e}")  # 捕获串口写入异常

message = f"S1{servo1_angle},S2{servo2_angle}\n",f (format)表示这是一个"格式化字符串(f-string)",告诉 Python:

"这个字符串里有变量,要先计算再生成字符串"

可以把变量的值直接"塞进"字符串里,{} 里的变量会被实际数值替换

{servo1_angle}表示:取变量 servo1_angle 的当前值,转成字符串,拼接到字符串中

2️⃣读取STM32回传的数据

python 复制代码
def read_from_stm32():
    try:
        while ser.in_waiting:        # 当串口接收缓冲区有数据时
            line = ser.readline()    # 读取一行数据(以 \n 结尾)
            line = line.decode('utf-8', errors='ignore').strip()  # 解码并去除换行
            if line:                 # 如果不是空行,判断一下,防止打印空行
                print(f"STM32 回传: {line}")  # 打印 STM32 返回信息
    except Exception as e:
        print(f"串口读取异常: {e}")  # 捕获读取异常

为什么要 decode,(line的类型是:bytes)串口收到的是 字节(bytes), Python 打印和处理需要 字符串(str), decode:字节 → 字符串;UTF-8 是最常用的字符编码;errors='ignore'如果遇到非法字符,直接跳过;.strip()去掉字符串两端的\n (换行,光标移动到下一行 ),\r(回车,光标回到行首 )(很多终端只有在收到 \r\n 时,才会正确"回到行首并换行),空格

line = line.decode('utf-8', errors='ignore').strip() 把从串口读到的"原始字节数据",转换成"干净、可打印、可比较的字符串"。

六.主循环

python 复制代码
try:
    while True:  # 程序主循环,持续运行直到手动退出

七.图像采集与基本信息

python 复制代码
        ret, frame = cap.read()  # 从摄像头读取一帧图像
        if not ret:              # 如果读取失败
            break                # 退出循环

        height, width = frame.shape[:2]  # 获取图像高度和宽度
        center_x = width // 2             # 图像中心 X 坐标
        center_y = height // 2            # 图像中心 Y 坐标

cap.read() :从摄像头(由 cv2.VideoCapture 创建的对象 cap)中读取一帧图像。

返回值:ret :布尔值,表示读取是否成功,如果成功读取一帧图像,retTrue;如果读取失败(例如摄像头断开或图像读取错误),retFalse

if not ret: :如果读取失败(即 retFalse),则执行下方代码。

frame.shape :返回一个元组,包含图像的形状((height, width, channels)),即图像的高度、宽度和通道数。height:图像的高度(即图像的行数),width:图像的宽度(即图像的列数),channels:图像的通道数,通常为 3,表示彩色图像的 RGB 通道。frame.shape[:2] :切片操作,取 frame.shape 元组(不可变的序列,支持切片和访问)的前两个值(heightwidth),并将其分别赋给 heightwidth 变量。

center_x = width // 2 :计算图像的水平中心坐标,// 是整除运算符,确保返回一个整数值。

八.颜色空间转换

python 复制代码
        hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)  # 转换到 HSV 空间,便于颜色识别

把摄像头拍到的彩色图像,从 BGR 颜色空间转换成 HSV 颜色空间,目的是:更稳定、更容易地做颜色识别(比如红色)。OpenCV 读到的彩色图像是BGR,HSV 把颜色拆成 3 个独立概念:色相(颜色)、饱和度(纯度)、亮度

九.红色阈值定义

python 复制代码
        lower_red1 = np.array([0, 120, 70])    # 红色低区间(Hue 低端)
        upper_red1 = np.array([10, 255, 255])

        lower_red2 = np.array([170, 120, 70])  # 红色高区间(Hue 高端)
        upper_red2 = np.array([180, 255, 255])

定义"红色"在 HSV 颜色空间中的取值范围,并且由于 红色在 HSV 里是"断开的",所以要分成 两段。HSV 里的 Hue(色相)是一个"圆",OpenCV 中:H ∈ 0, 180,0 和 180 是连在一起的(像圆环),红色刚好在 圆环的起点和终点(靠近 0 的一段,靠近 180 的一段)。每个数组里 3 个数分别是 H, S, V

十.红色掩膜生成

python 复制代码
        mask1 = cv2.inRange(hsv, lower_red1, upper_red1)  # 提取低区间红色
        mask2 = cv2.inRange(hsv, lower_red2, upper_red2)  # 提取高区间红色
        mask = cv2.bitwise_or(mask1, mask2)               # 合并两个掩膜

从 HSV 图像中,把"红色的像素"全部挑出来,生成一张只有黑白的二值图(掩膜)。

白色(255):红色像素 黑色(0):非红色像素

后面找轮廓、算中心点,用的就是掩膜,而不是原图。

mask1 = cv2.inRange(hsv, lower_red1, upper_red1) ,如果 lower_red1 ≤ 像素(H,S,V) ≤ upper_red1 ,mask1 中该像素 = 255(白);否则,mask1 中该像素 = 0(黑)

mask = cv2.bitwise_or(mask1, mask2),把 mask1 和 mask2 合成一张总的红色掩膜

十一.去噪与形态学处理

python 复制代码
        kernel = np.ones((5, 5), np.uint8)  # 定义 5x5 卷积核

        mask = cv2.morphologyEx(            # 开运算:去除小噪点
            mask,
            cv2.MORPH_OPEN,
            kernel
        )

        mask = cv2.morphologyEx(            # 膨胀:增强目标区域
            mask,
            cv2.MORPH_DILATE,
            kernel
        )

让红色掩膜从"脏乱的点"变成"干净的一整块"。

kernel = np.ones((5, 5), np.uint8)

(5, 5)表示5行5列;np.uint8表示数组元素的数据类型是:无符号 8 位整数,取值范围:0 ~ 255,图像像素本身就是 uint8

1 1 1 1 1 用一个 5×5 的窗口,在图像上"滑动"做判断

1 1 1 1 1 开运算 = 先腐蚀(Erode)→ 再膨胀(Dilate)

1 1 1 1 1 腐蚀:只有当 kernel 覆盖范围内全是白色,中心像素才保留为白色,否则 → 变黑

1 1 1 1 1 膨胀:kernel 范围内只要有一个白点,中心像素就变白

1 1 1 1 1

为什么开运算后还要再膨胀?因为开运算的"腐蚀"步骤会让目标变小,再膨胀一次,把目标补回来,甚至稍微放大(更稳定)

十二、轮廓查找

python 复制代码
        contours, _ = cv2.findContours( # 查找二值图像中的轮廓
            mask,              #单通道二值图像
            cv2.RETR_TREE,     #轮廓检索模式,找所有轮廓,保留层级
            cv2.CHAIN_APPROX_SIMPLE  #轮廓点的存储方式,直线段只存起点和终点,只保留关键拐点
        )

在二值图像中,把所有"白色区域的边界"找出来,并用点的形式表示。白色区域 = 目标,轮廓 = 目标的外边缘

十三、目标筛选与中心计算

python 复制代码
        if contours:                         # 如果检测到轮廓
            largest_contour = max(           # 选取面积最大的轮廓
                contours,
                key=cv2.contourArea
            )

            if cv2.contourArea(largest_contour) > 500:  # 过滤小噪声,如果最大轮廓面积仍然很小,那说明画面里没有真正目标,只是噪声
                x, y, w, h = cv2.boundingRect(largest_contour)  # 获取外接矩形

                obj_center_x = x + w // 2    # 目标中心 X 坐标
                obj_center_y = y + h // 2    # 目标中心 Y 坐标

从一堆轮廓里,确定你真正要跟踪的那个目标

十四、可视化标记

python 复制代码
                cv2.rectangle(              # 绘制目标矩形框
                    frame,   #视频画面本身
                    (x, y),   #矩形左上角坐标
                    (x + w, y + h),
                    (0, 255, 0), #矩形颜色(BGR格式) 绿色
                    2  #线条粗细(像素),2 像素既清晰又不挡画面
                )

                cv2.circle(                 # 绘制目标中心点
                    frame,
                    (obj_center_x, obj_center_y),  #圆心坐标
                    5,   #圆的半径(像素)
                    (255, 0, 0),   #颜色:蓝色(BGR)
                    -1   #填充方式,-1 表示 实心圆
                )

                cv2.putText(                # 显示文字标签
                    frame,
                    'Red Object',  #显示的字符串,用来标识目标类别
                    (x, y - 10),  #文字起始位置(左下角)
                    cv2.FONT_HERSHEY_SIMPLEX,  #字体类型,OpenCV 自带字体,清晰、通用
                    0.6,  #字体缩放比例
                    (0, 255, 0),  #文字颜色,和矩形颜色一致,视觉统一
                    2
                )

在原始视频画面上"画出你检测到的目标",让你能直观看到:目标位置、目标大小、目标中心、当前识别结果是否正确

十五、偏差计算(像素级)

python 复制代码
                diff_x = obj_center_x - center_x  # X 方向偏差(右为正)
                diff_y = obj_center_y - center_y  # Y 方向偏差(下为正)

                print(
                    f"Red Object Center: ({obj_center_x}, {obj_center_y}) "
                    f"| Diff: (dx={diff_x}, dy={diff_y})"
                ) #在运行时会被 实时替换成变量当前的值
#Python 的 f-string(格式化字符串),Python 会先分别格式化,再拼接成一个字符串。

计算目标中心相对于画面中心的"位置误差",并打印出来用于调试和控制。例如 :

十六、舵机角度映射

python 复制代码
                servo2_angle = int(          # X 方向舵机角度计算
                    np.clip(
                        90 + diff_x // 10,   # 偏差 → 角度增量,90° = 云台"正中间"位置
                        0,
                        180   #舵机物理角度范围:0°~180°
                    )
                )

                servo1_angle = int(          # Y 方向舵机角度计算
                    np.clip(
                        90 - diff_y // 10,   # 图像坐标与舵机方向相反,OpenCV 坐标系:Y 向下为正
                        0,
                        180
                    )
                )
现象 调哪里
云台太慢 改 10 → 5,同样误差 → 舵机动得更多
云台抖 改 10 → 15,让舵机动作更小、更慢、更稳,所以抖动会减轻,小误差被"忽略"
上下反了 -,翻转控制方向
左右反了 + / -,物理系统没有"标准方向",软件必须迁就硬件
撞限位 改 clip 范围,实际舵机:可能只能转 20°~160°,可能只能转 20°~160°,程序以为是 0~180
diff //10 //15
15
10
diff //10 //5
50 10°
20

十七、发送控制 + 接收反馈

python 复制代码
        send_to_stm32(servo1_angle, servo2_angle)  # 向 STM32 发送舵机角度
        read_from_stm32()                           # 读取 STM32 回传数据

十八、图像显示与退出控制

python 复制代码
        cv2.imshow('Red Object Tracking', frame)  # 显示处理后的视频画面,第一个参数窗口名,第二个参数要显示的图像

        if cv2.waitKey(1) & 0xFF == ord('q'):      # 按 q 键退出
            break
waitKey 效果
0 一直等,视频卡死
1 实时刷新
30 限制帧率(约 30 FPS)

waitKey 返回的是一个 32 位整数, 不同平台高位可能有垃圾数据,& 0xFF只取 低 8 位, 保证兼容性,ord('q')把字符 'q' 转成 ASCII 码,'q' → 113

十九、资源释放

python 复制代码
finally:
    cap.release()            # 释放摄像头资源
    cv2.destroyAllWindows()  # 关闭所有 OpenCV 窗口
    ser.close()              # 关闭串口
相关推荐
蓝斯4978 小时前
从.到实现一个Windows上的虚拟hid键盘设备
windows·单片机·计算机外设
熙芯XiChip8 小时前
硬实时与软实时RTOS核心区别
嵌入式硬件
伟大的前程9 小时前
unsigned int 定义的变量 在 电脑的 64 位系统中 和 32 位 cortex-m0 和 8 位的 51 单片机
单片机·嵌入式硬件
小渔村的拉线工9 小时前
1.HPM6E80 解析芯片整体工作原理和存储架构
单片机·嵌入式硬件·mcu·架构·hpm6e80
熙芯XiChip10 小时前
CAN与CANopen的本质区别
单片机
lilihewo10 小时前
Altium Designer 封装库的创建(分享pcb 3D封装库)
嵌入式硬件·51单片机
神罚天下ET11 小时前
典型arm位单片机启动流程(从上电到main.c)
c语言·arm开发·单片机
碧海银沙音频科技研究院12 小时前
蓝牙耳机使用WSDF5361锂保芯片进入船运模式实现方法
嵌入式硬件·算法
Zevalin爱灰灰13 小时前
模拟信号链电路——基于TL431的电压基准电路
单片机·嵌入式硬件
csdn杰哥13 小时前
瑞萨单片机开发教程[二十七] DTC 外部中断触发传输实验
单片机·嵌入式硬件·mongodb·瑞萨·ra6m5