☰
树莓派+OpenCV+Flask:基于互联网的摄像测量系统实现与工程实践
2026/10/28 23:05:15 网站建设 项目流程

简介:2021年全国大学生电子设计竞赛D题“基于互联网的摄像测量系统”源码与方案资料包,面向正在备战电赛的本科生,也适合对嵌入式视觉测量、网络通信感兴趣的开发者,用于方案参考与C++项目实战。包内共32个文件,以C/C++源码为主,包括14个头文件和14个实现文件,另有少量C源文件、README说明文档、文本说明与构建配置,整体压缩包仅32KB,结构清晰、便于翻阅。当前已有166人浏览学习。程序为真实赛题场景下的实战案例,经过测试可直接运行;从工程组织看,内容覆盖图像识别、测量会话控制、底层硬件与串口管理、基于TCP的通信交互、界面显示等模块,比较完整地呈现了一个多线程、多模块协作的摄像测量系统是如何搭建起来的。读者既能借此梳理电赛D题的解题思路与实现分工,也能学习如何在C++工程中整合算法、通信与界面,具有很好的借鉴意义。

1. 2021年电赛D题:基于互联网的摄像测量系统到底难在哪

2021年电赛D题“基于互联网的摄像测量系统”,听起来像个网络题,真正把工程拆完才发现,让队伍翻车最多的不是网络,而是图像标定和舵机回差。题目要你搭一套能远程操作的测量闭环:远端浏览器实时看画面,控制云台转动瞄准靶标,再把靶标的实际宽度、中心偏移算出来传回。它把大学生电子设计竞赛里“视频采集+云台控制+网络传输+图像测量”这四件事串成了一条线,也适合嵌入式工程师拿来做局域网远程测量的demo底子。这套资源整理了当时复现这个系统的完整思路:树莓派采集图像,OpenCV做测量,Flask推MJPEG流兼控制接口,云台用PCA9685驱动。看完你能照着搭出一版比赛现场能用的原型。

2. 方案选型与链路设计:树莓派+OpenCV+Flask怎么搭不被延迟拖死

2.1 为什么选树莓派+OpenCV而不是单片机裸算

先拆需求。这个系统有三件事:视频画面实时传到远端、远端发指令控制云台、云台端的图像算法测出靶标几何量。网络只是载体,真正的难点在画面、控制、测量三路数据并行时还能保持稳定。很多队伍第一步就在协议选型上翻车:视频用RTSP,控制走TCP自建端口,测量结果又单独开一路MQTT,三套链路互相抢资源,最后哪一路都不稳。常见做法是把三路逻辑分开,通道统一收敛到HTTP,这正是后面要讲的树莓派加Flask路线。

为什么不用单片机直接算?这题对测量误差的要求决定了图像端至少要能跑OTSU二值化和轮廓拟合,单片机裸算要从头写连通域遍历和最小外接矩形,四天赛程大概率不够用。树莓派跑OpenCV是成熟路线,轮廓检测、矩形拟合、像素当量都有现成API,Linux自带网络栈,所以选型核心不是“谁性能强”,而是“谁让你在截止前把闭环跑通”。如果只熟悉STM32,备选方案是OpenMV或OV2640加DMA采集,但要接受算法复杂度陡增,后面透视修正也更难写。

硬件按这条线配:树莓派3B+或4B,CSI接口用Pi Camera V2,云台用二自由度舵机支架,舵机选SG90或MG996R,驱动板用PCA9685。树莓派4B建议加散热片和小风扇,OpenCV处理720p时发热明显,赛场上死机一次就够受的。

供电是一个很容易忽略的坑:SG90转动瞬间电流接近500mA,直接从树莓派5V引脚抽,轻则画面卡顿,重则整板重启。常见做法是给PCA9685单独一路5V 2A电源,树莓派和舵机电源共地,I2C信号才不会被地电位差拉乱。现场因为这个重启烧掉一下午的队伍不少,别在这上面赌运气。

2.2 通信链路:视频流、控制流、测量结果流为什么要分开

三路数据性质完全不同。视频流要求实时性,每秒15帧以上;控制流由人点击触发,量小但优先级高;测量结果流频率低,只需要把最终值交给前端显示。如果把三路混在一个接口,调试时定位不了瓶颈。我一般拆成三个URL入口:/video、/control、/measure,全部走HTTP。

