既然$Raspbot$小车上面既有摄像头又有轮子,那么也许可以尝试做一个可以遥控行驶的监控器,这样即可非常方便地远程观察家中的情况,甚至脱离了固定监控的位置限制,它可以去到各个房间探查。下面就是实现这个功能的完整过程。

设备联网

(注:电脑上需要下载的应用有VNCviewer以及nmap,其中nmap可以通过其官网直接下载安装,而VNCviewer可以通过RealVNC官网安装,如果连接不上官网,可以在亚博智能网站提供的资源中下载远程登录软件.zip文件,解压后同样可以找到VNCviewer的安装包)

如果想要通过网络对小车进行控制,首先要让小车联网。本身按照官方的说法,直接用手机App中的配网按钮,生成一个专用的联网二维码,放到小车摄像头面前扫描即可,但这种方式实测并不可靠,本身如果有一根Micro-HDMI转HDMI的传输线,就可以把小车主板直接接到显示器上面,选择网络连接即可,但如果手头并没有线,必须通过远程才能访问到小车的桌面,那么就需要按照以下步骤联网:

  1. 用一根网线直接插入主板上的网线连接口,和路由器连接,强制通过有线连接的方式让它联网;
  2. 将电脑连入和小车同样的网络,运行ipconfig命令,找到WLAN的IPV4地址,可以发现显示的是一个形似A.B.C.D的局域网地址,记下四个数字,并将下文中的字母均替换为查询到的数字;
  3. 由于此时小车已经有线连接在同一个网络下,故在命令行中运行nmap -sn A.B.C.1/24命令,即可查找和电脑连接在同一网络下的所有设备。此时nmap会默认在括号中打印出设备的制造商和其局域网地址,找到括号中写着Raspberry Pi (Trading)的设备,下面的IPV4一栏即为小车的局域网地址;
  4. 打开VNCviewer,在左上角File一栏中找到New Connection,点击添加新的连接,并在弹出对话框的VNCserver一栏中填写刚刚查到的小车局域网地址,Name中填上Raspbot,其他默认即可,点击OK;
  5. 这时VNCviewer的主页中新增了一个名为Raspbot的设备,双击即可进入小车的桌面啦!至此,远程访问小车系统就已经实现了。

在小车桌面的左上角点击命令行的图标,打开命令行,开始设置无线网卡的联网:

  1. 运行以下命令,查询附近可用网络,这时会列出附近的网络名称和信号强度,如果要连接的网络在显示的列表中,即可连接;
sudo nmcli device wifi list
  1. 按q退出网络查找模式,运行以下命令,将无线网卡连入网络:(注意双引号不要删掉了)
sudo nmcli device wifi connect "你的WiFi名称" password "你的WiFi密码" ifname wlan0

如果连接成功,命令行就会返回:

Device 'wlan0' successfully activated with 'X-X-X-X-X'.

这时,在小车的命令行中运行ifconfig,找到wlan0对应的信息,其中第二行的inet后面即为小车无线联网的局域网IPV4地址了,此时,再在VNCviewer中新建一个连接,将无线联网的这个地址输入VNCserver,重新取个名字,这样,就算将网线拔了,双击这个新增的设备也可以通过局域网无线访问小车桌面了!

但这时如果重启小车,它依然会优先进入热点模式而非连接网络,所以需要修改小车系统中连接网络和开启热点的优先级,用以下命令:

sudo nmcli connection modify "你的WiFi名称" connection.autoconnect-priority 10
sudo nmcli connection modify "Raspbot" connection.autoconnect-priority 1

第一句设置连接wifi的优先级为10,第二句设置开启热点的优先级为1,提高wifi优先级,即可保证小车开机首选自动连接wifi。

至此,小车启动自连网设置完毕。

搭建网页

联网之后就需要选择一个信号传输的方式,我个人认为搭建一个网页并通过网络发送信号是最方便的,当然可以开发一个手机App,再通过网络连接小车,但这就和亚博智能官方的应用没什么区别了,甚至还更麻烦。以下就是搭建网页的步骤。

这里通过python第三方包flask进行网页搭建,这样,只需要在树莓派中运行一个.py文件,即可打开一个地址类似A.B.C.D:X的端口,其中A.B.C.D是小车自己的IP地址,而X则是一个可以在flask中自定义的一个端口编号,这样,只需用手机访问这个网址,即可开始和小车进行信号传输。

