CameraX学习研究

2019年的Google开发者大会上,Google发布了CameraX包,这个包是Camera1和Camera2的升级版。之前的Camera1由于性能差被大家诟病,Camera2作为升级版在拓展性上有了很大的提升,但是Api太多太难用,对于开发者来说属实头大。CameraX作为大哥推出,它不仅消除了前两个包在使用上的障碍,还与LifeCycler相结合,这样系统就可以自动管理相机的生命周期,我们就不需要去考虑什么时候释放相机。


配置CameraX

CameraX 由两个概念来完成实现 -- Camera View 和 Camera Core。Camera View 可被单独用于处理基本的相机要求,比如拍照,录视频,生命周期管理以及相机切换等。而核心库能够搭配 Camera View 处理更复杂的 CameraX 实现(比如在当前的相机上下文提供一个取景器)。

  • CameraView

    CameraView 给开发者提供了方法,使他们不需要太多困难就可以在 app 里提供基础的 camear 实现。我们能够在布局文件里直接添加这个组件。这个 CameraView 类是一个 ViewGroup,本质上包含了一个 TextureView 来显示 camera 流,以及配置这个组件的一些属性。

    这些 xml 属性既可以在布局文件里设置,也可以在代码里设置。所以,如果你想提供 UI 控件控制上面这些属性 ,你可以使用 ClickListener 来设置这些属性。

    既然我们是在 Activity 里布局的 CameraView,我们可以用 CameraView 的 bindToLifeCycle 方法将这个 View 与当前组件的生命周期绑定。

class MainActivity : AppCompatActivity() {     
    override fun onCreate(savedInstanceState: Bundle?) { 
        ...
        view_camera.bindToLifecycle(this) 
    }
}

​ 现在我们有了一个准备好了的简单的 CameraView 来捕获媒体。需要说明的是,CameraView 不能被单独扩展来提供更多的功能。CameraView 的目标是提供一个简化的可以方便地以 View 的形式使用的相机实现。如果你想要实现更多的功能,你需要使用 CameraX Core 库,我们将在另一篇文章里聊到它。

​ 完成了上面的配置,那就能够打开相机并且在屏幕上看到预览了。CameraView 提供了一些当用户操作 UI 时我们可以触发的方法。

​ 当要使用拍照功能时,takePicture 方法可以从相机捕获图片。这里我们需要提供一个图片数据保存位置的文件引用,以及一个在图片成功保存或者出现错误时使用的 Listener。

camera_view.takePicture(File("some_file_path"), 
                        object : ImageCaptureUseCase.OnImageSavedListener {                 override fun onImageSaved(file: File) {          
                            // 处理被保存的图片      
                        }                                                                 
    override fun onError(   error: ImageCaptureUseCase.UseCaseError,  
                        message: String,
                        throwable: Throwable?    ) {     
       // 处理错误      
   }   
       })

​ takePicture 还有另一种形式,这种形式只使用一个 OnImageCaptureListener 回调参数。这个回调用来监听图片被捕捉(或者出现了错误),然后开发者可以根据情况处理结果数据。前面的 takePicture 使用更简单,但这个 takePicture 提供了更多的灵活性。

camera_view.takePicture(
object : ImageCaptureUseCase.OnImageCapturedListener() {
        override fun onCaptureSuccess(
            image: ImageProxy, 
            rotationDegrees: Int
        ) {
            // 处理捕捉的图片
        }
        override fun onError(
            useCaseError: ImageCaptureUseCase.UseCaseError?, 
            message: String?, 
            cause: Throwable?
        ) {
            // 处理图片捕获错误
        }
    })

​ 也可以使用 CameraView 来录视频。这时候我们需要使用 startRecoring() 方法—只需要传递一个用来保存结果的文件引用,以及一个 来处理操作结果(成果或者失败)的 listener。

camera_view.startRecording(
    File("some_file_path"),   
    object : VideoCaptureUseCase.OnVideoSavedListener {
        override fun onVideoSaved(file: File?) {
            // Handle video saved
        }
        override fun onError(
            error: VideoCaptureUseCase.UseCaseError?, 
            message: String?, 
            throwable: Throwable?
        ) {
            // Handle video error
        }
    })

​ 可以看到,onVideSaved 方法给我们返回一个被保存的视频数据的文件实例。我们也有 onError 方法用来处理错误状态,在我们的 UI 上根据情况 作出对应的反馈。

​ 希望停止拍摄视频时,我们只需要调用 **stopRecording **方法让用例 停止拍摄。

