OpenMV无人机光流定点与巡线:从像素位移到状态机控制

OpenMV无人机光流定点与巡线:从像素位移到状态机控制 简介面向无人机视觉控制开发者的一套OpenMV实现方案基于Python语言完成光流定点、巡线与直角转弯等功能。资源聚焦机器视觉与飞行控制的结合适合正在学习OpenMV、无人机自主导航或参与智能车/无人机竞赛的读者参考既能用于课程设计也可作为工程预研的实验基础。压缩包共6个文件以py源码为主辅以md说明文档及备份文件整体仅16KB便于快速部署与二次修改。目前已有126人学习下载。内容涵盖寻线、光流加寻线、直线标志、十字定点等任务脚本针对不同应用场景做了功能拆分并配套说明文档可帮助开发者理解视觉巡线、光流测距与定点转弯的实现思路掌握从图像采集、特征提取到运动控制的完整逻辑快速迁移到自己的无人机平台上进行二次开发。1. 无人机光流定点为什么绕不开 OpenMV从无 GPS 环境说起做无人机室内定点大多数人第一反应是用 PX4Flow 或者 PMW3901 这类专用光流传感器。但真把飞机飞起来就会发现专用光流模块只能输出像素位移能不能用、用得多好完全取决于你上层怎么滤波、怎么和飞控的 PID 通信。如果你同时还要巡线、还要识别直角转弯标志那光流模块就帮不上忙了——它没有图像理解能力。OpenMV 的价值在于它把摄像头、图像处理、串口通信集成在一块能跑 MicroPython 的板子上既能用来做光流定点也能在同一个循环里完成巡线特征提取和转弯标志识别。这套资源里的光流加寻线.py、我的寻线.py、直线标志.py、十字定点.py就是按这个思路拆的模块光流加寻线.py是融合主程序另外三个分别对应基础寻线、直线段识别、十字定点判断。对想用视觉做无人机导航又不想上太重算力平台的开发者来说这套代码是一个可以直接改的起点。2. 光流定点从像素位移到机体速度指令的数据链路2.1 稀疏光流方案为什么选 LK 跟踪而不是全局光流OpenMV 的算力有限跑稠密光流不现实。常见做法是把图像转成灰度图在中心区域用find_keypoints提取 Harris 角点再用lk_track对相邻两帧做稀疏光流跟踪。lk_track在 OpenMV 里默认用的是 Lucas-Kanade 算法输入上一帧的关键点列表和当前帧图像输出跟踪到的角点。这比在整幅图上做块匹配快得多也更容易控制计算量。光流定点的基础逻辑是当无人机向前移动时地面特征点在图像中会向后移动左移时特征点会向右移动。通过计算所有跟踪角点在 x、y 方向上的平均位移就能反推机体在水平面上的运动方向和速度。2.2 光流主循环的代码骨架在你读光流加寻线.py时会看到主循环里同时做了三件事拍图、算光流、算巡线偏差。下面这段代码是把光流部分单独提出来后的骨架和资源里的实现思路一致import sensor, image, time, math from pyb import UART, LED sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) # 灰度图减少计算量 sensor.set_framesize(sensor.QQVGA) # 160x120光流分辨率够用 sensor.set_auto_exposure(False) # 固定曝光光流才稳定 sensor.set_auto_whitebal(False) # 锁定白平衡对灰度图影响小但保持固定 sensor.skip_frames(30) # 只取图像中心 80x60 的 ROI 做角点检测避开边缘噪声 ROI (40, 30, 80, 60) uart UART(3, 115200, timeout_char1000) old_keypoints None old_img None dx_sum 0.0 dy_sum 0.0 track_count 0 while True: img sensor.snapshot() curr_keypoints img.find_keypoints( max_keypoints30, threshold0.02, roiROI, normalizedTrue ) if old_keypoints is not None and curr_keypoints is not None: # 用 LK 光流跟踪上一帧的角点 flow_vectors img.lk_track(old_img, old_keypoints, curr_keypoints, window_size15, max_level2) frame_dx 0.0 frame_dy 0.0 valid 0 for v in flow_vectors: if v.visible(): frame_dx v.flow_x() # 角点在x方向的像素位移 frame_dy v.flow_y() # 角点在y方向的像素位移 valid 1 if valid 5: avg_dx frame_dx / valid avg_dy frame_dy / valid dx_sum avg_dx dy_sum avg_dy track_count 1 # 每 5 帧输出一次平均光流减少串口数据量 if track_count 5: out_dx dx_sum / track_count out_dy dy_sum / track_count # 注意像素位移方向与机体运动方向相反 pack_and_send(-out_dx, -out_dy) # 见 2.3 的协议 dx_sum 0.0 dy_sum 0.0 track_count 0 old_keypoints curr_keypoints old_img img.copy() # lk_track 要求传入上一帧图像对象需要 copy这里强调几个直接决定光流质量的因素。find_keypoints的threshold控制角点响应阈值值越大角点越少越稳定但太少会导致跟踪失败。在室内光照下0.02 是个比较稳的起点如果纹理密集可以调到 0.03如果地面太光滑、角点不足往下调到 0.01。max_keypoints30是上限实际角点十几二十个即可。lk_track的window_size是搜索窗口max_level是金字塔层数。对于 160x120 的 QQVGA 分辨率window15、max_level2 是兼顾速度和精度的常见配置。飞行速度较快时特征点位移大需要把max_level提到 3否则跟丢。2.3 和飞控的串口协议同样的代码跑不同的飞控OpenMV 算完光流要送给飞控。飞控端是 PX4 还是 ArduPilot决定了你走 MAVLink 还是自定义协议。但这套资源里更通用的做法是自定义帧格式由飞控端自行解析因为 OpenMV 上跑 MAVLink 得额外调库而自定义协议只需要uart.write发几个字节。一个常见的帧格式是字段长度(字节)说明帧头AA 552固定值用于找帧头数据类型10x01光流数据0x02巡线数据0x03转弯请求数据长度1后续数据字节数数据域N按小端排列的 int16 或 floatCRC81对前面所有字节做 CRC 校验给一个打包与发送的示例def crc8(data): crc 0x00 for b in data: crc ^ b for _ in range(8): if crc 0x80: crc ((crc 1) ^ 0x07) 0xFF else: crc (crc 1) 0xFF return crc def pack_and_send(dx, dy): # 像素位移量化为 int16单位是 0.01 像素 dx_q int(dx * 100) dy_q int(dy * 100) payload bytes([0xAA, 0x55, 0x01, 0x04]) payload dx_q.to_bytes(2, little, signedTrue) payload dy_q.to_bytes(2, little, signedTrue) payload bytes([crc8(payload)]) uart.write(payload)帧尾加 CRC8 不是多余的。飞控端通过串口收数据一旦帧错位校验失败就丢掉防止错误速度数据进入 PID。PX4 端如果不想自己解析也可以把 OpenMV 的数据转成OPTICAL_FLOW_RAD消息推给 EKF但对自研飞控或 Pixhawk 原生固件来说外部模式读串口控制速度环更直接。2.4 光流定点的关键约束高度与曝光很多人把光流数据接进去发现飞机乱飘第一反应是 PID 没调好其实问题多半在曝光。OpenMV 默认自动曝光在飞行时会不断调整每帧亮度变化导致特征点灰度梯度改变位移量会跳动很大。所以代码里必须set_auto_exposure(False)手动固定曝光值。室内和室外需要的曝光差异很大建议在sensor.set_auto_exposure(False)之后加一句sensor.set_auto_exposure(False, exposure_us15000)具体数值用 IDE 的帧率显示调让图像亮度稳定在 80~150 的灰度范围。另外光流输出的是像素位移不是实际位移。同一位移在高度 0.5 米时像素移动大在 2 米时像素移动小。飞控端需要把像素位移除以当前高度再乘上相机焦距相关常数才能得到米制位移。如果你用的是 OpenMV 广角镜头还得处理畸变否则边缘 ROI 的光流会有明显透视误差。这也是为什么ROI要取中心区域——中心区域畸变和透视影响最小。3. 巡线二值化、色块追踪与偏差提取3.1 从“我的寻线.py”看巡线主循环的构成我的寻线.py应该是整套资源里最早写出来的巡线版本它承担的任务是识别地面上的线输出无人机相对线的横向偏差。OpenMV 官方例程里的色块巡线通常是find_blobs找最大色块然后输出色块中心 x 坐标与图像中心 x 坐标的差。这个做法简单但在实际飞行中会遇到线太细、色块分裂、光线反射等问题。一个更稳的巡线主循环框架如下import sensor, image, math, time from pyb import UART sensor.reset() sensor.set_pixformat(sensor.RGB565) # 巡线建议用彩色便于LAB空间过滤 sensor.set_framesize(sensor.QQVGA) sensor.skip_frames(30) # LAB 色彩空间的黑色线阈值在IDE里用直方图工具标定后读取 THRESHOLD (0, 35, -20, 20, -20, 20) ROI (0, 40, 160, 80) # 只看下半部分避免远处干扰 uart UART(3, 115200) while True: img sensor.snapshot() blobs img.find_blobs( [THRESHOLD], roiROI, pixels_threshold20, # 小于20像素的噪点直接忽略 area_threshold20, mergeTrue # 相邻色块合并防止线被噪声断成两截 ) if blobs: # 选最大色块作为目标线 largest max(blobs, keylambda b: b.pixels()) img.draw_rectangle(largest.rect()) img.draw_cross(largest.cx(), largest.cy()) # 偏差 色块质心x - 图像中心x归一化到[-1, 1] error (largest.cx() - 80) / 80.0 uart.write(pack_line_data(error, largest.area())) else: uart.write(pack_line_data(0.0, -1.0)) # 丢失信号THRESHOLD的值必须在 OpenMV IDE 的“工具→机器视觉→阈值编辑器”里对着实际地面标定直接用别的项目的阈值往往不适用。注意黑线在不同光照下 L 通道变化很大所以阈值里 L 区间要放宽A、B 通道用于排除相近颜色的干扰。3.2 偏差计算只看质心 x 会在大角度时失真很多新手拿到巡线代码会直接把cx - 80当成偏差送给飞控低空和平行时没问题但无人机一旦有偏航角或者飞行高度变化色块的宽度和形状都会变。更稳的偏差计算是结合色块的面积和底边中心来计算因为线在地面上是连续的近处线段的像素宽度更宽。一个经验做法是假设无人机高度固定线宽固定时色块面积和距离成反比。取色块底部中心(b.cx(), b.y() b.h())作为参考点更合理因为底部对应无人机正下方的线位置而质心会受到远处透视的影响。偏差计算可以写成bottom_x b.cx() # 底边中心近似用质心x替代简单处理 fov_scale 80.0 / max(b.area(), 20) # 用面积归一化视角范围 # 横摆角估计如果无人机偏航线在图像中会倾斜 angle math.atan2(b.cy() - 120, b.cx() - 80) # 输出前馈角度 位置偏差 send_error(bottom_x - 80, angle)这里输出的两个量分别对应位置偏差和偏航角偏差。飞控端可以直接用位置偏差修正横向速度用角度偏差修正偏航速率。如果你的飞控只接受一个通道那就只发位置偏差让飞控的航向控制单独靠惯性测量单元处理。3.3 直线标志.py从连续线到“可转弯判定点”的过渡直线标志.py的存在意味着任务不只是巡线而是要在巡线过程中识别出当前正处于一条直线的稳定段为后续的直角转弯做准备。它的识别逻辑很简单当连续 N 帧内巡线输出的偏差在正负阈值内且色块宽度变化不大则认为无人机正在一条直线上飞行。straight_frames 0 STRAIGHT_THRESHOLD 0.15 # 偏差小于0.15即视为直线飞行 STRAIGHT_FRAME_CNT 30 # 持续30帧约0.5秒 if abs(error) STRAIGHT_THRESHOLD: straight_frames 1 else: straight_frames 0 if straight_frames STRAIGHT_FRAME_CNT: uart.write(bytes([0xAA, 0x55, 0x02, 0x01, 0x01, crc])) straight_frames 0这个模块的价值在于给飞控一个状态信号现在可以安心沿直线前飞不需要频繁修正。很多任务里的“光流定点巡线”融合无效是因为飞控不知道当前视觉状态是否稳定有了直线标志信号飞控可以做模式切换。3.4 光照与眩光飞行中最大的干扰源巡线最怕的是反光和光线突变。室内灯光直射地面形成亮斑黑色胶带反光后变成灰色二值化会把它和背景混在一起。除了锁曝光和白平衡还可以在每帧开头做一次自动统计取 ROI 左上角一小块的亮度均值如果整个画面过亮或过暗就调整曝光值。这种方法虽然粗暴但比完全自动曝光对单帧的稳定性伤害小。4. 直角转弯直线标志与十字定点的状态机融合4.1 为什么直角转弯是状态问题而不是单帧识别问题直角转弯最难的不是“看到十字标志”而是无人机以一定速度飞近时在什么时机开始转、什么时候完成转、转完之后如何重新定位到线上。如果只靠一帧图像判断“这是十字”然后立刻发转弯指令飞机一定会冲过头。合理的做法是把它拆成状态机状态A巡线找到直线段 状态B继续直线飞行直到检测到十字定点标志 状态C减速并执行顺时针/逆时针90度转弯 状态D恢复到巡线模式确认线在正前方十字定点.py在其中的角色是状态 B 的判据直线标志.py是状态 A 的判据。二者配合使用而不是单独工作。4.2 状态机的实现骨架STATE_SEARCH_LINE 0 STATE_FOLLOW_STRAIGHT 1 STATE_APPROACH_CROSS 2 STATE_TURNING 3 STATE_REFIND_LINE 4 state STATE_SEARCH_LINE turn_started_at 0 while True: img sensor.snapshot() if state STATE_SEARCH_LINE: # 大范围找线找到后切到沿直线飞 visible find_line(img, roi(0, 20, 160, 100)) if visible: send_status(LINE_FOUND) state STATE_FOLLOW_STRAIGHT elif state STATE_FOLLOW_STRAIGHT: # 巡线 光流定点保持在中线 error follow_line(img) if abs(error) 0.15: straight_ok True # 一旦识别到十字定点标志进入接近状态 if detect_cross(img): state STATE_APPROACH_CROSS send_status(CROSS_DETECTED) elif state STATE_APPROACH_CROSS: # 十字定点的核心控制无人机飞到十字中心 dx, dy optical_flow_center(img) dist_to_cross get_cross_distance(dx, dy) if dist_to_cross TURN_DISTANCE: send_turn_command(90) # 发直角转弯指令 state STATE_TURNING turn_started_at time.ticks_ms() elif state STATE_TURNING: # 转弯过程中不依赖视觉飞控自己完成 if time.ticks_diff(time.ticks_ms(), turn_started_at) TURN_TIMEOUT: state STATE_REFIND_LINE elif state STATE_REFIND_LINE: # 转弯完成后重新找线 if find_line(img, roi(0, 0, 160, 120)): state STATE_FOLLOW_STRAIGHT状态机里需要额外注意的一个点是TURN_TIMEOUT。如果转弯指令发出后飞控没有收到视觉反馈必须在超时后切换回巡线状态否则飞机会继续旋转直到撞墙。超时时间通常根据飞控的偏航角速度设置比如偏航速率 90 度/秒90 度转弯就是 1 秒加上稳定余量取 1.5 秒比较合理。4.3 十字定点.py 的判定逻辑两条直线段找交点要识别十字标志常见做法是检测图像中相互垂直的两条亮或暗条纹。OpenMV 里有find_lines函数用霍夫变换检测直线但直接在新灰度图上跑find_lines会很慢而且地面纹理稍复杂就误检。资源里十字定点.py更可能用的是区域扫描在 ROI 内找两个方向的矩形色块当横向色块和纵向色块同时存在时认为检测到十字。CROSS_ROI (40, 20, 80, 60) def detect_cross(img): horizontal img.find_blobs([THRESHOLD], roi(40, 40, 80, 20), pixels_threshold30) vertical img.find_blobs([THRESHOLD], roi(75, 20, 10, 60), pixels_threshold30) if horizontal and vertical: h max(horizontal, keylambda b: b.pixels()) v max(vertical, keylambda b: b.pixels()) cross_x (h.cx() v.cx()) // 2 cross_y (h.cy() v.cy()) // 2 return (cross_x, cross_y, True) return (0, 0, False)水平方向 ROI 取图像的中间偏上垂直方向 ROI 取中间偏左。这样在无人机遇到十字标志时两个 ROI 内会同时出现线段。务必只在确实需要转弯的地面放置高对比十字标线避免把普通线条交叉误识别成转弯点。4.4 光流定点与转弯的融合边界光流加寻线.py的核心在于巡线给出水平横向的参考光流给出前进方向的速度反馈二者在转弯的瞬间做一次主备切换。直线飞行时以巡线误差为主光流负责抑制前进方向的震荡转弯开始时巡线特征丢失光流成为唯一的位置参考。飞控端必须有对应的模式切换逻辑否则视觉端发送的巡线偏差失效时飞控的速度环会因为没有参考而发散。4.5 转弯距离的估算参数转弯距离取决于飞行速度和响应延迟。假设巡航速度是 0.5 m/s飞控响应延迟 100ms图像处理延迟 50ms则无人机从“看到十字”到“开始转弯”会向前冲出约 0.075 米。设定转弯触发距离时要大于这个量常见做法是 0.15~0.3 米之间。参数推荐值说明巡航速度0.3~0.5 m/s视觉延迟较大时取低值转弯触发距离0.15~0.3 m根据速度与延迟调整转弯超时1.5~2.0 s确保转弯失败的兜底重新找线 ROI全图转弯后线可能在任意位置5. 调参与验证把飞行风险降到最低的实操技巧5.1 用 OpenMV IDE 的直方图工具代替盲调阈值THRESHOLD、曝光时间、ROI 这三个参数的调试强烈建议全程连接 OpenMV IDE。在阈值编辑器里打开一帧实际场景图像用鼠标拖选地面上的线直方图会实时显示 LAB 各通道的分布。把阈值下限设为目标色块直方图的最低值上限设最高值保存后直接粘贴回代码。每次换场地、换灯光都要重新标定飞行前先用无人机手持过一遍测试线路比直接起飞排错效率高很多。5.2 在地面先验证串口协议再上真机把 OpenMV 接 USB 转串口模块连接电脑串口调试助手分别测试光流输出和巡线输出。拿一个纸箱模拟地面移动观察串口输出的光流 dx/dy 是否符合预期方向。把纸箱放在巡线摄像头下方左右移动纸箱观察巡线偏差是否灵敏。这里不建议跳过因为飞控端解析错误或帧格式不匹配在真机上很难排除。另外可以写一个简单的串口回环脚本验证 CRC 逻辑# 上位机回环验证模拟解析 OpenMV 的帧 import serial, struct ser serial.Serial(/dev/ttyUSB0, 115200, timeout1) buf b while True: data ser.read(32) if not data: continue buf data while len(buf) 7: if buf[0] 0xAA and buf[1] 0x55: length buf[3] if len(buf) 5 length: frame buf[:5 length] payload frame[:-1] crc frame[-1] if crc8(payload) crc: print(收到光流数据: , struct.unpack(hh, frame[4:8])) buf buf[5 length:] else: break else: buf buf[1:]5.3 参数清单速查参数位置参数名常见排错方向sensorexposure_us数值太小画面偏暗光流跟不住点find_keypointsthreshold太大点太少太小点太多且不稳定lk_trackwindow_size速度太快跟丢时增大find_blobspixels_threshold太大会忽略细线太小噪声多UARTbaudrate与飞控端不一致会导致乱码ROIy起始值摄像头安装角度不同时需重新切分5.4 从README.md.zbak看版本维护习惯资源里出现.zbak开头和.py结尾的文件说明这套代码里开发者的版本管理习惯是“只留一份构建旧文件改后缀作为备份”。这类备份文件在阅读时不要直接打开对比而要和当前版本的 diff 结合看重点看哪些参数被改过、哪段逻辑被整体替换。如果你要自己改这套代码建议一开始就把它纳入 git 管理别再沿用.zbak的方式否则飞了几个版本后经常分不清哪个才是最接近起飞成功的版本。对无人机视觉项目来说能复现上一次能飞的代码比新加一个功能更重要真正值得记录的是每次调参前后的环境和飞行表现。本文还有配套的精品资源点击获取