OpenCV4Android中,主要用 org.opencv.android.JavaCameraView
(后面用JavaCameraView
指代)、org.opencv.android.NativeCameraView
(后面用NativeCameraView
指代)及 org.opencv.android.CameraBridgeViewBase
(后面用CameraBridgeViewBase
指代)这几个类将应用程序的逻辑与 Camera 的图像捕捉及处理后的图像显示逻辑联系起来的。这几个类的结构大致如下图所示的这样:
大体上可以认为,CameraBridgeViewBase
主要是借助于 SurfaceView
的功能,来完成图像显示相关的功能。而 JavaCameraView
则主要用于与 Camera 硬件设备进行交互。而从线程的角度来分析,则这里也是一种生产者消费这模型。生产者线程在
onPreviewFrame()
回调方法中将 camera preview 的一帧帧数据压缩进类型为 Mat 的缓冲区中。而消费者线程则取出这些 Mat 图像,先是传递给 app 注册的 listener 做一些特定于应用的图像处理操作,然后把处理后的图像显示出来。在此处,我们也主要从生产者-消费者模型的角度来分析相关这些类的实现。
生产者、消费者
对于生产者线程,我们可以简单的将它理解为,当 Camera preview 的一帧数据准备好了的时候,它会去调用 onPreviewFrame()
回调方法将一帧图像压缩进 Mat 的缓冲区。具体 onPreviewFrame()
回调方法的调用流程,则大致如下面的这个 backtrace 所示的那样:
由此我们不难理解,生产者这一角色其实是由应用程序的主线程来担任的。我们还可以看一下在 JavaCameraView.onPreviewFrame(byte[], Camera)
里面具体都做了些什么:
非常直接,将一帧 camera preview 数据压入 Mat 缓冲区中;通知在当前对象的等待队列上等待的线程;然后便是调用了 mCamera.addCallbackBuffer(mBuffer)
(后续会进一步解释这一行)。
接着我们来看消费者线程的前世今生。首先是消费者线程的创建及启动过程,在JavaCameraView.connectCamera()
方法中:
整个过程可以看作两步,首先是完成对 Camera 硬件的初始化,为下一步消费者线程的创建及启动创造条件;然后便是创建一个线程,即消费者线程,来执行整个的图像处理及显示循环。那这个方法又是如何一步步被调到的呢?或者换句话说,camera图像的处理及显示循环会在什么样的情况下被启动呢?搜一下这个函数的callers,只有 CameraBridgeViewBase.onEnterStartedState()
的一处:
这个函数的功能只是调用 connectCamera(getWidth(), getHeight())
+ 对于调用执行出错时的处理,即显示一个 Dialog 给用户一些提示,并在用户点击了 Dialog 上的 Button 时退出应用。继续向上追,onEnterStartedState()
也只有一个 caller,为CameraBridgeViewBase.processEnterState(int state)
:
这个函数处理状态的进入,进入启动状态,或者进入退出状态。将要启动 camera 图像的处理及显示循环的是其中的进入启动状态部分。由此我们也不难发现,在camera图像的处理及显示循环被启动之后,OpenCV4Android 的JavaCameraView(CameraBridgeViewBase)
还会通过app注册的
Listener(CvCameraViewListener2.onCameraViewStarted(int width, int height))
给app一个通知。这个方法也还是一个 private 方法,似乎依然没有追到那种让我们可以很容易理解的地步。继续向上追,在 CameraBridgeViewBase.checkCurrentState()
:
在这个方法中,会检查这个 View
当前的状态,然后退出老的状态,更新当前状态,并进入新的状态。当状态由 STOPPED
转入 STARTED
状态时,会去启动 camera 图像的处理及显示循环。可是究竟什么样的动作会改变这个 View
的状态,并触发这个状态的切换呢?可以查一下这个方法的 callers:
总结一下,系统在 SurfaceView(CameraBridgeViewBase/JavaCameraView)
的 surface状态发生改变时可能会去更新这个 View 的当前状态;同时也提供给 app 两个接口enableView()
和 disableView()
来主动的改变 View
的状态。当一个 View
进入了STARTED
状态时,camera 图像的处理及显示循环会被启动。
消费者线程的启动过程大体如此。那么在消费线程的处理循环中又都会做些什么样的事情呢?来看 JavaCameraView.CameraWorker
类的实现:
可以看到,一个执行周期大体上为:先是等待被另外一个线程唤醒,然后在线程没有被终止的情况下,由 Mat 缓冲中取出一个 Mat,交由 app 进行图像处理,并显示处理之后图像,最后更新 mChainIdx
的值,以使得生产者线程能够了解那块缓冲是已经被使用过了的。配合前面 JavaCameraView.onPreviewFrame(byte[], Camera)
一起来看,消费者线程总是会先去等待生产者线程的通知,onPreviewFrame()
压入一帧图像之后,会去唤醒消费者线程去处理并显示图像。mFrameChain
是一个有着两个元素的数组,mChainIdx
为将要被处理的那一帧图像的 index。粗略的来看 code,似乎是当 onPreviewFrame()
向 index 为 0 的 frame 中压入一帧数据后,它会通知消费者线程去取出 index 为 1 的 frame 来处理,而不是它刚刚压入了数据的 index 为 0 的 frame;而当它向 index 为 1 的 frame 中压入数据时,则它实际上是通知消费者线程去处理它上一次压入图像的 index 为 0 的数据。因而,尽管这里使用了 synchronized-block,但这实际上似乎是一种无锁的并发。
这种设计似乎还是有一些问题。比如,刚启动的时候,mChainIdx
为 0,消费者线程会先等待,生产者线程向 index 为 1 的一帧中压入数据,然后消费这线程被唤醒,去处理没有实际内容的 index 为 0 的那一帧,消费者线程可能还是要花一些时间,于是生产者线程可能又多次向 index 为 1 的一帧中压入数据。消费者线程在处理完之后,则会实际上去等待处理 index 为1的那一帧。但消费者线程具体是被生产者线程向 index 为 1 还是为 0 的那一帧中压入图像之后所通知的,还是有不同的可能。假设 onPreviewFrame()
压入一帧图像可能会花一些时间,消费者线程会在生产者线程在更新 index 为 1 的那一帧时,迅速将 mChainIdx 更新为 1,并进入等待状态,于是乎,消费者线程将要处理的那一帧将会是刚刚被 onPreviewFrame()
更新的那一帧;另外的可能是,onPreviewFrame()
先是发出了 notify,消费者线程丢失了这一次通知,onPreviewFrame()
会需要一段时间才会有下一帧数据到来,但消费者线程会一直执行,于是消费者线程会继续更新 mChainIdx
为 1,并进入等待状态,而当 onPreviewFrame()
下一次被调到时,则它将会去更新 index为 0 的那一帧,于是发生的 case 将是,onPreviewFrame()
更新 index 为 0 的那一帧之后通知消费者线程去处理 index 为 1 的那一帧。
总结一下,此处是一种无锁并发的设计,使用了生产者-消费者模型,生产者生产的速度快于消费者消费速度时的丢弃策略为,丢掉之前的数据。消费者线程总是会轮流去处理缓冲区中的那两帧,而具体生产者是在更新完哪一帧时去通知的消费者线程则具有颇多的不确定性。比如在上一段的分析中,消费者线程要处理 index 为 1 的那一帧时,既有可能是onPreviewFrame()
刚刚第二(N) 次更新 index 为 1 的那一帧之后通知的,也可能是刚刚更新了一次或多次 index 为 0 的那一帧。这种设计总还是会丢弃掉很多帧不去处理及显示,当然这也是这样的应用场景下,非常 reasonable 的一种丢弃策略,即丢掉那些不能及时处理的数据,而只处理最新的那些。
消费的执行周期中,最主要的还是对于 deliverAndDrawFrame()
的调用,都是在这个方法中对图像进行处理并绘制的嘛。接着我们就来看一下这个方法的实现:
前面唠叨了半天“图像的处理及绘制”,那“图像的处理”究竟指的是什么呢?实际上指的就是那个对于 listener 的回调方法 mListener.onCameraFrame(frame)
的调用。图像处理是应用程序的逻辑,因而这一职责也完全的是在应用程序端。图像处理完了之后呢?将 Mat 这种 android 不能直接处理的结构转化为 android 能直接处理的 Bitmap 结构喽。接下来便是绘制,将 Bitmap 绘制在 surface 的正中心位置,可能会放缩,也可能不会,具体要看mScale 的值。此处绘制的部分,也是充分体现了 SurfaceView
可以在非主线程中绘制的巨大优势。
那消费者线程又是在什么时候终止的呢?在回到这个问题之前,当然是要先回答另一个问题,就是消费者线程是如何被终止的?由 JavaCameraView.CameraWorker.run()
还是不难看出,是通过标记 mStopThread
的设置来终止的。稍作搜索,不难发现,是在JavaCameraView.disconnectCamera()
:
再来看一下这个方法被调用到时的调用栈:
对照前面调用 connectCamera()
的过程的分析,大概也不需要做过多的说明。
Camera
在 JavaCameraView
中,与 Camera 硬件的交互方式还是有一些独特性的。首先来看camera 的打开及初始化:
JavaCameraView
允许 app 指定是使用前置摄像头还是后置摄像头。当 app 指定了要使用哪一种(前置或后置)摄像头时,此处会遍历所有的摄像头,找到符合要求的, cameraIndex 最小的那个来打开;没有指定时,则会先尝试打开设备上的第一个 back-facing 的 camera,如果失败,就找能用的 camera 中 cameraIndex 最小的那个来打开。然后就是设置 camera 的 parameters。比较值得关注的,一是获取 preview frame 的方式:此处是先调用 mCamera.addCallbackBuffer(mBuffer)
,然后调用mCamera.setPreviewCallbackWithBuffer(this)
注册 preview 的 callback,这也就意味着camera 会把 preview 的数据填入我们传递给它的一个缓冲区中;同时我们注意到,在onPreviewFrame()
方法的最后会再次调用 mCamera.addCallbackBuffer(mBuffer)
,如果没有这个调用的话,camera 传回 preview 数据的过程将因没有数据缓冲区可用而中止。另一个值得我们关注的是 camera preview 所用 SurfaceTexture
的来源问题:在此处是手动创建了一个 SurfaceTexture
并设置给 camera 以用于 preview。但此处这个手动创建的 SurfaceTexture
是不能直接交给一个 TextureView
来显示的。当我们像下面这样直接将这个 SurfaceTexture
交给 TextureView
来显示时:
就会出现一个像下面这样的Exception:
在把 SurfaceTexture
交给 TextureView
显示之前,我们还需要先将SurfaceTexture
detach from GL context,像下面这样:
基本上就是这样。
Done.