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 :布尔值,表示读取是否成功,如果成功读取一帧图像,ret 为 True;如果读取失败(例如摄像头断开或图像读取错误),ret 为 False。
if not ret: :如果读取失败(即 ret 为 False),则执行下方代码。
frame.shape :返回一个元组,包含图像的形状((height, width, channels)),即图像的高度、宽度和通道数。height:图像的高度(即图像的行数),width:图像的宽度(即图像的列数),channels:图像的通道数,通常为 3,表示彩色图像的 RGB 通道。frame.shape[:2] :切片操作,取 frame.shape 元组(不可变的序列,支持切片和访问)的前两个值(height 和 width),并将其分别赋给 height 和 width 变量。
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 | 1° | 1° |
| 10 | 1° | 0° |
| diff | //10 | //5 |
|---|---|---|
| 50 | 5° | 10° |
| 20 | 2° | 4° |
十七、发送控制 + 接收反馈
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() # 关闭串口