相对于租房,买房,装修监工等需求。以往的异地看房,只能通过现场拍摄的平面图片查看,但这种方式细节完全无法感受,不得不亲自跑一趟现成。所以现在越来越多的公司开始拓展全景看房的领域。
全景图片的消费端很简单,只要有个手机,有台电脑,既可以浏览全景图片,全景视频。而作为内容的生成端,那就麻烦多了。好在现在的全景相机厂商都提供了硬件以及硬件集成的接口,让第三方服务公司更好的拓展全景领域的创意与服务。我手上这台全景相机为
RICOH THETA SC2 for Business,官方也提供了接口文档,API的标注是符合OSC by Google标准的,此标注是属于webApi标准,可以直接使用http请求调用。
一、少啰嗦,先看效果
画质比较渣,看的清就好
手机是通过wifi连接的全景相机,可以捕获到相机的实时预览,并且在手机中呈全景展示,移动相机的时候,画面会实时变化,在手机上拖动的时候,可以展示不同的方向的画面。
二、相机API
1、操作介绍
相机是通过wifi功能连接手机,是把相机做为一共wifi热点让手机连接,所有请求相机的接口,可以直接请求http://192.168.1.1
相机的请求主要操作一共分为2类,Commands/Execute和Commands/Status,2个接口都属于POST类型,正如接口的命名,一个是进行操作,一个是查看操作的状态,我们可以直接发送POST请求来操作相机。比如对相机进行设置:
// 用大家熟悉的ajax举例
$.ajax(
type: 'POST',
url:'http://192.168.1.1/osc/commands/execute',
data: {
"name": "camera.setOptions",
"parameters": {
"options": {
"exposureProgram":1,
"iso":800,
"shutterSpeed":0.002
}
}
}
)
- name:你要执行的操作
- parameters: 执行操作的参数
2、getLivePreview
此功能主要使用了OSC的camera.getLivePreview接口,根据官方文档解释,此API在SC型号相机中只能在拍摄模式下使用,而且当触发了拍照功能或者更改拍摄模式,此接口都会停止
| 参数 | |
|---|---|
| Parameters | none |
| Output | Binary data of live view (MotionJPEG) |
可以看到,这个接口不需要填入任何的参数,直接调用而返回一个live view stream,这是一个实时的MJPEG数据流,你要问我这个非常像JPEG的是什么东西,咱先按下不表,稍后来补充一下。
M-JPEG是一种基于静态图像压缩技术JPEG发展起来的动态图像压缩技术,可以生成序列化的运动图像,每一帧都是一张JPEG图
三、Flutter实现
1、需要引入的包
实现此功能主要使用了2个插件,第一个是用来做接口请求,第二个用来做图片全景预览功能
- http: 0.12.2
- panorama: 0.3.1
你要问我为什么不用
Dio这个非常强大的请求工具,那是因为此接口返回的是一个live stream数据,需要一个持久连接的方法,http提供了client用来做持久连接,其中的send方法可以返回一个StreamedResponse,而在其他的请求库中没有找到,希望有懂的各位指点一下。
除了上面2个插件,还有3个官方的包也是必不可少的。
import 'dart:async'; // 异步操作
import 'dart:typed_data'; // 使用里面的Uint8List
import 'dart:convert'; // 转换JSON
2、声明2个Stream
既然请求的是一个数据流,就需要先声明一个StreamSubscription来监听这个数据,好进行控制。再声明一个StreamController将数据绘制到页面上
StreamSubscription vidoestream;
StreamController _streamController;
3、进行请求
该引入的都引入,改声明的都声明就可以开始请求和处理数据了。可以看到这个请求方法一共分为上下2部分,上半部分是用来做请求,下半部分用来将数据处理成图片
一、请求
- 我们首先需要一个
client的实例,才能调用send方法, - 而这个方法需要传一个基于
BaseRequest实例的参数,再声明一个final req = http.Request(); Request又需要传2个参数(String method, Uri uri)- 所以在声明一个
final uri = Uri.http('192.168.1.1', '/osc/commands/execute') - 最后使用
client.send发送请求 - 加了一个
timeout是做超时处理
void liveStream() async {
// 一、请求
final client = http.Client();
final uri = Uri.http('192.168.1.1', '/osc/commands/execute');
final params = {"name": "camera.getLivePreview"};
final req = http.Request('post', uri);
req.body = json.encode(params);
final res = await client.send(req).timeout(Duration(seconds: 5));
// 二、数据转换
const _trigger = 0xFF;
const _soi = 0xD8;
const _eoi = 0xD9;
//1、声明一个空的整型List
List<int> chunks = <int>[];
//2、订阅请求返回的数据流
vidoestream = res.stream.listen((List<int> data) async {
if(chunks.isEmpty) { // 判断当前的chunks,是否有数据
final startIndex = data.indexOf(_trigger); // 判断jpeg数据的开头标识, 将第一chunk插入进chunks
if(startIndex >=0 && startIndex+1 < data.length && data[startIndex +1] == _soi) {
final slicedData = data.sublist(startIndex, data.length);
//3、插入
chunks.addAll(slicedData);
}
} else {
final startIndex = data.lastIndexOf(_trigger); // 判断结束标识,插入最后一个chunk,表示一帧的数据完成
if( startIndex + 1 < data.length && data[startIndex + 1] == _eoi ) {
final slicedData = data.sublist(0, startIndex + 2);
//3、插入
chunks.addAll(slicedData);
//4 转换为图像后并add进stream
final imageMemory = MemoryImage(Uint8List.fromList(chunks));
await precacheImage(imageMemory, context);
_streamController.add(imageMemory);
//5 清空这一帧的数据
chunks = <int>[];
} else { // 既不是开头,也不是结尾,中间的chunk直接插入
//3、插入
chunks.addAll(data);
}
}
});
}
二、转换数据
对于不了解MJPEG的来说,这里可以说是最蒙圈的地方。
上面的注释中第3点有3个,就当成一个,我们来看这5个地方
- 1、List chunks = [];
- 2、res.stream.listen
- 3、chunks.addAll
- 4、_streamController.add(imageMemory)
- 5、chunks = []
这里其实比较容易理解,首先声明一个
List<int>用来存放图像的编码数据,在listen(监听)这个res.stream中数据,将每一帧的数据处理后用addall方法塞入chunk里面,这一帧的数据获取到后,我们就将数据转换为imageMemory,并插入进_streamController,当数据一直更新的时候,我们就可以进行实时预览。
三、插入页面
这里就比较简单,直接使用一个StreamBuilder的控件,里面在使用Panorama包裹住,功能就实现了。
StreamBuilder(
stream: _streamController.stream,
builder: (context, db) {
if(db.hasData) {
return Panorama(
child: Image(image: db.data),
);
}
return Text('没数据');
},
),
好的,完结撒花。
等等,哪有那么容易的,还有2个非常重要的问题:
_soi、_eoi是什么东东?- 为什么
chunks.addAll要使用3次? 我们继续往下看
四、核心原理了解
为了解决问题,只花了这2天看了看相关资料,就带大家了解一下,而不敢说讲解😂
1、MJPEG
先看一下官方解释,我们可以得知,我们回去的数据每帧都是一张JPEG图的数据,所以我们只需将数据转为图片就好,那么问题来了,我们如何将数据转为图片?
Motion JPEG(M-JPEG或MJPEG,Motion Joint Photographic Experts Group,FourCC:MJPG)是一种影像压缩格式,其中每一帧图像都分别使用JPEG编码。M-JPEG常用在数字相机和摄像头之类的图像采集设备上。MJPEG即动态JPEG,按照至少达到25帧/秒速度使用JPEG压缩算法压缩视频信号,完成动态视频的压缩。 再来看一下
JPEG的官方解释,又发现使用JFIF(Jpeg File Interchange Format)来作为标准,通过这个标准,可以获悉数据里面哪些是标记码,再对数据进行处理 JPEG委员会在制定JPEG标准时,定义了许多标记码(marker)或标记段(marker segments)组成,用来区分和识别图像数据及其相关信息。目前,使用比较广泛的是其交换格式JFIF(Jpeg File Interchange Format)。JPEG的每个标记码都是由2个字节组成,其前一个字节是固定值0xFF,每个标记码之前还可以添加数目不限的0xFF填充字节。JPEG文件中的字节是按照正序排列的,即高位字节在前,低位字节在后。 再再看一下我们获取的数据是长什么样,
JFIF主要标记码:
| 标记码 | 数值 | 描述 |
|---|---|---|
| SOI(start of image) | FFD8 | 图像开始 |
| EOI(end of image) | FFD9 | 图像结束 |
目前我们只需编码里面图像开发和结束的位置就好,就可以找到ff d8中前一个字节的索引,和ff d9后一个字节的索引,再将中间的数据截取出来,就是我们需要图形的数据
List<int> chunks = <int>[];
const _trigger = 0xFF;
const _soi = 0xD8;
const _eoi = 0xD9;
int startIndex = -1;
int endIndex = -1;
// data是我们请求的数据流
if(data[i] == _trigger && data[startIndex + 1] == _soi ) {
startIndex = i
}
if(data[i] == _trigger && data[startIndex + 1] == _eoi ) {
endIndex = i
}
chunks = data.sublist(startIndex, endIndex)
好的,那么我们又完成了这个功能。才怪嘞。
将这个数据塞入MemoryImage,将程序运行起来,在页面上显示,却发现每次的图像都是残缺的,而且控制台每次都报Invalid Image的错误。
通过print(startIndex)或者print(endIndex)会发现打印多次设定的初始值-1,出现一次正确的索引位置后,再打印多次-1,这样一直循环下去。说明每一帧的数据都不是完整的,被分成了多块,还需要将这些数据块合并起来才能得到一帧完整的图像。
2、Transfer-Encoding: chunked
通过查找资料了解了一般的mjpeg-streaming实现,还有flutter插件市场里http.dart的源码,发现在服务端和客户端都没有对数据进行过多的处理,那问题就是传输的过程中,我们通过打印http响应的报文,可以发现响应头是这样子。
| key | value |
|---|---|
| Connection | close |
| X-Content-Type-Options | nosniff |
| Content-Type | multipart/x-mixed-replace; boundary="---osclivepreview---" |
| Transfer-Encoding | chunked |
不了解http协议的话,就很容易忽视掉这里,其实的Transfer-Encoding: chunked翻译成中文,可以理解为分块传输编码,意思就是说传输大容量数据时,通过把数据分割成多块,能够让页面逐步显示页面。这种把实体主体分块的功能称为分块传输编码。
这样就能理解了,为什么打印传输的数据时,隔几次打印一次SOI(图像开发标识)和EOI(图像结束标识),那么只需要将数据进行拼接就好,回到前面的数据处理方法那里,再来看一下这个方法,可以说是完全理解了。
const _trigger = 0xFF; // 标识
const _soi = 0xD8; //图像开始
const _eoi = 0xD9; //图像结束
List<int> chunks = <int>[]; // 来保存每一帧的数据
vidoestream = res.stream.listen((List<int> data) async {
//判断当前的chunks,是否有数据
if(chunks.isEmpty) {
// 找到开头标识
final startIndex = data.indexOf(_trigger);
if(startIndex >=0 && startIndex+1 < data.length && data[startIndex +1] == _soi) {
// 从开始标识,到最后是有用的数据
final slicedData = data.sublist(startIndex, data.length);
chunks.addAll(slicedData);
}
} else {
// 找到结束标识,
final startIndex = data.lastIndexOf(_trigger);
if( startIndex + 1 < data.length && data[startIndex + 1] == _eoi ) {
// 从最开头,到结束标识是有用的数据
final slicedData = data.sublist(0, startIndex + 2);
// 插入,这一帧的数据就完成
chunks.addAll(slicedData);
final imageMemory = MemoryImage(Uint8List.fromList(chunks));
await precacheImage(imageMemory, context);
_streamController.add(imageMemory);
chunks = <int>[];
} else {
// 既不是开头,也不是结尾,就是中间的数据,都有用,插入
chunks.addAll(data);
}
}
});
日常的工作中,没怎么用过这个,完全忽视了这个东西,为了找数据在哪分块的,花费了大量时间研究。是相机给的数据就是分块的,还是
http.dart帮我分块了,最后发现在http协议里,也算学了不少东西。
5、总结
在这个功能实现上,是耽误时间最久的,这一块涉及到不少知识,比如flutter中StreamController的使用,如何请求一个mjpeg stream,图像编码技术,以及http的分块编码传输,而基础知识不牢固,都是要现找资料学习。好在经过这次开发,也学习了不少东西,了解到基础知识的重要性。这个功能实现,就在筹备这篇文章,可能还有很多不足的地方,欢迎大家指正。