LeoCar:树莓派智能车——网络篇

  • 文章链接:网络篇——>电控篇——>综合篇
  • 项目链接:https://github.com/LegendLeoChen/LeoCar
  • 实现一个树莓派为核心的双轮小车,搭载人脸检测算法,摄像头传输图像同步到手机或PC,手机(同局域网)可以控制网页上的交互组件远程操控小车运动,小车摄像头跟踪人脸运动。
  • 这个项目涉及前端开发、算法编写和部署、网络链路、电控、结构搭建、电路搭建等全方面工作。
  • 本次先实现网络链路的打通,在仅有PC、手机、树莓派、摄像头的情况下即可率先完成。

🌐网络摄像头

摄像头安装

树莓派摄像头

  • 摄像头采用如图所示的排线接口的摄像头,在树莓派HDMI旁边的排线接口上,把类似于开关的黑色挡板向上拔到顶(不是拔出),然后排线接入,排线面向HDMI接口(也可以看接线口里面也有一排金属触点)。注意:千万不用使大力气拔,以免搞坏。接好再使力向下按,保证排线接好。

配置摄像头

  • 打开树莓派的配置界面,进入后再进入Interface项
sudo raspi-config

树莓派配置界面

  • 选择Camera并开启它,然后重启树莓派即可

树莓派配置界面

mjpg streamer

mjpg streamer的作用是从摄像头采集图像,并把它们以数据流的形式,通过基于IP的网络传输到浏览器端。

  • 先安装需要的包
sudo apt-get install libjpeg8-dev  //JPEG支持库
sudo apt-get install imagemagick
sudo apt-get install libv4l-dev   
sudo apt-get install cmake  //下载编译工具
  • 克隆mjpg streamer的库到本地
git clone https://github.com/jacksonliam/mjpg-streamer.git
  • 进入下载目录路径并编译安装
cd mjpg-streamer/mjpg-streamer-experimental
make all
sudo make install
  • 启动mjpg streamer,这里为了方便每次打开,直接写一个脚本webcam.sh,其中 320x240是分辨率,可以调大点,这里比较小为了提升算法帧率:
#!/bin/bash
cd ~/mjpg-streamer/mjpg-streamer-experimental
export LD_LIBRARY_PATH=.
mjpg_streamer -i "input_uvc.so -d /dev/video0 -r 320x240" -o "output_http.so -w ./www"
${mjpg_streamer_command}
  • 在该脚本的路径下打开终端运行一下指令,使得以后双击就能运行脚本:
chmod +x webcam.sh
  • 双击运行脚本(选择在终端),可以看到源源不断有视频流传入局域网:
    运行webcam脚本
  • 这时同局域网的设备都可以进入 http://ip:8080 ,ip代表树莓派的网络ip地址,进入如下界面就是成功了,可以点击JavaScript获取前端代码的实例
    mjpg网站
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>MJPEG-Streamer</title>
</head>
<script type="text/javascript">

var imageNr = 0; // Serial number of current image
var finished = new Array(); // References to img objects which have finished downloading
var paused = false;

function createImageLayer() {
  var img = new Image();
  img.style.position = "absolute";
  img.style.zIndex = -1;
  img.onload = imageOnload;
  img.onclick = imageOnclick;
  img.src = "./?action=snapshot&n=" + (++imageNr);
  var webcam = document.getElementById("webcam");
  webcam.insertBefore(img, webcam.firstChild);
}

// Two layers are always present (except at the very beginning), to avoid flicker
function imageOnload() {
  this.style.zIndex = imageNr; // Image finished, bring to front!
  while (1 < finished.length) {
    var del = finished.shift(); // Delete old image(s) from document
    del.parentNode.removeChild(del);
  }
  finished.push(this);
  if (!paused) createImageLayer();
}

function imageOnclick() { // Clicking on the image will pause the stream
  paused = !paused;
  if (!paused) createImageLayer();
}

</script>
<body onload="createImageLayer();">

<div id="webcam"><noscript><img src="./?action=snapshot" /></noscript></div>

</body>
</html>
  • 有了这个代码我们可以轻松集成网络视频流到自己的前端代码了。最简单可以只用<img src="http://IP地址/?action=stream" />直接访问视频流,不过这样可能会累积缓存,这个实例使用snapshot访问,获取图片,还加了暂停交互等(暂时用不到)
  • 至此,网络视频流就可以被同一个局域网任何设备访问了。摄像头变成了网络摄像头

🌐Python获取视频流

  • 用Python opencv显示视频流
import cv2
import numpy as np
from urllib import request

url = "http://树莓派IP:8080/?action=snapshot"

def downloadImg():
    global url
    with request.urlopen(url) as f:
        data = f.read()
        img1 = np.frombuffer(data, np.uint8)
        # print("img1 shape ", img1.shape) # (83653,)
        img_cv = cv2.imdecode(img1, cv2.IMREAD_ANYCOLOR)
        return img_cv