我的小车网页总共分四部分:主页、遥控页面、人脸识别页面、聊天室,其中:

  1. 主页集成了监控画面和三个功能页面的跳转按钮;
  2. 遥控页面中功能较多,集成了以下功能:
    1. 监控画面;
    2. 摄像头角度控制;
    3. 小车行驶控制;
    4. 蜂鸣器控制;
    5. 抓拍并下载监控当前画面;
  3. 人脸识别页面在进入时,小车会将摄像头转到几个设定的角度,寻找人脸,在找到人脸后自动调整摄像头角度使得人脸居于画面中央,并在对应人脸上标明绿色方框,如果人脸已经录入在人脸数据库中则会在方框右上角标注人名;
  4. 聊天室中集成以下功能:
    1. 发送信息,记录并显示聊天历史;
    2. 根据发送信息的IP地址不同,调用不同的人声对信息进行阅读;
    3. 可在后台修改不同IP对应的昵称,用于聊天历史的显示。

下面是具体代码,注意这些网页均需要用.py文件和.html文件联合搭建。

一些基本搭建网页的代码如下: python语句:

from flask import Flask, request, render_template, url_for, Response, send_file, jsonify
 # 初始化网页对象,之后所有网页地址对应功能的实现都需要靠它
app = Flask(__name__)

# @app.route(path)命令需要配合在其下方的函数一起执行,表示当网址"A.B.C.D:X"+path被访问的时候,下方的函数就会开始运行。传入path表示网页路径,单一的"/"表示根目录
@app.route("/")
def home():
	# render_template函数中传入的是当前要渲染的网页对应html文件的名字,通常文件结构如下:
	# │————当前.py文件
	# └────templates
	#      │————homePage.html
	#      └────所有html文件均在templates目录下
	return render_template("homePage.html")
	
# 以下语句需要写在@app.route()语句下面的函数中

# 返回一个字典化的东西,使得html可以读取到函数返回的信息
return jsonify({"status":"ok"})

# 当html在运行时转到了类似:A.B.C.D:X/something?button=xxx的网址时,"?"后面的东西相当于对python函数中给出了一些变量,必须通过request.args.get()语句获得,并且里面填入的名称必须和传入的变量名相同
button = request.args.get("button")

# 实现抓拍并下载监控当前画面功能时,需要用到send_file命令,它会将抓拍下来的照片tmp.jpg作为网页附件发送给手机并自动开始下载,其中download_name表示手机下载时的图片名
send_file('tmp.jpg', as_attachment=True, download_name=curTime+'.jpg')

# 实现实时预览监控画面的功能时,需要用到Response函数,其中generate_frames()函数是自己定义的一个获取图像并用yield函数生成数据流的函数,后面的mimetype告诉了浏览器这个地方需要用多个图像不断替换的
return Response(
	generate_frames(), mimetype="multipart/x-mixed-replace; boundary=frame"
)

# 可以在下面的函数中写入资源释放的语句,例如car.Car_Stop(),当Ctrl+C中断程序时就会自动执行该函数
def cleanup_and_exit(signum, frame):
    sys.exit(0)
signal.signal(signal.SIGINT, cleanup_and_exit)
signal.signal(signal.SIGTERM, cleanup_and_exit)

# 这一句放在if __name__ == "__main__":中,启动网页,开启5000端口
app.run(host = '0.0.0.0', port = 5000, use_reloader = False)

html语句:

html中的<head>部分需要写的部分代码如下:

<style>
	/* 自定义视频框样式 */
	.video-stream {
		width: 100%; /* 在左右方向上自动铺满整个屏幕 */
		max-width: 800px; /* 设定最大宽度为800像素 */
		height: auto; /* 自动缩放图像,按照当前宽度自动调节高度 */
		border-radius: 8px; /* 圆角处理,半径为8像素 */
		box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); /* 在图片下方加上阴影 */
	}
	
	/* 自定义按钮样式 */
	.btn{
		padding: 20px 40px; /* 设定按钮边框和文字在横向和纵向上的间隔 */
		font-size: 20px; /* 按钮上的字体为20像素 */
		/* 防止用户长按按钮时选择到按钮上的文字 */
		user-select: none;
		-webkit-user-select: none;
		touch-action: manipulation;
	}

	body {
		touch-action: manipulation; /* 禁止双击缩放页面 */
	}
	
	.btn, 
	.vertical-slider, 
	.horizontal-slider, 
	.joystick-wrapper {
		touch-action: none; /* 禁止在点击这些按钮时缩放页面 */
	}
</style>

创建全比例油门与转向摇杆的指令:

<style>
/* 下面是遥控页面中实现全比例油门和转向的两个摇杆 */
	
	/* 用一个容器同时放置油门和转向的两个摇杆 */
	.joystick-wrapper {
		display: flex;
		flex-direction: row; /* 将两个摇杆横向摆放 */
		justify-content: center; /* 将横向摆放的两个摇杆居于页面中央 */
		align-items: center; /* 将两个摇杆在创建的空间中居中 */
		gap: 20px; /* 两个摇杆之间的间隔为20像素 */
		margin: 15px auto;
	}

	/* 放置纵向油门摇杆的容器 */
	.slider-container {
		margin: 0;
		height: 160px; /* 容器大小为160*160 */
		width: 160px;
		display: flex;
		justify-content: center;
		align-items: center;
	}

	/* 油门摇杆 */
	.vertical-slider {
		/* 清除浏览器默认摇杆样式,使其可以自定义 */
		-webkit-appearance: none;
		appearance: none;
		/* 默认此处创建的是横向的摇杆,故之后旋转了90度,同时这里的宽度设置为150像素,高度设置为20像素 */
		width: 150px;
		height: 20px;
		/* 背景颜色设置为深灰色,同时加上圆角 */
		background: #444;
		border-radius: 5px;
		cursor: pointer; /* 鼠标移动到按钮上时显示为手指形状 */
		accent-color: #00e5ff; /* 滑块手柄设置为亮青色 */
		outline: none; /* 设定当被按住的时候外侧不显示浏览器中被选择时的边框 */
		-webkit-tap-highlight-color: transparent; /* 修复手机上长按时按钮颜色闪烁问题 */
		transform: rotate(-90deg); /* 由于创建的是横向摇杆,需要纵向的,故逆时针旋转90度 */
	}

	/* 油门摇杆按钮 */
	.vertical-slider::-webkit-slider-thumb{
		-webkit-appearance: none;
		appearance: none;
		width: 20px; /* 设定油门摇杆按钮宽高分别为20和60,旋转后就会变成水平方向较宽的按钮 */
		height: 60px;
		background-color: #00e5ff; /* 按钮为亮青色 */
		/* 下面和视频页面的设定类似 */
		border-radius: 6px;
		border: 2px solid #ffffff;
		cursor: pointer;
		box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
	}
	
	/* 针对Firefox浏览器设定滑块可点击部分的大小 */
	.vertical-slider::-moz-range-thumb{
		width: 20px;
		height: 60px;
		background-color: #00e5ff;
		border-radius: 6px;
		border: 2px solid #ffffff;
		cursor: pointer;
	}
	
	/* 横向的转向摇杆和油门摇杆极其类似,去掉旋转指令即可 */
</style>

html中<body>部分需要写的部分代码如下:

创建一个返回主菜单的按钮,形状为创建的btn,点击以后会直接跳转到python文件中home函数对应的地址
<a href="{{ url_for('home') }}" class="btn">返回主菜单</a>

创建一个控制相机上扬的按钮,在网页或手机上按下或离开都会调用后面JavaScript中定义的send_state函数
<button class="btn"
        onmousedown="sendState('camera','up','down', 0)" 
        onmouseup="sendState('camera','up','up', 0)"
        ontouchstart="sendState('camera','up','down', 0)" 
        ontouchend="sendState('camera','up','up', 0)">
        上
</button>

创建两个摇杆,设定初始值为0,最小最大值分别为-100和100,可用于之后的读取
<div class="joystick-wrapper">
	<div class="slider-container">
		<input type="range" min="-100" max="100" value="0" class="vertical-slider" id="throttle-slider">
	</div>

	<div class="horizontal-slider-container">
		<input type="range" min="-100" max="100" value="0" class="horizontal-slider" id="steering-slider">
	</div>
</div>

创建一个文本区域,用来显示聊天记录,只读,可以自动调节宽度,并且当没有聊天记录时会用浅色字体写上"Chat messages will appear here..."
<textarea id="chatBox"
	readonly
	placeholder="Chat messages will appear here..."
	style="width: 100%; max-width: 100%; height: 300px; resize: none; box-sizing: border-box; font-size: 20px;">
</textarea>

创建一个输入框,用来发布言论,可以自动调节宽度
<input type="text" id="myInput" placeholder="Type here..." style="width: 100%; max-width: 100%; height: 50px; font-size: 25px; padding: 10px; box-sizing: border-box;">

创建一个音频对象,用来在手机上播放最新一句话,并且不会显示任何图标
<audio id="myAudio">
	<source src="{{ url_for('static', filename='chinese.mp3') }}" type="audio/mpeg">
