Create a spotlight effect with CameraX and Jetpack Compose

Jolanda Verhoef on 2025-01-17

Create a spotlight effect with CameraX and Jetpack Compose

Part 3 of Unlocking the Power of CameraX in Jetpack Compose

Hey there! Welcome back to our series on CameraX and Jetpack Compose. In the previous posts, we’ve covered the fundamentals of setting up a camera preview and added tap-to-focus functionality.

Enable face detection

First, let’s modify the CameraPreviewViewModel to enable face detection. We’ll use the Camera2Interop API, which allows us to interact with the underlying Camera2 API from CameraX. This gives us the opportunity to use camera features that are not exposed by CameraX directly. We need to make the following changes:

With these changes in place, our view model now emits a list of Rectobjects representing the bounding boxes of detected faces in sensor coordinates.

Translate sensor coordinates to UI coordinates

The bounding boxes of detected faces that we stored in the last section use coordinates in the sensor coordinate system. To draw the bounding boxes in our UI, we need to transform these coordinates so that they are correct in the Compose coordinate system. We need to:

private fun List<Rect>.transformToUiCoords(
    transformationInfo: SurfaceRequest.TransformationInfo?,
    uiToBufferCoordinateTransformer: MutableCoordinateTransformer
): List<Rect> = this.map { sensorRect ->
    val bufferToUiTransformMatrix = Matrix().apply {
        setFrom(uiToBufferCoordinateTransformer.transformMatrix)
        invert()
    }

    val sensorToBufferTransformMatrix = Matrix().apply {
        transformationInfo?.let {
            setFrom(it.sensorToBufferTransform)
        }
    }

    val bufferRect = sensorToBufferTransformMatrix.map(sensorRect)
    val uiRect = bufferToUiTransformMatrix.map(bufferRect)

    uiRect
}

Now, let’s update the CameraPreviewContent composable to draw the spotlight effect. We’ll use a Canvas composable to draw a gradient mask over the preview, making the detected faces visible:

@Composable
fun CameraPreviewContent(
    viewModel: CameraPreviewViewModel,
    modifier: Modifier = Modifier,
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current
) {
    val surfaceRequest by viewModel.surfaceRequest.collectAsStateWithLifecycle()
    val sensorFaceRects by viewModel.sensorFaceRects.collectAsStateWithLifecycle()
    val transformationInfo by
        produceState<SurfaceRequest.TransformationInfo?>(null, surfaceRequest) {
            try {
                surfaceRequest?.setTransformationInfoListener(Runnable::run) { transformationInfo ->
                    value = transformationInfo
                }
                awaitCancellation()
            } finally {
                surfaceRequest?.clearTransformationInfoListener()
            }
        }
    val shouldSpotlightFaces by remember {
        derivedStateOf { sensorFaceRects.isNotEmpty() && transformationInfo != null} 
    }
    val spotlightColor = Color(0xDDE60991)
    ..

    surfaceRequest?.let { request ->
        val coordinateTransformer = remember { MutableCoordinateTransformer() }
        CameraXViewfinder(
            surfaceRequest = request,
            coordinateTransformer = coordinateTransformer,
            modifier = ..
        )

        AnimatedVisibility(shouldSpotlightFaces, enter = fadeIn(), exit = fadeOut()) {
            Canvas(Modifier.fillMaxSize()) {
                val uiFaceRects = sensorFaceRects.transformToUiCoords(
                    transformationInfo = transformationInfo,
                    uiToBufferCoordinateTransformer = coordinateTransformer
                )

                // Fill the whole space with the color
                drawRect(spotlightColor)
                // Then extract each face and make it transparent

                uiFaceRects.forEach { faceRect ->
                    drawRect(
                        Brush.radialGradient(
                            0.4f to Color.Black, 1f to Color.Transparent,
                            center = faceRect.center,
                            radius = faceRect.minDimension * 2f,
                        ),
                        blendMode = BlendMode.DstOut
                    )
                }
            }
        }
    }
}

Here’s how it works:

Result

With this code, we have a fully functional spotlight effect that highlights detected faces. You can find the full code snippet here.

This effect is just the beginning — by using the power of Compose, you can create a myriad of visually stunning camera experiences. Being able to transform sensor and buffer coordinates into Compose UI coordinates and back means we can utilize all Compose UI features and integrate them seamlessly with the underlying camera system. With animations, advanced UI graphics, simple UI state management, and full gesture control, your imagination is the limit!

In the final post of the series, we’ll dive into how to use adaptive APIs and the Compose animation framework to seamlessly transition between different camera UIs on foldable devices. Stay tuned!

The code snippets in this blog have the following license:

// Copyright 2024 Google LLC. SPDX-License-Identifier: Apache-2.0

Many thanks to Nick Butcher, Alex Vanyo, Trevor McGuire, Don Turner and Lauren Ward for reviewing and providing feedback. Made possible by the hard work of Yasith Vidanaarachch.