​ 最后,当我们使用 CameraView 完毕后,我们必须通过调用CameraX.unBindAll()确保解绑相机,释放被用到的资源。

  • Camera Core

    核心库像我们带来了所谓用例的概念,这些用例实现特定的功能,可以用来简化常见摄像机要求的实现过程,在CameraX中已经有集中不同的用例实现:

    1.预览 - 用于为相机预览准备查找器。可以在Context中多次绑定。

    2.图像捕获 - 用于低延迟图像捕获,只能在Context中绑定一次。

    3.图像分析 - 用于对图像执行分析,可以在Context中多次绑定。


用例配置

每个用例类采用Config实例的形式进行配置,此接口用于定义一组通用功能,这些功能用于每个用例的每个子类。如果跳转到 Config 文件的源文件,会注意到有很多特定于库的定义。Config 类用于保存相应用例的配置详细信息的选项和值的集合,可以通过用例类操作这些值。

  • 预览用例 Preview

    这个用例可以用于在SurfaceTextureView中提供当前摄像机流的预览 - 然后,此视图可以连接到相应的TextureView,以在屏幕上显示摄像机内容。因此,首先需要向布局添加TextureView,以便能够容纳预览中的内容:

    <TextureView    
      android:id="@+id/preview"    
      android:layout_width="match_parent"    
      android:layout_height="match_parent" />
    

    在实现用例之前,我们将配置一些将用于查找器的选项。此配置采用Preview Config类的形式,我们将使用其Builder来配置用例的一些选项:

    val previewConfig = PreviewConfig.Builder().apply {
      setLensFacing(CameraX.LensFacing.FRONT)
    // configure other options
    }.build()
    

    在配置预览实例时,我们可以应用许多选项:

    • setTargetName() – 设置用于标识配置的唯一名称。
    • setTargetResolution() – 以预览的最小边界区域的形式设置目标分辨率。
    • setLensFacing() – 以LensFaceing值的形式设置用于取景的镜头,前置或后置。
    • setTargetAspectRatio() – 采用一个Rational实例定义用于图像的纵横比。
    • setTargetRotation() – 设置屏幕方向。

注意上述方式在最新的alpha09中已经废弃,需要通过perView.Builder()来构建,不需要使用previewConfig了

  • setCallbackHandler() – 提供一个处理CallBack的Handler。
    之后通过定义的PreViewConfig创建Preview类的新实例,并设置给实例一个OnPreviewOutputUpdateListener监听。
val previewUseCase = Preview(previewConfig)
previewUseCase.onPreviewOutputUpdateListener = Preview.OnPreviewOutputUpdateListener {
   val viewGroup = view_texture.parent as ViewGroup
   viewGroup.removeView(view_texture)
   viewGroup.addView(view_texture)
   view_texture.surfaceTexture = it.surfaceTexture
}

在上面的实例中可以看到将TextureView的surfaceTexture 指向布局中额度surfaceTexture 。这个surfaceTexture 包含相机源中的内容,因此在这里我们只是重新路由此输出以在Activity/Fragment中显示。removeView(view_texture)之后又addView(view_texture)是为了可以再次布局内容确保可以正确地显示。

我们在onPreviewOutputUpdateListener的回调中收到的 PreviewOutput是一组数据,通过提供的接口方法检索:

  • getSurfaceTexture() – 返回包含指定图像数据的SurfaceTexture实例

  • getTextureSize() –返回指定的SurfaceTexture的大小

  • getRotationDegrees() – 返回一个int值表示SurfaceTexture的旋转值


​ 通过阅读源码,会注意到在OnPreviewOutputUpdateListener 中回调了下面的updateTransform方法:

private fun updateTransform() {
    val matrix = Matrix()

    // Compute the center of the view finder
    val centerX = viewFinder.width / 2f
    val centerY = viewFinder.height / 2f

    // Correct preview output to account for display rotation
    val rotationDegrees = when(viewFinder.display.rotation) {
        Surface.ROTATION_0 -> 0
        Surface.ROTATION_90 -> 90
        Surface.ROTATION_180 -> 180
        Surface.ROTATION_270 -> 270
        else -> return
    }
    matrix.postRotate(-rotationDegrees.toFloat(), centerX, centerY)

    // Finally, apply transformations to our TextureView
    viewFinder.setTransform(matrix)
}

这段代码用于在设备方向更改时允许补偿,确保取景器保持直立位置。可以看到在代码中得到取景器的中心,利用取景器接受其显示属性的当前旋转值,然后使用上述计算的旋转来变换取景器。

虽然这有助于我们考虑设备上的这些方向更改,但是使用预览用例时我们需要考虑其他事项。在某些情况下,可能需要180的设备旋转,有的时候甚至会使用非方型的取景器,其中横纵比例会根据设备的方向发生变化。此时需要通过updateTransform()方法来处理这些需求:

private fun updateTransform(textureView: TextureView?, rotation: Int?, newBufferDimens: Size, newViewFinderDimens: Size) {

        val textureView = textureView ?: return

        if (rotation == viewFinderRotation &&
            Objects.equals(newBufferDimens, bufferDimens) &&
            Objects.equals(newViewFinderDimens, viewFinderDimens)) {
            // Nothing has changed, no need to transform output again
            return
        }

        if (rotation == null) {
            // Invalid rotation - wait for valid inputs before setting matrix
            return
        } else {
            // Update internal field with new inputs
            viewFinderRotation = rotation
        }

        if (newBufferDimens.width == 0 || newBufferDimens.height == 0) {
            // Invalid buffer dimens - wait for valid inputs before setting matrix
            return
        } else {
            // Update internal field with new inputs
            bufferDimens = newBufferDimens
        }

        if (newViewFinderDimens.width == 0 || newViewFinderDimens.height == 0) {
            // Invalid view finder dimens - wait for valid inputs before setting matrix
            return
        } else {
            // Update internal field with new inputs
            viewFinderDimens = newViewFinderDimens
        }

        val matrix = Matrix()

        // Compute the center of the view finder
        val centerX = viewFinderDimens.width / 2f
        val centerY = viewFinderDimens.height / 2f

        // Correct preview output to account for display rotation
        matrix.postRotate(-viewFinderRotation!!.toFloat(), centerX, centerY)

        // Buffers are rotated relative to the device's 'natural' orientation: swap width and height
        val bufferRatio = bufferDimens.height / bufferDimens.width.toFloat()

        val scaledWidth: Int
        val scaledHeight: Int
        // Match longest sides together -- i.e. apply center-crop transformation
        if (viewFinderDimens.width > viewFinderDimens.height) {
            scaledHeight = viewFinderDimens.width
            scaledWidth = Math.round(viewFinderDimens.width * bufferRatio)
        } else {
            scaledHeight = viewFinderDimens.height
            scaledWidth = Math.round(viewFinderDimens.height * bufferRatio)
        }

        // Compute the relative scale value
        val xScale = scaledWidth / viewFinderDimens.width.toFloat()
        val yScale = scaledHeight / viewFinderDimens.height.toFloat()

        // Scale input buffers to fill the view finder
        matrix.preScale(xScale, yScale, centerX, centerY)

        // Finally, apply transformations to our TextureView
        textureView.setTransform(matrix)
    }
  • updateTransform()可以帮助我们确保显示取景器内容。这个方法和之前的一个updateTransform()采用了类似的过程:

    ​ 1.检索取景器的尺寸和旋转

    ​ 2.计算取景器的中心

    ​ 3.根据计算的旋转角度旋转内容

    ​ 4.根据每个x轴和y轴的计算比例缩放取景器内容
    之后可以在OnPreviewOutputUpdateListener中调用任意的updateTransform()方法然后将生命周期绑定到application中。

  • 图像捕获 Image Capture

ImageCapture用例可以用于使用设备摄像机捕获图像,这个用例将拍摄照片并提供图像数据,我们只需要处理这些数据即可。和前面一样,先创建ImageCapture Config实例的形式用于ImageCapture

val imageCaptureConfig = ImageCaptureConfig.Builder().apply {
    setFlashMode(FlashMode.AUTO)
}.build()

在配置ImageCapture Config实例时,可以应用许多选项:

  • setFlashMode() - 使用FlashMode的值设置图像捕获的闪存状态,可以选择AUTO,ON,OFF

  • setLensFacing() - 设置用于捕获图像的摄像头,可以是FRONT或BACK

  • setCaptureMode() -设置图像捕获期间画面质量/延迟的优先级。这可以设置为MAX_QUAILTY(画质优先)或者MIN_LATENCY(低延迟)

  • setTargetAspectRatio() - 传入一个Rational实例,该实例将用于为通过此配置捕获的图像分配纵横比。

  • setTargetRotation() - 传入一个Surface的旋转值,该值将用于设置通过此配置捕获的图像旋转值。可以设置为Surface.ROTATION_0, Surface.ROTATION_90, Surface.ROTATION_180 or Surface.ROTATION_270。当设置这个值时,可以使用ViewFinder TextureView中的Display实例。这个Display持有一个rotation的引用,该属性可用于设置ImageCapture用例的目标旋转角度。

之后需要创建一个ImageCaptureUseCase的实例,传入刚刚定义的配置,和以前一样,我们将绑定到当前Context的生命周期。