通道路径数据格式典型频率优先级
视频流/videomultipart/x-mixed-replace,即MJPEG15-20 fps高
控制指令/controlPOST表单,返回JSON按键触发,通常<10 Hz高
测量结果/measureJSON1-5 Hz轮询中

为什么不选RTSP或WebRTC?RTSP在浏览器里原生支持差,评委端打开要装VLC或插件,多一个步骤就多一个故障点。WebRTC延迟确实低,但信令服务器和ICE联通要花整整一晚上去调,比赛时间不允许。MJPEG带宽开销偏大,可一旦把分辨率压在640x480,局域网内完全够用,体验是“浏览器打开URL就能看”。这是性价比最高的选择。

2.3 环境准备与I2C启用

树莓派环境安装是第一步,很多人卡在OpenCV版本冲突上。推荐先装系统自带的python3-opencv,再补充Flask和舵机驱动库。具体命令如下:

sudo apt update sudo apt install -y python3-pip python3-opencv pip3 install --upgrade flask numpy adafruit-circuitpython-servokit sudo raspi-config # Interface Options -> I2C -> Enable,保存后 sudo reboot

说明:python3-opencv走Debian源,避免pip安装OpenCV时可能出现的库名冲突;adafruit-circuitpython-servokit封装了PCA9685,比直接写smbus寄存器省事,但依赖numpy版本不能太旧。raspi-config里必须显式开启I2C,否则ServoKit初始化会抛OSError。

注意:pip安装时如果提示权限错误,加上--user或者换成虚拟环境,不要直接sudo pip装到系统Python里,容易把apt的OpenCV覆盖。

装好后用i2cdetect -y 1查总线,看到0x40表示PCA9685被识别。如果什么都没扫到,先查SDA/SCL是否分别接到GPIO2/GPIO3,再查供电是否足够,最后才怀疑芯片。这一步不做,后面控制接口写再对也没用。

3. 靶标测量核心:OpenCV轮廓检测与像素当量换算的完整实现

3.1 靶标识别:OTSU二值化与矩形筛选

赛场上最常见的靶标设计是一块白色硬板贴上黑色矩形,宽度取100mm。黑色和白色对比度高,检测算法可以做得简单,不需要深度学习。测量第一步是从帧里找到这个矩形,输出像素宽度和中心坐标。下面这个measure函数是整个测量的地基:

import cv2 import numpy as np def measure(frame): gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) blur = cv2.GaussianBlur(gray, (5, 5), 0) _, th = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) cnts, _ = cv2.findContours(th, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) best = None box = None for c in cnts: area = cv2.contourArea(c) if area < 800: # 滤掉小噪点 continue x, y, w, h = cv2.boundingRect(c) if w < 40 or h < 40: # 滤掉过小目标 continue if abs(w / h - 1.0) > 0.6: # 靶标近似正方形 continue if best is None or area > cv2.contourArea(best): best = c box = (x, y, w, h) if best is None or box is None: return None x, y, w, h = box return {"width_px": w, "height_px": h, "center_x": x + w // 2, "center_y": y + h // 2}

这段逻辑分四层:先灰度化再高斯模糊,压掉JPEG压缩产生的高频噪声;OTSU自动找阈值,把黑目标提成白色前景,适应现场灯光变化;findContours用RETR_EXTERNAL只取外轮廓,避免矩形内部的阴影产生干扰;面积和宽高比约束把背景里的深色块、阴影挡在外面。

面积阈值800和宽高比0.4~1.6这两个参数要按实际距离调:靶标离得远,图像里占的像素少,面积阈值就要下调。宽高比判据假定靶标是近正方形,如果你改用长条靶标,这个约束要改成对应的矩形比例,不能照搬。

3.2 像素当量:从像素宽度到毫米宽度

测出宽度w像素后,换算关系要用“已知宽度”来标定。把靶标放在测量位置,或者在同距离放一个已知宽度的标准条,读像素宽,算出每毫米对应多少像素。这一步必须在每次开机后重做,因为摄像头固定角度、物距稍有变化,当量就会变。

px_per_mm = None known_width_mm = 100.0 def calibrate(frame, known_width_mm=known_width_mm): global px_per_mm result = measure(frame) if result: px_per_mm = result["width_px"] / known_width_mm return px_per_mm def width_to_mm(width_px): return width_px / px_per_mm if px_per_mm else 0.0

