最近在做一个机器人项目,需要将试试捕获安装于机器人身上的视频图像,并能够对机器人进行无线运动控制。作为前端工程师的我,很自然的想到了使用Node作为服务器和机器人的控制中心,通过前端页面实现对机器人控制和视频图像的捕捉。

本文主要对项目中的一个单元:视频图像的捕捉和拍照功能进行开发记录和解析。

实现功能
一: 远程视频图像获取
二: 视频图像清晰度调节
三: 拍照功能


基于Express的服务器环境搭建

Express是基于Node的一个快速搭建服务器的框架,项目使用Express快速搭建服务器。

node的安装

首先,更新所有安装列表到最新的状态:

pi@raspberrypi:~$ sudo apt-get update

升级所有安装包到最新版本:

pi@raspberrypi:~$ sudo apt-get dist-upgrade

接下来,下载和安装node(注意版本号使用_8.x)

pi@raspberrypi:~$ curl -sL https://deb.nodesource.com/setup_8.x | sudo -E bash -

现在可以安装了:

pi@raspberrypi:~$ sudo apt-get install -y nodejs

测试是否安装成功:

pi@raspberrypi:~$ node -v

Express安装

使用Node的包管理工具npm来新建项目和安装框架

首先,进入项目目录,并新建工程:

$ cd Public/WebProject/FisrtPage/
$ npm init -y

安装 Express 并将其保存到依赖列表中:
以下命令会将 Express 框架安装在当前目录的 node_modules 目录中

$ npm install express --save

然后,在该项目文件下新建server.js文件,引入Express就可以很方便的搭建起一个服务器。具体的内容在后面进行分析。

Mjpg-Streamer

项目使用的是一个USB摄像头,为了能将图像捕获并通过HTTP转发,项目使用Mjpg-Streamer实现这一功能。

  1. 安装必要的库
sudo apt-get update
sudo apt-get install libjpeg8-dev
sudo apt-get install imagemagick
sudo apt-get install libv4l-dev //
sudo apt-get install cmake //编译工具
  1. 为了向后兼容,链接videodev2.h和videodev.h
sudo ln -s /usr/include/linux/videodev2.h /usr/include/linux/videodev/h

注意,这里的sudo ln -s是非常重要的操作命令,类似于为a做一个超链接

  1. git开源代码到本地,编译进入到home目录,然后开始克隆
cd ~
sudo git clone https://github.com/jacksonliam/mjpg-streamer.git
cd mjpg-streamer/mjpg-streamer-experimental
  1. 编译和安装
sudo make
sudo make install
  1. 测试和使用

完成以上步骤之后,可以开始测试一下。 插入摄像头,执行以下命令,分别在两个窗口打开

sudo mjpg_streamer -i "./input_uvc.so -r 640x480 -q 70 -f 15 -d /dev/video1 -n" -o "./output_http.so -p 8080 -w /usr/local/www"

出现一下的内容,表明安装成功

这样,打开浏览器输入http://localhost:8080/?action=stream就可以看到视频图像,其中localhost在实际使用中,换成了树莓派的IP地址,树莓派已经提前设置了静态地址,我使用的是192.168.123.251,因此,视频的地址就顾定成了:

http://192.168.123.251:8080/?action=stream

依赖模块

shelljs

上面使用的Mjpg-Streamer可以通过改变参数实现对清晰度、帧率的调整,比如将上面的图像修改为720P、15帧的指令为:

mjpg_streamer -i "input_uvc.so -r 1280x720 -f 15 -n" -o "output_http.so "

但是这个是在终端中执行的命令,而服务器是使用Node,因此这里使用了shelljs实现在Node运行shell指令。

首先安装shelljs

npm install shelljs -S

有关该模块的具体使用及相关API可以查阅官网,本项目中主要使用了两个指令是:

  • shell.exec() 执行某个指令
  • shell.cd() 进入某个目录

为了在后台实现不同分辨率图像的转换,专门写一个函数来实现切换,并通过变量videoStatus的状态来表示不同的分辨率,与前端相对应的:

  • videoStatus: 1-流畅
  • videoStatus: 2-清晰
  • videoStatus: 3-高清

清晰度切换的函数实现如下:

