ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Python-OpenCV的Tello无人机视觉识别:二维码与数字识别实战

基于Python-OpenCV的Tello无人机视觉识别:二维码与数字识别实战 简介基于Python与OpenCV的Tello无人机项目将二维码扫描与数字识别结合到无人机视觉任务中面向计算机视觉方向的课程设计、毕业设计以及希望快速上手无人机开发的学习者。压缩包内共10个文件包含5个Python脚本、3张样例图片、1份README说明文档和1个license许可文件整包仅25KB结构精简便于阅读与二次开发。5个脚本分别承担主程序入口、无人机控制界面、二维码检测识别、数字识别等任务配合三张样例图片可直观验证识别效果README文档则对环境配置和运行逻辑进行说明降低了上手门槛。目前已有279人学习下载具有一定的参考热度。借助该资源读者既能理解OpenCV下图像处理、轮廓检测、模板匹配等基础用法也能掌握Tello无人机摄像头的调用与控制思路为后续扩展自主巡航、目标跟随等功能提供良好起点。1. 用 python-opencv 和 Tello SDK 搭一套无人机视觉识别基于 python-opencv 的 Tello 无人机二维码扫描和数字识别压缩包听起来是个课程设计实际上手才会发现识别算法只占三成功夫剩下七成在四件事上视频流能不能稳定拿到二维码在什么距离内还能解码数字区域怎么从画面里干净地切出来最后再把识别结果安全地接回飞控。这篇会按一条能跑通的主线展开先打通 Tello 的 UDP 视频通道并做帧预处理再实现二维码扫描和数字识别两条链路最后用一个三态状态机把结果接回 SDK 命令末尾补两个实机调试直接用得上的技巧。适合正在做无人机视觉定位、巡检标签识别或室内编队任务的工程师也适合刚接触 Tello 开发者模式的入门者。2. Tello 视频流与帧预处理从 UDP 裸流到干净的 BGR 帧2.1 命令通道和视频通道的区别要先搞清楚连上 Tello 开出的 Wi-Fi 热点后飞控会暴露一组固定端口命令口 UDP 8889视频口 UDP 11111。两者承载完全不同。命令通道发的是文本指令比如command、takeoff、streamon飞控回给一行文本视频通道则是一路连续的 H.264 裸流没有 MP4 容器也没有moov之类的索引。刚接触 Tello UDP 开发环境的人最容易犯的错是直接用cv2.VideoCapture(udp://0.0.0.0:11111)去抢视频流发现花屏或者只有首帧就误以为代码问题。实际上这条流能不能被 OpenCV 吃进去取决于本地 OpenCV 发行版是否带完整 ffmpeg 支持所以更稳妥的做法是把两个通道分开管理。import socket CMD_ADDR (192.168.10.1, 8889) sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((, 8890)) # 本地端口不要求与远端一致但要确保 UDP 能被接收 def send_cmd(cmd, wait2.0): sock.sendto(cmd.encode(), CMD_ADDR) sock.settimeout(wait) try: return sock.recv(1024).decode().strip() except socket.timeout: return timeout assert send_cmd(command) ok assert send_cmd(streamon).startswith(ok)command是进入 SDK 模式的握手指令必须最先发送只有返回ok后其它飞行指令才被接受。streamon打开视频推流返回ok后 UDP 11111 上才开始产生数据。bind的本地端口我习惯写成 8890避免和视频接收端口的 11111 混淆。真机上这两条指令偶尔超时重发一次即可不必因此反复重启 socket。2.2 OpenCV 取流的三种途径与典型故障取流方案我按优先级排PyAV 解码 ffmpeg 子进程管道 cv2.VideoCapture直接拉 UDP。PyAV 是 ffmpeg 的 Python 绑定对 H.264 裸流支持稳定还能拿到 PTS 时间戳做丢帧判断cv2.VideoCapture(udp://...)在部分发行版上也能跑通但它在拿到 SPS/PPS 之前就可能开始取帧开流后常有 1 到 2 秒黑屏硬解参数也不透明。第三种办法是外部拉起 ffmpeg 进程从 stdout 管道读原始 BGR 数据适合不想引入av依赖的离线环境。import av import cv2 container av.open(udp://0.0.0.0:11111, formath264, timeout5.0) for frame in container.decode(video0): img frame.to_ndarray(formatbgr24) img cv2.resize(img, (640, 480), interpolationcv2.INTER_NEAREST) cv2.imshow(tello, img) if cv2.waitKey(1) 0xFF ord(q): breakformath264告诉 PyAV 这不是带封装格式的流而是一段裸 H.264 码流不指定时 PyAV 会按扩展名猜协议往往猜错。to_ndarray(formatbgr24)把解码后的 YUV 数据转成 OpenCV 需要的 BGR 布局。缩放用INTER_NEAREST而不是INTER_LINEAR是因为后续二维码识别对插值质量不敏感这种最省开销真正需要放大细节的是后面数字识别的 ROI 局部不在整帧上做。提示如果环境中 PyAV 不可用且无法安装退路是用cv2.VideoCapture(udp://0.0.0.0:11111)但遇到黑屏不要反复cap.read()先停 0.5 秒重新 open通常能等到 SPS/PPS。2.3 帧预处理的四个步骤降采样、灰度、对比度、去模糊拿到 BGR 帧后不要直接丢给检测器。飞行场景里最大的变量是运动模糊和自动曝光抖动。我会先做一次等比降采样把长边限制在 640 左右这既减少后续计算量也让二维码的三次定位在更小的图上更稳定然后转灰度对比度增强用 CLAHE 而不是全局直方图均衡因为全局均衡会把暗部噪声同时放大二维码边缘反而被污染。def preprocess(frame, target_w640): h, w frame.shape[:2] if w target_w: frame cv2.resize(frame, (target_w, int(h * target_w / w))) gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) gray clahe.apply(gray) return grayclipLimit2.0是局部对比度放大的上限数值过大会把光照渐变切成一块块噪声tileGridSize默认是 8×8对二维码这种边缘密集的图像是合适的起点。这步产出的是灰度图给二维码检测和 OCR 用如果后面接到cv2.QRCodeDetector上也可以保留原 BGR 帧做可视化两者不冲突。3. 二维码扫描OpenCV 检测器选型、解码参数与距离换算3.1 QRCodeDetector 与 pyzbar 的适用边界二维码扫描听起来是 OpenCV 的成熟功能实际选型要按飞行距离和倾斜角分开看。cv2.QRCodeDetector走的是传统图像处理路线先找三个“回”字定位角标做透视校正再解码。它在码离得近、画面正的时候单帧耗时毫秒级但要求三个角标同时可见斜视角度超过约 45° 或者距离拉远都会直接返回空串。pyzbar 包装了 zbar 库解码鲁棒性略好对模糊和局部遮挡容忍度略微高一些代价是要依赖系统动态库、首次导入慢而且在多线程解码时需要自行加锁。检测器主要优点主要短板推荐使用场景cv2.QRCodeDetector零依赖、速度快、一次拿数据和角点小尺寸和斜视角下失败率高悬停稳定的定点识别pyzbar解码鲁棒性较好、遮挡容忍度略高依赖 zbar 动态库、线程不安全离线图片批处理wechat_qrcode综合识别率最高需 OpenCV contrib、模型文件较大对识别率要求高于实时性我的建议直接点初期只依赖cv2.QRCodeDetector把流程跑通后再看失败样本。换检测器只会把误检率从 30% 降到 20%没有本质改善真正要解决的是距离和曝光。飞行悬停后摄像头基本正对标签单帧解码失败主要是距离太远而不是算法不够强。3.2 用 detectAndDecode 写一个带超时的扫描循环detectAndDecode一步完成常规检测和解码返回字符串data和四角坐标points。容易踩的坑有两个空的data是空字符串而不是None判断要用if datapoints在失败时是空数组直接调用reshape会抛异常必须先确认data非空。import cv2 import numpy as np detector cv2.QRCodeDetector() MAX_FRAMES 150 for i in range(MAX_FRAMES): ok, frame cap.read() if not ok: continue data, points, _ detector.detectAndDecode(frame) if data: pts points.reshape(-1, 2).astype(np.int32) cv2.polylines(frame, [pts], True, (0, 255, 0), 3) print(content:, data) break if i MAX_FRAMES - 1: print(no qrcode found in, MAX_FRAMES, frames)detectAndDecode的第三个返回值是校正后的二维码小图调试时可以用来判断“检测到了但解码失败”的情况通常表现为定位角标找到但data为空。MAX_FRAMES150按 30 帧每秒算约 5 秒超过这个时间就应认为当前搜索方向无目标。需要说明的是这个 API 对输入分辨率很敏感把输入帧宽度缩到 640 比调任何内部参数都有效。3.3 二维码距离估算一个公式替代反复试飞“飞多近才能识别”是实机调参最耗时间的部分。版本 1 的二维码是 21×21 模块每个模块在成像面至少要占 2.5 到 3 像素所以码的像素边长下限大概在 60 像素。知道真实码边长后可以按针孔相机模型推距离。import math HFOV_DEG 82.6 QR_LEN_M 0.10 # 码的实际边长单位米 def distance_from_pixel(px_length, frame_w640.0): f_px (frame_w / 2.0) / math.tan(math.radians(HFOV_DEG / 2.0)) return f_px * QR_LEN_M / px_lengthf_px是根据水平视场角和帧宽换算出的等效焦距单位是像素。Tello 相机水平视场角约 82.6°不同批次可能有小幅差异但用来做速度分级足够。落成实际数据就是640 宽画面里二维码占 120 像素时距离约 0.3 米占 60 像素时约 0.6 米。飞控程序里用这个结果做粗粒度的速度分级比固定速度前进安全得多。4. 数字识别打印体 OCR 与手写数字识别MNIST 轻量模型4.1 先判断数字的成像来源再决定技术路线无人机拍到的数字主要有两种形态选型前必须分清。一种是指纹牌、楼层号这类印刷体笔画边缘锐利、字体统一走 OCR 又稳又快。另一种是货架标签上手写的数字粗细不均、倾斜不定EasyOCR 这类通用 OCR 模型很容易把 4 识别成 7更适合用手写数字识别里常用的 MNIST 轻量分类模型。这个判断做错后面所有调参都是在错的轨道上优化。方案适用场景单字符延迟参考部署时注意Tesseract--psm 7印刷体单行数字低需先裁剪并放大 ROI语言包要精简EasyOCR印刷体、背景复杂中首次加载模型有数秒开销MNIST 轻量 CNN手写数字分类低需训练数据贴合实拍分布Tesseract 的--psm 7表示把图片视为单行文本数字区域已经是裁剪好的小块时这个参数最合适。EasyOCR 的优势是简单劣势在意模型初始化时间和整体体积MNIST 模型则要求输入是 28×28 灰度图需要把数字区域先做尺寸归一。4.2 EasyOCR 裁剪数字区域的实现直接给结论Tello 拍到的数字 ROI 往往不到 40 像素宽不放大直接送 EasyOCR误识别率会高到不可用。我会先把 ROI 放大 2 倍再用allowlist把字符集限制到纯数字避免把 0 识别成字母 O。import easyocr reader easyocr.Reader([en], gpuFalse) def ocr_digit(roi_bgr): roi cv2.cvtColor(roi_bgr, cv2.COLOR_BGR2GRAY) roi cv2.resize(roi, None, fx2.0, fy2.0, interpolationcv2.INTER_CUBIC) result reader.readtext(roi, allowlist0123456789, detail1) if not result: return None, 0.0 return result[0][1], float(result[0][2])allowlist是 EasyOCR 的白名单参数只保留数字字符这能明显降低字母和数字间的混淆。放大插值用INTER_CUBIC而不是线性的INTER_LINEAR因为 OCR 对边缘锯齿更敏感。如果数字区域是从二维码角点推导出来的必须先做透视校正再切 ROI直接截取的图会带透视形变丢尾笔画是常态。4.3 手写数字识别MNIST 轻量模型训练与 ONNX 部署python-opencv 体系下手写数字识别有两条常见路线一是用 HOG 特征配合cv2.ml里的 SVM 或 KNearest二是训练一个轻量 CNN导出 ONNX 后用cv2.dnn.readNetFromONNX推理。前者代码短但泛化能力有限后者模型文件只有几十 KB在树莓派或 Jetson 上都能轻松运行。下面是一个极简但有效的网络结构训练数据用torchvision.datasets.MNIST获取训练完成后保存权重再导出。class MnistNet(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 16, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(32 * 7 * 7, 64), nn.ReLU(), nn.Linear(64, 10), )两个卷积层把 28×28 灰度图逐级压到 7×7×32 的特征图再用全连接输出 10 类。为什么不用更深的结构Tello 端通常跑在低功耗板子上而且输入数字区域本身只有 28×28小网络在精度上基本追平大网络但推理时间和功耗相差数倍。训练完成后导出 ONNXimport torch model MnistNet() model.load_state_dict(torch.load(mnist.pt, map_locationcpu)) model.eval() torch.onnx.export(model, torch.randn(1, 1, 28, 28), mnist.onnx, input_names[input], output_names[output])torch.randn(1, 1, 28, 28)是给 ONNX 做静态图推导用的虚拟输入尺寸写错会直接导出失败。OpenCV 端推理代码如下net cv2.dnn.readNetFromONNX(mnist.onnx) def classify_digit(roi_gray): roi cv2.resize(roi_gray, (28, 28), interpolationcv2.INTER_AREA) roi cv2.bitwise_not(roi) # 白底黑字转成黑底白字贴合 MNIST 数据集 blob cv2.dnn.blobFromImage(roi, 1.0 / 255.0, (28, 28), 0.0, False) net.setInput(blob) scores net.forward().reshape(-1) label int(scores.argmax()) return label, float(scores[label])bitwise_not是整个推理里最容易被忽略的细节。MNIST 训练样本是黑底白字而真实标签是白底黑字不做反转模型会把背景当成笔画。blobFromImage的scalefactor为 1/255 做归一化mean保持 0如果训练时对输入减过均值这里必须保持一致否则模型不会报错只是精度悄悄下降。5. 识别结果接回 Tello 飞控三态状态机与实机调参技巧5.1 用一个三态状态机避免“看到就扑上去”常见错误做法是检测到二维码就立刻朝它飞码一旦丢失又原地悬停。我习惯用一个三态循环“搜索”状态边旋转边扫描检测到就把状态切到“靠近”“靠近”状态根据码的像素尺寸前飞靠近到能稳定解码再切到“确认”“确认”状态连续多帧拿到相同结果才输出避免单帧误检。state SEARCH stable_cnt, last_data 0, None while True: frame get_bgr_frame() data, pts, size scan_qr(frame) # size 为码的像素边长 if state SEARCH: if data: state APPROACH else: send_cmd(rc 0 0 0 20) # 原地自旋速度 20 是较低档位 elif state APPROACH: if size and size 150: send_cmd(rc 0 0 0 0) send_cmd(rc 20 0 0 0) # 前飞速度 20实测中按手感调整 elif size and size 150: state CONFIRM elif state CONFIRM: if data and data last_data: stable_cnt 1 else: stable_cnt, last_data 0, data if stable_cnt 3: print(final:, last_data) send_cmd(rc 0 0 0 0) breakrc指令的四个参数分别是左右、前后、上下和旋转速度范围 -100 到 100但它不是严格的厘米每秒更像是归一化的速度参考值具体手感要实机测。APPROACH状态里我用了最简单的高低速两档真实项目应对size和目标尺寸的差做一次比例控制限制输出在 -30 到 30 之间飞行会更顺。5.2 实机调试最实用的一招录制成视频再做离线回放状态机调参最花时间的不是逻辑而是每次调试都要起飞、悬停、换电池。我自己的做法是先用cv2.VideoWriter把一次巡检过程录成 MP4回到桌面用同一套识别代码回放视频逐帧打印检测结果和置信度。回放和实机共用同一个闭环函数只是把数据源从 PyAV 容器换成cv2.VideoCapture(record.mp4)。这样可以把搜索速度、靠近阈值和确认帧数全部在地面调完再上真机时只需要微调起飞高度。调参时顺手把每帧的识别日志写进 JSON 文件和视频时间戳对齐飞行结束后逐帧对照比在空中反复试错高效得多。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进