一、少啰嗦,先看效果
画质比较渣,看的清就好
手机是通过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
的是什么东西,咱先按下不表,稍后来补充一下。
三、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
图的数据,所以我们只需将数据转为图片就好,那么问题来了,我们如何将数据转为图片?
再来看一下JPEG
的官方解释,又发现使用JFIF(Jpeg File Interchange Format)
来作为标准,通过这个标准,可以获悉数据里面哪些是标记码,再对数据进行处理
再再看一下我们获取的数据是长什么样,
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);
}
}
});
5、总结
在这个功能实现上,是耽误时间最久的,这一块涉及到不少知识,比如flutter中StreamController
的使用,如何请求一个mjpeg stream
,图像编码技术
,以及http的分块编码传输
,而基础知识不牢固,都是要现找资料学习。好在经过这次开发,也学习了不少东西,了解到基础知识的重要性。这个功能实现,就在筹备这篇文章,可能还有很多不足的地方,欢迎大家指正。
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!