var videoStatus = 0;  // 1-流畅; 2-清晰; 3-高清, 默认清晰
const videoCommand = [
	'mjpg_streamer -i "./input_uvc.so -r 640x480 -q 70 -f 30 -d /dev/video0 -n" -o "./output_http.so -p 8082 -w /usr/local/www"',
	'mjpg_streamer -i "./input_uvc.so -r 1280x720 -q 70 -f 15 -d /dev/video0 -n" -o "./output_http.so -p 8082 -w /usr/local/www"',
	'mjpg_streamer -i "./input_uvc.so -r 1920x1080 -q 70 -f 15 -d /dev/video0 -n" -o "./output_http.so -p 8082 -w /usr/local/www"'
]
function openVideo(qulity) {
	return new Promise((resolve,reject) => {
		if (shell.exec('pgrep mjpg_streamer').stdout !== '') {
			shell.exec('killall mjpg_streamer');
		}
		let command = videoCommand[qulity];
		shell.cd('~');
		shell.cd('mjpg-streamer/mjpg-streamer-experimental/')
		shell.exec(command, (code, std, err) => {
			console.log('Exit code:', code);
			console.log('Program output:', std);
			console.log('Program stderr:', err);
		})
		videoStatus = +qulity + 1;
		resolve('Success');
	})
}

对关键的几条指令进行一下说明:

  • shell.exec('pgrep mjpg_streamer').stdout !== ''

pgrep以名称为依据从运行进程队列中查找进程,并显示查找到进程的id。在shelljs中,stdout是指令的输出,如果不存在进程,则返回为空; 这里加判断的意思主要在于如果mjpg已经在运行,则要杀死该进程(清晰度更换通过重启mjpg实现)

  • let command = videoCommand[qulity]

具体的程序执行取决于前端的请求,根据qulity来开启不同清晰度的摄像头。

接下里,对设置清晰度的请求,设置服务器响应,并开启服务器:

var express = require('express');
var app = express();
var bodyParse = require("body-parser");

app.post('/VideoSet',(req,res) => {
	if(+req.body.Video === videoStatus) {
		res.end('Same Video Set');
		return;
	}
	switch (req.body.Video) {
		case "1": 
			openVideo(0);
			res.end('Success');
			break;
		case "2":
			openVideo(1);
			res.end('Success');
			break;
		case "3": 
			openVideo(2);
			res.end('Success');
			break;
	}
})

var server = app.listen(8081,function(){
	console.log("Server is running at 8081 port...");
});

serialport

项目除了传统前端的内容,还涉及了对机器人的控制,而机器人控制主要通过基于STM系列的下位机实现。所以RaspberryPi在作为服务器接收到前端控制请求后,需要将控制请求发送至下位机,实现控制,项目中使用了UART串口进行相互连接。

打开RaspberryPi 3B的串口通讯能力

之前项目中,使用了USB转串口模块直接插在RaspberryPI的USB接口上,然后通过serialport打开相应的串口实现串口通讯。

本项目中,为了节约USB资源和空间,要使用GPIO口的TX/RX进行UART通讯。RaspberryPi 3B与之前的版本不同,它带了两个串口,分别是:

  1. /dev/ttyAMA0:
    RPI3配备了蓝牙,为了保证蓝牙的正确使用,/dev/ttyAMA0则不再为GPIO串口服务,而是为蓝牙模块服务。
  2. /dev/ttyS0:
    被称为"mini uart",此串口代表了"Physical pin 8|10 BCM pin 14|15Wiring Pi pin 15|16".
    但是由于次串口波特率收到cpu频率影响,并不稳定,所以实际上无法被用来串口通信。

正因如此,网络上大部分教程,直接使用/dev/ttyAMA0作为串口的方法就无法使用RPI3了,查了相关资料,通过以下方法解决(参考自简书R4L)

将ttyAMA0和ttyS0互换,那么gpio tx\rx串口映射给ttyAMA0,ttyS0则映射给蓝牙设备。
这样gpio 14、15串口就拥有了稳定,强大的通信功能,而蓝牙串口则无法正常使用。

1) 激活串口

$ sudo nano /boot/config.txt

改变使得:enable_uart=1.
若无此参数,则在最后一行添加:enable_uart=1.
重启设备。

2)查看串口别名

ls -l /dev

会发现:
lrwxrwxrwx 1 root root 7 Aug 28 07:41 serial0 -> ttyS0
lrwxrwxrwx 1 root root 5 Aug 28 07:41 serial1 -> ttyAMA0

3)禁用/dev/ttyS0的console功能

$ sudo systemctl stop serial-getty@ttyS0.service
$ sudo systemctl disable serial-getty@ttyS0.service

并且修改cmdline.txt文件

$ sudo nano /boot/cmdline.txt

删除“console=serial0,115200”,保存并重启

  1. 交换串口