</audio>

html中需要用JavaScript编写的部分指令:

// 用来记录当前驾驶状态
let driveState = {
	throttle: 0,
	steering: 0
};

// 设定某个文本位置中的内容
document.getElementById("text").innerText = "blah blah blah"

// 跳转到某个网址,传入一些信息,不同信息之间用&连接,注意只有用反引号``包裹字符串时,才可以在{}中写上变量名,会自动填充
fetch(`/somepath?first=${first}&second=${second}`)

// 检测到滑块被按下,就直接更新驾驶状态并发送给Python函数
slider.addEventListener('input', function() {
	driveState.throttle = parseInt(slider.value);
	sendDriveState();
});

// 当网页或手机松开时将滑块位置设定为居中,并更新驾驶状态
function resetVerSlider() {
	slider.value = 0;
	driveState.throttle = 0;
	sendDriveState(true);
}
slider.addEventListener('mouseup', resetVerSlider);
slider.addEventListener('touchend', resetVerSlider);

// 更新myAudio对象并在手机上进行播放
fetch("/read_message")
// python中该路径有对应的函数,用来生成语音,故接收到返回值时就说明新的音频已经准备好了,可以播放
.then(res => res.json())
.then(data => {
	if (last_played_version != data.sound_version){
		// 防止播放重复的音频
		const audio = document.getElementById("myAudio");
		// 创建一个独一无二的路径
		audio.src = "{{ url_for('static', filename='chinese.mp3') }}?t=" + new Date().getTime();
		audio.load(); // 重新加载音频
		audio.play().catch(err => console.error("Playback error:", err)); // 播放音频
		last_played_version = data.sound_version; // 更新播放版本
	}
});

// 设定每1000毫秒即1秒调用一次updateText函数,并在页面访问时强制调用一次该函数
setInterval(updateText, 1000);
updateText();

至此,搭建基本网页框架的代码结束了,剩下主要是整合和控制小车,总的代码放在本文最后。

远程联网

当搭建好网页后,如果直接运行,那么当且仅当手机和小车连在同一个网络时才能访问小车开启的网页,如果要远程连接,这是不行的,故需要用一个可以穿透内网的东西,给手机和小车各分配一个IP地址,才能通过手机->手机网络->总服务器->小车网络->小车的途径和小车连接。

我使用的是Tailscale进行内网穿透,使用方法非常简单,首先用自己的账号在Tailscale官网登陆,按照提示在电脑上下载它的软件,如果可以,同时在手机上下载一个手机版的Tailscale软件,打开软件,同样按照提示登陆自己的账号,这样,手机和电脑就同时被添加在了自己的账号中。每个添加在自己账号中的设备会被分配一个IP地址,并且只有添加在同一个账号下的设备才能互相访问。

之后在小车命令行中运行以下代码:

sudo apt update
sudo apt upgrade
curl -fsSL https://tailscale.com/install.sh | sh

运行结束就成功在小车上安装了Tailscale软件,之后运行:

sudo tailscale up

启动Tailscale,这时会输出一个网址,复制出来到电脑浏览器中,按照提示登陆账号,成功之后小车也会加到自己的Tailscale账号中,拥有自己的IP地址,这时,将手机上的软件启动,将小车上的.py文件开始运行,在手机浏览器中输入小车IP地址:5000即可访问小车的网页了。这样,就算不连在同一个局域网下,不同设备之间同样可以实现互通。

需要注意的细节

人脸识别中用到了OpenCV的YuNet轻量级大模型,以及SFace模型,前者用来识别人脸,后者用来匹配人脸与名字,如果要用这些东西,可以到OpenCV官网进行下载,通过WinSCP应用和小车系统互联,将下载好的这两个东西通过该应用复制到小车系统中即可。需要注意的是,如果Raspberry Pi系统自带的cv2库版本过低,无法调用这两个东西,则需要绕过系统限制升级cv2库。

同样的,如果要用我程序中用到的edge-tts进行语音生成,同样要绕过系统限制下载edge-tts库。

绕过系统限制下载或安装任何东西,需要在安装命令之后加上:

--break-system-packages
例如:
pip install --upgrade opencv-python --break-system-packages
pip install edge-tts --break-system-packages

小结

本文采用flask框架在携带树莓派5B视觉小车上创建了集成实时监控、人脸识别、在线聊天室等功能的网站,并用Tailscale实现了远程访问,相较于传统的固定监控系统,该系统增加了移动和聊天功能,实际测试效果较佳。

整体代码

点击查看所有代码