while True:
    # image = downloadImg()
    image = downloadImg()  # cv2.imread('1.jpg') # 根据路径读取一张图片
    cv2.imshow("frame", image)
    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

cv2.destroyAllWindows()

Python获取网络视频流

🌐PC与手机交互

  • 我们先实现PC上搭建Python的Flask restful服务器,获取已实现的网络视频流的同时,搭建网页使得手机可以交互。

前端

  • 先完成前端,新建index.html文件和随便一张ico图作为浏览器上的网页图标(以免报错)
<!DOCTYPE html>
<html>
<head>
  <link />
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0" />
  <title>rpi-robot control</title>
  <style>
    body {
      margin: 0;
      overflow: hidden;
      height: 100vh;
    }
    .btn-controller {
      position: absolute;
      left: 5%;
      top: 40%;
    }
    #angleLabel {
      position: absolute;
      left: 5%;
      top: 92%;
      font-size: 20px;
    }
    .raspi-video {
      position: absolute;
      left: 5%;
      top: 5%;
      border: 5px solid #007cfc;
    }
    @media only screen and (max-width: 1000px) {
      .btn-controller {
        left: 50%;
        top: 65%;
        width: 50%;
        transform: translate(-50%, -50%);
      }
      #angleLabel {
        left: 30%;
        top: 92%;
        transform: translate(-50%, -50%);
        font-size: 20px;
      }
      .raspi-video {
        left: 50%;
        top: 20%;
        width: 90%;
        transform: translate(-50%, -50%);
      }
    }
  </style>
  <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>

<body id="container">
  <script type="text/javascript">

    var imageNr = 0; // Serial number of current image
    var finished = new Array(); // References to img objects which have finished downloading
    var paused = false;

    function createImageLayer() {
      var img = new Image();
      img.style.position = "absolute";
      img.style.zIndex = -1;
      img.className = "raspi-video";
      img.onload = imageOnload;
      img.onclick = imageOnclick;
      img.src = "http:/树莓派IP:8080/?action=snapshot&n=" + (++imageNr);
      var webcam = document.getElementById("webcam");
      webcam.insertBefore(img, webcam.firstChild);
    }

    // Two layers are always present (except at the very beginning), to avoid flicker
    function imageOnload() {
      this.style.zIndex = imageNr; // Image finished, bring to front!
      while (1 < finished.length) {
        var del = finished.shift(); // Delete old image(s) from document
        del.parentNode.removeChild(del);
      }
      finished.push(this);
      if (!paused) createImageLayer();
    }

    function imageOnclick() { // Clicking on the image will pause the stream
      paused = !paused;
      if (!paused) createImageLayer();
    }

  </script>

  <body onload="createImageLayer();">
    <div id="webcam"><noscript><img class="raspi-video" src="http://树莓派IP:8080/?action=snapshot" /></noscript>
    </div>

  </body>
  <label id="angleLabel">Angle:</label>
  <canvas class="btn-controller" width="300" height="300"></canvas>
  <!-- 遥杆 -->
  <script>
    // 显示角度的label
    const label = document.getElementById('angleLabel');
    // 获取 Canvas 元素
    const canvas = document.getElementsByClassName('btn-controller')[0];
    const ctx = canvas.getContext('2d');
    // 大圆半径和小圆半径
    const bigRadius = 150;
    const smallRadius = 55;
    // 小圆的初始位置
    let smallCircleX = canvas.width / 2;
    let smallCircleY = canvas.height / 2;
    // 角度
    let angle = 0;
    let last_angle = 0;

    // 绘制函数
    function draw() {
      // 清空 Canvas
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.strokeStyle = '#007cfc';
      ctx.fillStyle = '#007fff';
      // 绘制大圆
      ctx.beginPath();
      ctx.arc(canvas.width / 2, canvas.height / 2, bigRadius, 0, 2 * Math.PI);
      ctx.stroke();
      // 绘制小圆
      ctx.beginPath();
      ctx.arc(smallCircleX, smallCircleY, smallRadius, 0, 2 * Math.PI);
      ctx.fill();
    }

    // 更新小圆的位置
    function updateSmallCircle(x, y) {
      // 计算小圆与大圆的距离
      const distance = Math.sqrt((x - canvas.width / 2) ** 2 + (y - canvas.height / 2) ** 2);
      // 小圆与大圆相切
      const angle = Math.atan2(y - canvas.height / 2, x - canvas.width / 2);
      smallCircleX = canvas.width / 2 + (bigRadius - smallRadius) * Math.cos(angle);
      smallCircleY = canvas.height / 2 + (bigRadius - smallRadius) * Math.sin(angle);
      // 重新绘制
      draw();
      // 返回实际角度(上半周为正)
      return -Math.round(Math.atan2(smallCircleY - canvas.height / 2, smallCircleX - canvas.width / 2) * 180 / Math.PI);
    }

    // 触摸事件处理
    let isDragging = false;

    canvas.addEventListener('touchstart', (e) => {
      e.preventDefault();
      const x = e.touches[0].clientX;
      const y = e.touches[0].clientY;
      const rect = canvas.getBoundingClientRect();
      const mouseX = x - rect.left;
      const mouseY = y - rect.top;
      angle = updateSmallCircle(mouseX, mouseY);
      isDragging = true;
    });

    canvas.addEventListener('touchmove', (e) => {
      e.preventDefault();
      if (isDragging) {
        const x = e.touches[0].clientX;
        const y = e.touches[0].clientY;
        const rect = canvas.getBoundingClientRect();
        const mouseX = x - rect.left;
        const mouseY = y - rect.top;
        angle = updateSmallCircle(mouseX, mouseY);
        if (Math.abs(last_angle - angle) > 5) {
          // 发送 AJAX 请求
          $.ajax({
            url: "http://192.168.43.127:5000/position",
            type: "POST",
            data: JSON.stringify({ angle: angle }),
            contentType: "application/json",
            dataType: 'json',
            success: function (data) {
              console.log(data);
            }
          });
          last_angle = angle;
          label.textContent = `Angle: ${angle}°`;
        }
      }
    });

    canvas.addEventListener('touchend', () => {
      isDragging = false;
    });

    // 初始绘制
    draw();
  </script>