$ sudo nano /boot/config.txt

在最下面添加:dtoverlay=pi3-miniuart-bt
保存并重启。
此时查看串口别名则发现:
lrwxrwxrwx 1 root root 7 Aug 28 07:41 serial0 -> ttyAMA0
lrwxrwxrwx 1 root root 5 Aug 28 07:41 serial1 -> ttyS0
此时,ttyAMA0串口可以正常用于串口通信,ttyS0则无法被用于串口通信,蓝牙功能失效。

使用serialport打开通讯

  1. 安装serialport
npm install serialport -S
  1. 引入serialport,并开启串口
var SerialPort = require('serialport');
const port = new SerialPort('/dev/ttyAMA0', {
	baudRate: 9600
})   //使用串口,与下位机机型通讯
  1. 串口通讯

serialport的api非常简单,使用相关进行通讯即可

port.write('main screen turn on', function (err) {
	if (err) {
		return console.log('Error on write: ', err.message)
	}
	console.log('message written')  //打开串口
})

// Open errors will be emitted as an error event
port.on('error', function (err) {
	console.log('Error: ', err.message)
})

// Switches the port into "flowing mode"
port.on('data', function (data) {
	console.log('Data:', data.toString())  //接收到数据,打印出来
})

拍照与保存功能

MJPG-STREAMER支持保存当前帧,只需要将视频画面地址http://192.168.123.251:8082/?action=stream中的最后一个stream改为snapshot即可。

一开始初步的想法是完全同通前端实现,通过<img src="http://192.168.123.251:8080/?action=action" />标签来实现拍照功能,但是这种放有两个问题:

  1. 所见非所得,假如在t0时刻拍照为img1,接着点击保存到本地的时候,下载和保存的图片是t1时刻的另一张照片,这是不满足需求的;
  2. 图片下载功能通过<a>标签+download属性实现,但是chrome浏览器对与跨域的图像无法实现保存,只能在新页面打开。

因此拍照与保存功能设计成如下的流程:

服务器端配置

1) 获取图片地址

服务器端要实现保存图片到本地,首先需要获取图片的地址。图片地址为http://IP:PORT/?action=action

项目中,将视频画面的地址端口设置为8082,即PORT=8082,IP地址则是RaspberryPi本机的地址,在NODE中获取本机地址的方法如下:

function getIPAdress() {
	var interfaces = require('os').networkInterfaces();
	for (var devName in interfaces) {
		var iface = interfaces[devName];
		for (var i = 0; i < iface.length; i++) {
			var alias = iface[i];
			if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal) {
				return alias.address;
			}
		}
	}
} 

2) 下载图片

图片下载,使用到了request这个模块,首先在项目中安装该模块

npm install request -S

接下来,写一个下载图片的函数,创建一个文件downIMG.js

$ vim downIMG.js

写下载图片的函数,并将函数导出

const fs = require('fs');
const request = require('request');

const download = function(uri, filename, callback) {
    request.head(uri, function(err, res, body) {
        request(uri).pipe(fs.createWriteStream(__dirname +'/public/SnapShoot/' + filename)).on('close', callback);
    });
};

module.exports = download

在项目所在文件夹下,新建一个SnapShoot的文件夹。

3) 引入图片下载函数,服务器实现响应

在主文件server.js中,实现服务器的响应

var download = require('./downIMG');

app.use(express.static(path.join(__dirname, 'public'))); //将public设置为静态资源,这样保存的截图才可以被访问得到
app.use(bodyParse.json({ limit: '1mb' }));  //body-parser 解析json格式数据
app.use(bodyParse.urlencoded({extended:false}));

app.get('/capture', function (req, res) {
	download(snapAddress,'current.png',function(){
		res.send('Capture Sussess');
	})
})   //拍照请求

至此,前端只需要通过<a>标签配合download属性,就可以实现拍照和下载的功能了,样例:

<a target="_blank" id="down" :href="snapAdd" :download="currentDate">点击下载</a>

完整代码

服务器端包括了 server.js + downIMG.js,以及前端的页面及静态资源。

  • downIMG.js
var fs = require('fs'),
    request = require('request');

var download = function(uri, filename, callback) {
    request.head(uri, function(err, res, body) {
        request(uri).pipe(fs.createWriteStream(__dirname +'/public/SnapShoot/' + filename)).on('close', callback);
    });
};

module.exports = download
  • server.js