val imageCaptureUseCase = ImageCapture(imageCaptureConfig)
CameraX.bindToLifecycle(this, imageCaptureUseCase)

至此图像捕获的用例就已经定义完成并且可供使用,可以继续使用它捕获图像。在这里可以调用takePicture方法,可以用于从相机捕获图像,需要传入一个文件引用路径,表示捕获的图像数据将传到哪里。还要传入一个映像保存成功或发生错误的监听器。

imageCaptureUseCase.takePicture(File("some_file_path"),
    object : ImageCaptureUseCase.OnImageSavedListener {

        override fun onImageSaved(file: File) {
            // Handle image saved
        }

        override fun onError(
            error: ImageCapture.UseCaseError,
            message: String,
            throwable: Throwable?
        ) {
            // Handle image error
        }
    })

这个方法存在一个替代的方法,可以传递一个ImageCapture.MetaData类的实例作为最后的参数,可以传递一些有关图像的额外详细信息:

​ location:有关图片地理位置的详细信息

​ isReveredHorizontal:表示图像是否水平反转

​ isReveredVerticial: 表示图像是否垂直反转

最后还有一种takePicture()方法,采用OnImageCaptureListener的实例作为唯一参数,可以用于监听捕获图像的时机或是否发生错误,然后处理相应的结果。虽然之前的takePicture()方法更简单,但是这种方法提供了更多的灵活性。

imageCaptureUseCase.takePicture(object : ImageCapture.OnImageCapturedListener() {
    override fun onCaptureSuccess(
        image: ImageProxy, 
        rotationDegrees: Int
    ) {
        // Handle image captured
    }

    override fun onError(
        error: ImageCapture.UseCaseError?, 
        message: String?, 
        cause: Throwable?
    ) {
        // Handle image capture error
    }
})

这种方法意味着和之前的方法不同,图像不会保存。完成后即可将相机绑定到LifeCycler。

CameraX.bindToLifecycle(this, viewFinderUseCase)
  • 图像分析 Image Analysis

图像分析用例可用于对相机源中显示的图像进行分析,这个用例允许我们从它扩展以便于创建我们自己的分析类。从而允许我们对摄像机介质执行特定的分析操作,与前面的用例一样,需要首先创建Config实例。

val imageAnalysisConfig = ImageAnalysisConfig.Builder().apply {
setImageReaderMode(
    ImageAnalysis.ImageReaderMode.ACQUIRE_LATEST_IMAGE)
}.build()
  • setImageReaderMode() - 设置用于从媒体队列获取图像进行分析的方法,可以取ACQUIRE_LATEST_IMAGE(使用媒体队列中的最新图像,同时丢弃任何比最新图像旧的图像),ACQUIRE_NEXT_IMAGE (使用媒体队列中下一个图像)。
  • setImageQueueDepth() - 设置媒体队列的长度
  • setLensFacing() 设置前置或后置相机。LensFacing_Facing / LensFacing_BACK。
  • setCallBackHandler() 提供一个handler用于处理回调

对于图像分析,必须创建自己的分析类,这个类从ImageAnalysis.Analyzer接口扩展,CameraX提供了一个analyze()方法 ,这个方法是我们必须要重写的一个方法,比如分析图像的亮度:

private class LuminosityAnalyzer : ImageAnalysis.Analyzer {
    private var lastAnalyzedTimestamp = 0L

    private fun ByteBuffer.toByteArray(): ByteArray {
        rewind()
        val data = ByteArray(remaining())
        get(data)
        return data
    }

    override fun analyze(image: ImageProxy, rotationDegrees: Int) {
        val currentTimestamp = System.currentTimeMillis()
        if (currentTimestamp - lastAnalyzedTimestamp >=
            TimeUnit.SECONDS.toMillis(1)) {
            val buffer = image.planes[0].buffer
            val data = buffer.toByteArray()
            val pixels = data.map { it.toInt() and 0xFF }
            val luma = pixels.average()
            Log.d("CameraXApp", "Average luminosity: $luma")
            lastAnalyzedTimestamp = currentTimestamp
        }
    }
}

创建好Anayzer之后可以将其指定给相机

val imageAnalysisUseCase = ImageAnalysis(imageAnalysisConfig).apply {
    analyzer = MyAnalyser()
}

之后将取景器绑定到应用程序当前的生命周期即可。

绑定多个用例

使用 CameraX 核心库时,我们不妨使用多个用例。例如,我们可能希望捕获图像,并针对捕获的媒体执行分析。为此,我们可以简单地定义用例并将它们全部绑定到当前生命周期:

CameraX.bindToLifecycle(this, imageCaptureUseCase, imageAnalysisUseCase)
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容