示例代码是单帧标定,实际我会把stable_width里的中位逻辑直接复用到这里,连续取10帧宽度像素中位数再除以100mm。为什么取中位而不是均值?因为偶尔一帧受反光影响测出异常值,均值会被拉偏,中位数更稳。

这里要强调一个前提:测量必须在靶标贴近画面中心时触发。云台把靶标转到边缘时,镜头畸变和透视都会放大误差,所以流程里约定“先控制云台对准,再触发测量”。这也是“摄像测量”这四个字真正想表达的操作顺序。

3.3 多帧中值滤波:让读数稳定下来

单帧测量值总有随机抖动,风扇吹动靶标、云台微振、边缘噪点都会让宽度跳一到两个像素。后端直接显示,就看到末位数字不停变。常见做法是取连续5帧宽度,排序后取中位数输出,而不是用平均值。

from collections import deque import statistics last_widths = deque(maxlen=5) def stable_width(frame): r = measure(frame) if r: last_widths.append(r["width_px"]) if len(last_widths) < 3: return None return int(statistics.median(last_widths))

deque(maxlen=5)自动丢弃最旧的数据,不需要手动管理队列。至少攒够3帧才输出,避免开机前两帧因为摄像头自动曝光还没收敛导致的结果异常。取中位数而不是均值,是因为中位数对个别坏帧免疫,一帧被反光拉坏不会让结果明显漂移。追求响应速度时可以把队列缩到3,追求稳定就保持5。

3.4 把测量框画到画面里:调试和演示都靠它

算法认没认对目标,光看数值是不够的,要把矩形框和中心点直接画到视频流里。OpenCV的绘制函数开销很低,不影响帧率:

def draw_result(frame, r): out = frame.copy() x = r["center_x"] - r["width_px"] // 2 y = r["center_y"] - r["height_px"] // 2 cv2.rectangle(out, (x, y), (x + r["width_px"], y + r["height_px"]), (0, 0, 255), 2) cv2.circle(out, (r["center_x"], r["center_y"]), 4, (0, 255, 0), -1) return out

注意center坐标来自measure返回的center_x和center_y,和boundingRect的x、y是不同参考系。画框时一定要重新换算回左上角坐标,否则框会错位。把draw_result接在generate里每帧画完再编码JPEG,远程画面里就能实时看到算法状态。现场评委看到带框的视频流,比听你口述测量原理更直白。

4. 互联网控制链路:MJPEG视频流与云台指令通道的联调顺序

4.1 MJPEG视频流:浏览器打开即看的低成本推流

视频推流用一个Flask路由就能撑起来。摄像头帧连续编码成JPEG,拼成multipart格式往HTTP响应里写,浏览器用img标签直接渲染,没有额外播放依赖:

from flask import Flask, Response, request, jsonify import cv2, time app = Flask(__name__) cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) cap.set(cv2.CAP_PROP_FPS, 15) def generate(): while True: ret, frame = cap.read() if not ret: break ret, jpg = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 70]) if not ret: continue chunk = (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + jpg.tobytes() + b"\r\n") yield chunk time.sleep(0.05) # 限流到约20fps @app.route("/video") def video(): return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")

640x480@15fps是树莓派4B在CPU占用与流畅度之间的平衡点。分辨率再提到1280x720,JPEG编码时间明显变长,反而丢帧。JPEG质量70兼顾清晰度和带宽,局域网卡顿明显时降到50。time.sleep(0.05)限流很关键,不限的话OpenCV会按摄像头原始帧率跑满,树莓派单核被编码线程打满,控制接口跟着卡。

4.2 云台控制:PCA9685舵机角度接口

云台分两个自由度:pan左右,tilt上下。用ServoKit库封装PCA9685,代码量很小。PCA9685本身是I2C设备,servokit把角度转成脉冲宽度的细节都封好了:

from adafruit_servokit import ServoKit kit = ServoKit(channels=16) kit.servo[0].actuation_range = 180 kit.servo[1].actuation_range = 180 kit.servo[0].angle = 90 kit.servo[1].angle = 90 @app.route("/control", methods=["POST"]) def control(): try: pan = int(request.form.get("pan", 90)) tilt = int(request.form.get("tilt", 90)) pan = max(0, min(180, pan)) tilt = max(0, min(180, tilt)) kit.servo[0].angle = pan kit.servo[1].angle = tilt return jsonify(code=0, pan=pan, tilt=tilt) except Exception as e: return jsonify(code=1, error=str(e))

actuation_range明确为180,保证库内角度换算和舵机实际转角一致。前后端都做0到180的边界裁剪,防止参数越界把舵机打死。这里还隐藏了一个经验:舵机供电必须放开到5V 2A,转动瞬间电流峰值很大,用树莓派引脚供电会触发欠压保护,树莓派直接重启。

提示:如果舵机嗡嗡响、发热,先看PWM频率是否是50Hz。ServoKit默认频率符合多数舵机,但MG996R这类大扭力舵机若出现抖动,可以显式设置kit.servo_pwm_freq = 50。

4.3 前端控制与节流

前端页面保持极简:左侧img指向/video,底部放方向键按钮,右侧div显示测量值。控制指令用fetch发POST到/control,不需要WebSocket:

const body = `pan=${pan}&tilt=${tilt}`; fetch("/control", { method: "POST", headers: {"Content-Type": "application/x-www-form-urlencoded"}, body: body });

fetch在树莓派Flask服务里是同源请求,没有跨域问题。按键步长我习惯用3度,靶标离中心近时切到1度微调。这里容易翻车的点是节流:按住方向键不松,浏览器会连续触发几十次请求,PCA9685响应不过来,舵机看起来像发疯。常见做法是两次请求间隔至少100ms,前端记录上次发送时间,超时后才发下一次。

按下按键后云台转到位需要时间,尤其是小舵机带载时,200ms内角度还没完全稳定,不要在这个时间窗口内触发测量,否则测到的是目标还没停稳的帧。

4.4 测量结果回传:什么时候触发测量最稳

/measure接口负责回传毫米值和中心偏移。前端每隔1秒轮询一次,拿到数据就更新页面。测量触发条件不是“随时测”,而是靶标中心与画面中心偏差落在±20像素以内后才读数,这样透视误差最小:

state = {"result": None} @app.route("/measure") def measure_api(): r = measure(frame) if r and px_per_mm: dx = r["center_x"] - 320 if abs(dx) <= 20: # 中心对准后才认可 state["result"] = { "width_mm": round(r["width_px"] / px_per_mm, 1), "center_dx": dx, "center_dy": r["center_y"] - 240 } return jsonify(state["result"])

这里frame建议用视频循环里的全局最新帧持续覆盖,measure_api读它的当前值。不要在这个路由里再开一个VideoCapture,两个读线程会互相抢摄像头buffer,直接导致视频花屏。320和240是640x480画面的中心坐标,center_dx用于前端提示“目标偏左/偏右”。轮询频率不要超过2Hz,MJPEG流本身已经占带宽,测量轮询太密会让树莓派网络栈忙于处理HTTP小请求。

联调顺序我建议固定成:先本地跑measure确认识别和标定,再开/video看画面流,再加/control试云台,最后把/measure接到前端。四个模块分开过,出问题时能快速定位是算法问题、视频问题还是控制问题。

5. 常见问题与排查:延迟、透视误差和舵机抖动的五类踩坑

5.1 远程画面卡顿,控制指令半秒后才反应

现象:电脑打开/video像看幻灯片,点方向键后云台延迟明显,有时点了没反应。

原因:MJPEG码率过高占满树莓派WiFi带宽;Flask默认开发服务器是单线程,视频流长连接占住线程后,控制POST请求排队等待。这两个原因叠加,画面和控制全卡死。

解决:把分辨率降到640x480,JPEG质量降到50到70之间;确认帧率限制在15fps以内。Flask启动时显式加threaded=True,用app.run(host="0.0.0.0", port=5000, threaded=True),让每个请求在独立线程处理。改完这两个点,局域网内延迟能压到200ms以内。

5.2 测量宽度偏大,云台转动后尤其明显

现象:靶标正对画面时宽度接近真实值,一旦云台转角度,测出来比实际宽不少。

原因:云台转动后摄像头光轴不再垂直于靶标平面,透视投影让矩形在图像里被拉伸;同时物距变化改变了像素当量,两个误差叠加,宽度能偏5%以上。