const express = require('express');
const app = express();
const bodyParse = require("body-parser");
const shell = require('shelljs');
const download = require('./downIMG');
const path = require('path');
const SerialPort = require('serialport');
const IP = getIPAdress();
const PORT = '8082';
const videoCommand = [
	'mjpg_streamer -i "./input_uvc.so -r 640x480 -q 70 -f 30 -d /dev/video0 -n" -o "./output_http.so -p 8082 -w /usr/local/www"',
	'mjpg_streamer -i "./input_uvc.so -r 1280x720 -q 70 -f 15 -d /dev/video0 -n" -o "./output_http.so -p 8082 -w /usr/local/www"',
	'mjpg_streamer -i "./input_uvc.so -r 1920x1080 -q 70 -f 15 -d /dev/video0 -n" -o "./output_http.so -p 8082 -w /usr/local/www"'
]

const snapAddress = `http://${IP}:${PORT}/?action=snapshot`;

var speed = 50; //运动速度
var videoStatus = 0;  // 1-流畅; 2-清晰; 3-高清, 默认清晰
const port = new SerialPort('/dev/ttyAMA0', {
	baudRate: 9600
})   //使用串口,与下位机机型通讯

port.write('main screen turn on', function (err) {
	if (err) {
		return console.log('Error on write: ', err.message)
	}
	console.log('message written')
})

// Open errors will be emitted as an error event
port.on('error', function (err) {
	console.log('Error: ', err.message)
})

// Switches the port into "flowing mode"
port.on('data', function (data) {
	console.log('Data:', data.toString())
})
 const buf1 = Buffer.alloc(1,1),  //前进
	buf2 = Buffer.alloc(1,2),  //后退
	buf3 = Buffer.alloc(1,3),  //伸张
	buf4 = Buffer.alloc(1,4),  //收缩
	buf5 = Buffer.alloc(1,5);  //停止

app.use(express.static(path.join(__dirname, 'public')));
app.use(bodyParse.json({ limit: '1mb' }));  //body-parser 解析json格式数据
app.use(bodyParse.urlencoded({extended:false}));
app.post('/VideoSet',(req,res) => {
	if(+req.body.Video === videoStatus) {
		res.end('Same Video Set');
		return;
	}
	switch (req.body.Video) {
		case "1": 
			openVideo(0);
			res.end('Success');
			break;
		case "2":
			openVideo(1);
			res.end('Success');
			break;
		case "3": 
			openVideo(2);
			res.end('Success');
			break;
	}
})
app.get('/capture', function (req, res) {
	download(snapAddress,'current.png',function(){
		res.send('Capture Sussess');
	})
}) 
app.get('/speed',function(req,res) {
	res.send({'speed':speed});
}) 
app.post('/speedSet',(req,res) => {
	speed = req.body.speed;
	res.end('Speed Set Success');
})

app.get('/VideoStatus', function(req, res){
	res.send(videoStatus.toString());
})

app.get('/',function(req,res){
	res.sendFile(__dirname + '/' + "index.html");
})
app.get('/up',function(req,res){
	console.log("up recieve");
	res.send('ok');
	port.write(buf1,'hex');
})
app.get('/down',function(req,res){
	console.log('down recieve');
	res.send('ok');
	port.write(buf2,'hex');
	
})
app.get('/stretch', function (req, res) {
	console.log('stretch');
	res.send('ok');
	port.write(buf3,'hex');

})
app.get('/shrink',function(req,res){
	console.log('shrink recieve');
	res.send('ok');
	port.write(buf4);
})
app.get('/stop',function(req,res){
	console.log('stop!!!');
	res.send('ok');
	port.write(buf5);
})
var server = app.listen(8081,function(){
	console.log("Server is running at 8081 port...");
});

function openVideo(qulity) {
	return new Promise((resolve,reject) => {
		if (shell.exec('pgrep mjpg_streamer').stdout !== '') {
			shell.exec('killall mjpg_streamer');
		}
		let command = videoCommand[qulity];
		shell.cd('~');
		shell.cd('mjpg-streamer/mjpg-streamer-experimental/')
		shell.exec(command, (code, std, err) => {
			console.log('Exit code:', code);
			console.log('Program output:', std);
			console.log('Program stderr:', err);
		})
		videoStatus = +qulity + 1;
		resolve('Success');
	})
}

function getIPAdress() {
	var interfaces = require('os').networkInterfaces();
	for (var devName in interfaces) {
		var iface = interfaces[devName];
		for (var i = 0; i < iface.length; i++) {
			var alias = iface[i];
			if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal) {
				return alias.address;
			}
		}
	}
}