【MJPEG】Flutte开发全景相机

2,715 阅读9分钟

相对于租房,买房,装修监工等需求。以往的异地看房,只能通过现场拍摄的平面图片查看,但这种方式细节完全无法感受,不得不亲自跑一趟现成。所以现在越来越多的公司开始拓展全景看房的领域。

全景图片的消费端很简单,只要有个手机,有台电脑,既可以浏览全景图片,全景视频。而作为内容的生成端,那就麻烦多了。好在现在的全景相机厂商都提供了硬件以及硬件集成的接口,让第三方服务公司更好的拓展全景领域的创意与服务。我手上这台全景相机为RICOH THETA SC2 for Business,官方也提供了接口文档,API的标注是符合OSC by Google标准的,此标注是属于webApi标准,可以直接使用http请求调用。

一、少啰嗦,先看效果

画质比较渣,看的清就好

手机是通过wifi连接的全景相机,可以捕获到相机的实时预览,并且在手机中呈全景展示,移动相机的时候,画面会实时变化,在手机上拖动的时候,可以展示不同的方向的画面。

二、相机API

1、操作介绍

相机是通过wifi功能连接手机,是把相机做为一共wifi热点让手机连接,所有请求相机的接口,可以直接请求http://192.168.1.1

相机的请求主要操作一共分为2类,Commands/ExecuteCommands/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型号相机中只能在拍摄模式下使用,而且当触发了拍照功能或者更改拍摄模式,此接口都会停止

参数
Parametersnone
OutputBinary 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响应的报文,可以发现响应头是这样子。

keyvalue
Connectionclose
X-Content-Type-Optionsnosniff
Content-Typemultipart/x-mixed-replace; boundary="---osclivepreview---"
Transfer-Encodingchunked

不了解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的分块编码传输,而基础知识不牢固,都是要现找资料学习。好在经过这次开发,也学习了不少东西,了解到基础知识的重要性。这个功能实现,就在筹备这篇文章,可能还有很多不足的地方,欢迎大家指正。