</body>
</html>
  • 该文件整合了Css、JS、Html,不需要额外文件。我们按照代码从上到下简单解释。
  • Css部分针对大小屏做了简单的分别适配。
  • 接着是之前的mjpg网站给的JS实例(英文注释部分),做了小幅修改,注意要修改对应的树莓派IP地址。
  • 紧接着是交互组件,是一个遥杆,使用Canvas实现,遥杆由大圆和小圆组成,小圆保持和大圆内切不超出。获取小圆相对于大圆中心(也是画布中心)的位置的角度,当角度变化足够大(这里设置5°)就发送POST请求,将角度angle以json格式打包发送给服务端。
  • 交互采用触控检测,电脑端不能触屏所以交互不了,只能在手机上交互。
  • 打开网页得到如下界面:
    前端交互界面

Python服务端

  • 在static文件夹的同级创建下面这个python文件
from flask import Flask, jsonify, request, make_response
import os
from flask_cors import CORS

app = Flask(__name__, static_url_path="")
CORS(app, resources={r"/position/*": {"origins": "*"}})

@app.route("/")
def index():
    return app.send_static_file("index.html")

@app.route('/position',methods=['POST'])
def send_button_position():  # put application's code here
    args = request.json
    try:
        print(f'angle = {args["angle"]}')
        return jsonify({"passed": True, "message": "成功发送位置", "data": f'angle={args["angle"]}'})
    except:
        return jsonify({"passed": False, "message": "错误", "data": None})

if __name__ == '__main__':
    # app.run()
    app.run(debug=True, host='0.0.0.0', port=int(os.environ.get('PORT', 5000)))
  • 这个Python文件简单地创建了一个同局域网都可访问的服务,服务主界面加载了index.html作为显示视频流和交互组件的UI。/position路径完成了对之前网页通过操作遥杆发送的POST请求的接收,解析json并反馈信息。
  • 运行程序,同局域网手机访问http://PC端IP.5000/即可进入如下界面:
    手机浏览器访问服务器前端
  • 操作遥杆(按住并移动小圆),小圆转到对应的角度,下面的label也显示角度,同时我们看到PC上程序也打印了角度,说明交互成功使得数据在局域网内传输。视频流也顺利显示。
    PC服务器打印接收的角度信息
  • 注意:如果同局域网手机访问不了网页,需要设置防火墙使之允许外界应用访问python程序。

🌐树莓派实现

  • 接下来可以将PC的服务器代码移植到树莓派,static文件夹也要移植。注意前端index.html文件里面的PC地址都要改成树莓派IP,树莓派端口号要大于1024否则会报错,PC上没有这个要求,在这里都取5000。
  • 在树莓派上运行程序,手机访问对应地址,同样地操作遥杆,得到如下:
    手机浏览器访问服务器前端
    树莓派服务器打印接收的角度信息
  • 至此,PC和手机(视频和交互)、树莓派和手机(视频和交互)、树莓派和PC(仅视频)都完成,PC的作用除了先行调试,还有我们可以在后期实现在连接PC的情况下实现对算力的增强,相当于让小车拥有“云端”算力。

🌐参考链接

树莓派摄像头和Mjpg Streamer:https://blog.csdn.net/m0_69808624/article/details/132192457
               https://blog.csdn.net/weixin_62529596/article/details/132569528
Python Flask框架与web页面交互:https://www.jianshu.com/p/2466cdad0d4a

  • Copyrights © 2023-2025 LegendLeo Chen
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信