解决:测量一定要在“先对准后测量”的流程下做,即靶标中心贴近画面中心时才读数。如果要求更高,按云台角度做余弦修正:真实宽度等于测量宽度除以cos(偏转角度)。我实测10度偏角不修正,宽度误差就超5%,修正后能回到2%以内。第6章的九点标定也可以缓解这个问题。

5.3 舵机抖动画面上飘,回差不居中

现象:云台转到指定角度后,画面稳定两三秒又开始缓慢漂移;或者同样指令发两次,角度结果差一两度。

原因:舵机存在死区和机械回差,SG90这类小舵机低速时扭力不足,还会轻微抖动。PCA9685供电不足时更严重,舵机内部可能直接复位。

解决:舵机独立5V供电,和树莓派共地,这是第一优先级。控制函数里做“到位再确认”:目标角度写入后等300ms,再读一次当前角度,偏差超过阈值就补发一次。机械上给舵机臂和转轴之间加少量润滑脂,也能减轻回差。这个现象有玄学成分,不是所有舵机都一样,建议实测后再决定要不要加补偿。

5.4 局域网内其他电脑访问不了

现象:树莓派本机用curl能拿到/video,同一局域网下的笔记本却打不开页面。

原因:服务只绑定了127.0.0.1,只监听本机回环地址;或者防火墙没有放行5000端口;再或者路由器和交换机开了AP隔离,无线设备互不可见。

解决:app.run的地址改成0.0.0.0,不要写localhost。查看树莓派IP用ip addr show wlan0,然后在笔记本上ping同网段确认网络通。端口不通时用sudo ufw allow 5000放行。竞赛现场如果WiFi条件差,直接改用网线把树莓派接到交换机,别花时间折腾无线和公网访问。这种问题看似小,但会耽误一整个调试时段。

5.5 靶标识别抓错或丢失

现象:画面里明明有靶标,测量值却偶尔跳到几十毫米,甚至直接显示无目标。背景里出现深色物体时更容易触发。

原因:OTSU二值化只按灰度分布切分,背景里高对比物体可能被误判为靶标;强反光让黑色矩形内部出现高光,轮廓断裂,面积约束直接把它滤掉。

解决:加连续性约束,连续5帧内宽度像素变化不超过10%才认为有效。换用HSV的明度通道做cv2.inRange,选择亮度低于50的像素再做开闭运算,对反光的鲁棒性更好。如果现场允许换靶标,用哑光黑色卡纸,别用亚克力板。亚克力板的反光会让同一套阈值参数在不同角度下表现完全不一样,这是我踩过的血泪经验。

6. 进阶落地:九点标定下云台自动对准的实现与精度验证

6.1 九点标定:把云台角度和靶标偏差拟合成映射

手动控制只能算demo,想自动对准就先做九点标定:靶标在画面九个位置,分别记录靶标中心偏差(dx, dy)和云台角度(pan, tilt),再最小二乘拟合出角度到偏差的线性映射:

import numpy as np pan = np.array([90, 85, 95, 90, 85, 95, 90, 85, 95]) # 9个位置的pan tilt = np.array([80, 80, 80, 85, 85, 85, 90, 90, 90]) dx = np.array([-60, 40, 120, -55, 5, 110, -50, 10, 95]) # 中心x偏差 A = np.stack([pan, tilt, np.ones_like(pan)], axis=1) coef = np.linalg.lstsq(A, dx, rcond=None)[0]

coef就是每个角度对偏差的权重。控制时测得新dx,反解出pan增量并叠加到当前角度,云台就能自动把靶标拉回中心。采集时靶标远近要拉开,否则拟合没覆盖物距维度,换个距离就失效。

6.2 精度验证与最后的收尾

验证方法固定:对准后连续读12次measure结果,算平均值、标准差,并用游标卡尺对比,目标是把标准差控制在真值±2%以内。达不到就回头查当量标定和舵机回差,别盲目加算法复杂度。这类工程我习惯拆成server.py、camera.py、measure.py、servo.py四个模块,问题定位快。从那以后,我每次改完舵机机械结构都强制重跑一次九点标定再测功能——机械一变,旧参数就是废纸。希望这份拆解和资源包能帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询