Skip to content

Commit

Permalink
Add background blur to video calls in demo app (#943)
Browse files Browse the repository at this point in the history
Beta version
  • Loading branch information
liviu-timar authored Nov 29, 2023
1 parent b1f0ed9 commit 0d0d12b
Show file tree
Hide file tree
Showing 7 changed files with 217 additions and 56 deletions.
3 changes: 2 additions & 1 deletion demo-app/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/build
/build
!/libs/**
8 changes: 8 additions & 0 deletions demo-app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ android {
baseline = file("lint-baseline.xml")
}

packaging {
jniLibs.pickFirsts.add("lib/*/librenderscript-toolkit.so")
}

baselineProfile {
mergeIntoMain = true
}
Expand Down Expand Up @@ -260,6 +264,10 @@ dependencies {
implementation(libs.play.auth)
implementation(libs.play.app.update.ktx)

// Video Filters
implementation(libs.google.mlkit.selfie.segmentation)
implementation(files("libs/renderscript-toolkit.aar"))

// Memory detection
debugImplementation(libs.leakCanary)

Expand Down
Binary file added demo-app/libs/renderscript-toolkit.aar
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ import io.getstream.video.android.core.Call
import io.getstream.video.android.core.call.audio.AudioFilter
import io.getstream.video.android.core.call.video.BitmapVideoFilter
import io.getstream.video.android.ui.common.R
import io.getstream.video.android.util.BlurredBackgroundVideoFilter
import io.getstream.video.android.util.SampleAudioFilter
import io.getstream.video.android.util.SampleVideoFilter
import kotlinx.coroutines.launch
import java.nio.ByteBuffer

Expand Down Expand Up @@ -96,6 +96,38 @@ internal fun SettingsMenu(
.background(VideoTheme.colors.appBackground)
.padding(12.dp),
) {
Row(
modifier = Modifier.clickable {
if (call.videoFilter == null) {
call.videoFilter = object : BitmapVideoFilter() {
val filter = BlurredBackgroundVideoFilter()

override fun filter(bitmap: Bitmap) {
filter.applyFilter(bitmap)
}
}
} else {
call.videoFilter = null
}
},
) {
Icon(
painter = painterResource(
id = R.drawable.stream_video_ic_fullscreen_exit,
),
tint = VideoTheme.colors.textHighEmphasis,
contentDescription = null,
)

Text(
modifier = Modifier.padding(start = 20.dp),
text = "Toggle background blur (beta)",
color = VideoTheme.colors.textHighEmphasis,
)
}

Spacer(modifier = Modifier.height(12.dp))

Row(
modifier = Modifier.clickable {
onDismissed()
Expand Down Expand Up @@ -149,36 +181,6 @@ internal fun SettingsMenu(
Spacer(modifier = Modifier.height(12.dp))

if (showDebugOptions) {
Row(
modifier = Modifier.clickable {
if (call.videoFilter == null) {
call.videoFilter = object : BitmapVideoFilter() {
override fun filter(bitmap: Bitmap) {
SampleVideoFilter.toGrayscale(bitmap)
}
}
} else {
call.videoFilter = null
}
},
) {
Icon(
painter = painterResource(
id = R.drawable.stream_video_ic_fullscreen_exit,
),
tint = VideoTheme.colors.textHighEmphasis,
contentDescription = null,
)

Text(
modifier = Modifier.padding(start = 20.dp),
text = "Toggle video filter",
color = VideoTheme.colors.textHighEmphasis,
)
}

Spacer(modifier = Modifier.height(12.dp))

Row(
modifier = Modifier.clickable {
if (call.audioFilter == null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* Copyright (c) 2014-2023 Stream.io Inc. All rights reserved.
*
* Licensed under the Stream License;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/GetStream/stream-video-android/blob/main/LICENSE
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.getstream.video.android.util

import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Matrix
import com.google.android.gms.tasks.Tasks
import com.google.android.renderscript.Toolkit
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.segmentation.Segmentation
import com.google.mlkit.vision.segmentation.SegmentationMask
import com.google.mlkit.vision.segmentation.selfie.SelfieSegmenterOptions

/**
* Applies a blur effect to the background of a video frame.
*
* Note that this filter is still in beta and may not work as expected. To tweak it, see constants at bottom of file.
*
* To do:
* - For better performance research the [Android built-in accelerated image operations](https://developer.android.com/guide/topics/renderscript/migrate#image_blur_on_android_12_rendered_into_a_bitmap).
* - Determine what is available for which Android version (Toolkit library vs built-in operations).
*/
class BlurredBackgroundVideoFilter {
private val options =
SelfieSegmenterOptions.Builder()
.setDetectorMode(SelfieSegmenterOptions.STREAM_MODE)
.enableRawSizeMask()
.build()
private val segmenter = Segmentation.getClient(options)

private lateinit var segmentationMask: SegmentationMask
private val onlyBackgroundBitmap by lazy {
Bitmap.createBitmap(
segmentationMask.width,
segmentationMask.height,
Bitmap.Config.ARGB_8888,
)
}

fun applyFilter(bitmap: Bitmap) {
val mlImage = InputImage.fromBitmap(bitmap, 0)
val task = segmenter.process(mlImage)
segmentationMask = Tasks.await(task)

copySegment(
segment = Segment.BACKGROUND,
source = bitmap,
destination = onlyBackgroundBitmap,
segmentationMask = segmentationMask,
)

val blurredBackgroundBitmap = Toolkit.blur(onlyBackgroundBitmap, BLUR_RADIUS.toInt())
val canvas = Canvas(bitmap)
val matrix = newMatrix(bitmap, segmentationMask)

canvas.drawBitmap(blurredBackgroundBitmap, matrix, null)
}

private fun copySegment(
segment: Segment,
source: Bitmap,
destination: Bitmap,
segmentationMask: SegmentationMask,
) {
val scaleBetweenSourceAndMask = getScalingFactors(
widths = Pair(source.width, segmentationMask.width),
heights = Pair(source.height, segmentationMask.height),
)

segmentationMask.buffer.rewind()

val sourcePixels = IntArray(source.width * source.height)
source.getPixels(sourcePixels, 0, source.width, 0, 0, source.width, source.height)
val destinationPixels = IntArray(destination.width * destination.height)

for (y in 0 until segmentationMask.height) {
for (x in 0 until segmentationMask.width) {
val confidence = segmentationMask.buffer.float

if (((segment == Segment.BACKGROUND) && confidence.isBackground()) ||
((segment == Segment.FOREGROUND) && !confidence.isBackground())
) {
val scaledX = (x * scaleBetweenSourceAndMask.first).toInt()
val scaledY = (y * scaleBetweenSourceAndMask.second).toInt()
destinationPixels[y * destination.width + x] = sourcePixels[scaledY * source.width + scaledX]
}
}
}

destination.setPixels(
destinationPixels,
0,
destination.width,
0,
0,
destination.width,
destination.height,
)
}

private enum class Segment {
FOREGROUND, BACKGROUND
}

private fun getScalingFactors(widths: Pair<Int, Int>, heights: Pair<Int, Int>) =
Pair(widths.first.toFloat() / widths.second, heights.first.toFloat() / heights.second)

private fun newMatrix(bitmap: Bitmap, mask: SegmentationMask): Matrix {
val isRawSizeMaskEnabled = mask.width != bitmap.width || mask.height != bitmap.height
return if (!isRawSizeMaskEnabled) {
Matrix()
} else {
val scale =
getScalingFactors(Pair(bitmap.width, mask.width), Pair(bitmap.height, mask.height))
Matrix().apply { preScale(scale.first, scale.second) }
}
}
}

private fun Float.isBackground() = this <= BACKGROUND_UPPER_CONFIDENCE

private const val BACKGROUND_UPPER_CONFIDENCE = 0.999 // 1 is max confidence that pixel is in the foreground
private const val BLUR_RADIUS = 10f // Set the radius of the Blur. Supported range 0 < radius <= 25
6 changes: 5 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ landscapist = "2.2.11"
accompanist = "0.32.0"
telephoto = "0.3.0"
audioswitch = "1.1.8"
libyuv = "0.28.0"
libyuv = "0.30.0"

wire = "4.7.0"
okhttp = "4.12.0"
Expand Down Expand Up @@ -77,6 +77,8 @@ leakCanary = "2.12"
binaryCompatabilityValidator = "0.13.2"
playPublisher = "3.8.4"

googleMlKitSelfieSegmentation = "16.0.0-beta4"

[libraries]
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraCamera2" }
androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidxMaterial" }
Expand Down Expand Up @@ -192,6 +194,8 @@ play-app-update-ktx = { group = "com.google.android.play", name = "app-update-kt
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
leakCanary = { group = "com.squareup.leakcanary", name = "leakcanary-android", version.ref = "leakCanary" }

google-mlkit-selfie-segmentation = { group = "com.google.mlkit", name = "segmentation-selfie", version.ref = "googleMlKitSelfieSegmentation" }

# Dependencies of the included build-logic
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }
kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@
package io.getstream.video.android.core.call.video

import android.graphics.Bitmap
import android.graphics.Matrix
import io.getstream.log.taggedLogger
import io.github.crow_misia.libyuv.AbgrBuffer
import io.github.crow_misia.libyuv.I420Buffer
import io.github.crow_misia.libyuv.RotateMode
import org.webrtc.JniCommon
import org.webrtc.VideoFrame
import org.webrtc.YuvHelper
Expand Down Expand Up @@ -96,39 +96,47 @@ object YuvFrame {
}

private fun getBitmap(i420buffer: I420Buffer, width: Int, height: Int, rotationDegree: Int): Bitmap {
val newBuffer = AbgrBuffer.allocate(width, height)
i420buffer.convertTo(newBuffer)
val abgrBuffer = AbgrBuffer.allocate(width, height)
i420buffer.convertTo(abgrBuffer)
i420buffer.close()

// Construct a Bitmap based on the new pixel data
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
bitmap.copyPixelsFromBuffer(newBuffer.asBuffer())
newBuffer.close()

// If necessary, generate a rotated version of the Bitmap
return when (rotationDegree) {
var swapWidthAndHeight = false
val rotatedAbgrBuffer = when (rotationDegree) {
90, -270 -> {
val m = Matrix()
m.postRotate(90f)
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, m, true)
}
swapWidthAndHeight = true

val dstBuffer = AbgrBuffer.allocate(height, width)
abgrBuffer.rotate(dstBuffer, RotateMode.ROTATE_90)
dstBuffer
}
180, -180 -> {
val m = Matrix()
m.postRotate(180f)
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, m, true)
val dstBuffer = AbgrBuffer.allocate(width, height)
abgrBuffer.rotate(dstBuffer, RotateMode.ROTATE_180)
dstBuffer
}

270, -90 -> {
val m = Matrix()
m.postRotate(270f)
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, m, true)
}
swapWidthAndHeight = true

val dstBuffer = AbgrBuffer.allocate(height, width)
abgrBuffer.rotate(dstBuffer, RotateMode.ROTATE_270)
dstBuffer
}
else -> {
// Don't rotate, just return the Bitmap
bitmap
abgrBuffer
}
}

// Construct a Bitmap based on the new pixel data
val bitmap = Bitmap.createBitmap(
if (swapWidthAndHeight) height else width,
if (swapWidthAndHeight) width else height,
Bitmap.Config.ARGB_8888,
)
bitmap.copyPixelsFromBuffer(rotatedAbgrBuffer.asBuffer())
abgrBuffer.close()
rotatedAbgrBuffer.close()

return bitmap
}
}

0 comments on commit 0d0d12b

Please sign in to comment.