From 0ab7bafa87d6bb22adba34f718cd6e1665e466f8 Mon Sep 17 00:00:00 2001 From: tofunmi Date: Fri, 22 Dec 2023 14:10:12 -0800 Subject: [PATCH] Migrate Gaussian Blur Effect to media3. PiperOrigin-RevId: 593164068 --- .../media3/effect/EffectsTestUtil.java | 211 +++++++++ .../androidx/media3/effect/FrameDropTest.java | 222 ++------- .../media3/effect/GaussianBlurTest.java | 102 ++++ ...ment_shader_separable_convolution_es2.glsl | 105 +++++ .../media3/effect/ConvolutionFunction1D.java | 41 ++ .../androidx/media3/effect/GaussianBlur.java | 61 +++ .../media3/effect/GaussianFunction.java | 71 +++ .../media3/effect/SeparableConvolution.java | 57 +++ .../SeparableConvolutionShaderProgram.java | 446 ++++++++++++++++++ .../media3/effect/GaussianFunctionTest.java | 46 ++ .../bitmap/GaussianBlurTest/pts_32000.png | Bin 0 -> 14683 bytes 11 files changed, 1187 insertions(+), 175 deletions(-) create mode 100644 libraries/effect/src/androidTest/java/androidx/media3/effect/EffectsTestUtil.java create mode 100644 libraries/effect/src/androidTest/java/androidx/media3/effect/GaussianBlurTest.java create mode 100644 libraries/effect/src/main/assets/shaders/fragment_shader_separable_convolution_es2.glsl create mode 100644 libraries/effect/src/main/java/androidx/media3/effect/ConvolutionFunction1D.java create mode 100644 libraries/effect/src/main/java/androidx/media3/effect/GaussianBlur.java create mode 100644 libraries/effect/src/main/java/androidx/media3/effect/GaussianFunction.java create mode 100644 libraries/effect/src/main/java/androidx/media3/effect/SeparableConvolution.java create mode 100644 libraries/effect/src/main/java/androidx/media3/effect/SeparableConvolutionShaderProgram.java create mode 100644 libraries/effect/src/test/java/androidx/media3/effect/GaussianFunctionTest.java create mode 100644 libraries/test_data/src/test/assets/media/bitmap/GaussianBlurTest/pts_32000.png diff --git a/libraries/effect/src/androidTest/java/androidx/media3/effect/EffectsTestUtil.java b/libraries/effect/src/androidTest/java/androidx/media3/effect/EffectsTestUtil.java new file mode 100644 index 0000000000..496f0f576d --- /dev/null +++ b/libraries/effect/src/androidTest/java/androidx/media3/effect/EffectsTestUtil.java @@ -0,0 +1,211 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * 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 androidx.media3.effect; + +import static androidx.media3.common.VideoFrameProcessor.INPUT_TYPE_SURFACE; +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.test.utils.BitmapPixelTestUtil.MAXIMUM_AVERAGE_PIXEL_ABSOLUTE_DIFFERENCE; +import static androidx.media3.test.utils.BitmapPixelTestUtil.getBitmapAveragePixelAbsoluteDifferenceArgb8888; +import static androidx.media3.test.utils.BitmapPixelTestUtil.maybeSaveTestBitmap; +import static androidx.media3.test.utils.BitmapPixelTestUtil.readBitmap; +import static androidx.test.core.app.ApplicationProvider.getApplicationContext; +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import android.graphics.Bitmap; +import android.text.SpannableString; +import androidx.annotation.Nullable; +import androidx.media3.common.ColorInfo; +import androidx.media3.common.DebugViewProvider; +import androidx.media3.common.Effect; +import androidx.media3.common.FrameInfo; +import androidx.media3.common.VideoFrameProcessingException; +import androidx.media3.common.VideoFrameProcessor; +import androidx.media3.common.util.Consumer; +import androidx.media3.common.util.NullableType; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; +import androidx.media3.test.utils.TextureBitmapReader; +import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.MoreExecutors; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +/** Utilities for effects tests. */ +@UnstableApi +/* package */ class EffectsTestUtil { + + /** + * Gets the {@link Bitmap}s (generated at the timestamps in {@code presentationTimesUs}) from the + * {@link TextureBitmapReader}, and asserts that they are equal to files stored in the {@code + * goldenFileAssetPath} with the same {@code testId}. + * + *

Tries to save the {@link Bitmap}s as PNGs to the {@link Context#getCacheDir() cache + * directory}. + */ + public static void getAndAssertOutputBitmaps( + TextureBitmapReader textureBitmapReader, + List presentationTimesUs, + String testId, + String goldenFileAssetPath) + throws IOException { + for (int i = 0; i < presentationTimesUs.size(); i++) { + long presentationTimeUs = presentationTimesUs.get(i); + Bitmap actualBitmap = textureBitmapReader.getBitmapAtPresentationTimeUs(presentationTimeUs); + Bitmap expectedBitmap = + readBitmap( + Util.formatInvariant("%s/pts_%d.png", goldenFileAssetPath, presentationTimeUs)); + maybeSaveTestBitmap( + testId, String.valueOf(presentationTimeUs), actualBitmap, /* path= */ null); + float averagePixelAbsoluteDifference = + getBitmapAveragePixelAbsoluteDifferenceArgb8888(expectedBitmap, actualBitmap, testId); + assertThat(averagePixelAbsoluteDifference) + .isAtMost(MAXIMUM_AVERAGE_PIXEL_ABSOLUTE_DIFFERENCE); + } + } + + /** + * Generates and processes a frame for each timestamp in {@code presentationTimesUs} through a + * {@link DefaultVideoFrameProcessor}, applying the given {@link GlEffect}, and outputting the + * resulting frame to the provided {@link TextureBitmapReader}. + * + *

The generated frames have their associated timestamps overlaid on them. + * + * @param frameWidth The width of the generated frames. + * @param frameHeight The height of the generated frames. + * @param presentationTimesUs The timestamps of the generated frames, in microseconds. + * @param glEffect The effect to apply to the frames. + * @param textSpanConsumer A {@link Consumer} used to set the spans that styles the text overlaid + * onto the frames. + */ + // MoreExecutors.directExecutor() pattern is consistent with our codebase. + @SuppressWarnings("StaticImportPreferred") + public static ImmutableList generateAndProcessFrames( + int frameWidth, + int frameHeight, + List presentationTimesUs, + GlEffect glEffect, + TextureBitmapReader textureBitmapReader, + Consumer textSpanConsumer) + throws Exception { + ImmutableList.Builder actualPresentationTimesUs = new ImmutableList.Builder<>(); + @Nullable DefaultVideoFrameProcessor defaultVideoFrameProcessor = null; + + try { + AtomicReference<@NullableType VideoFrameProcessingException> + videoFrameProcessingExceptionReference = new AtomicReference<>(); + BlankFrameProducer blankFrameProducer = new BlankFrameProducer(frameWidth, frameHeight); + CountDownLatch videoFrameProcessorReadyCountDownLatch = new CountDownLatch(1); + CountDownLatch videoFrameProcessingEndedCountDownLatch = new CountDownLatch(1); + + defaultVideoFrameProcessor = + checkNotNull( + new DefaultVideoFrameProcessor.Factory.Builder() + .setTextureOutput( + (textureProducer, outputTexture, presentationTimeUs, token) -> { + checkNotNull(textureBitmapReader) + .readBitmap(outputTexture, presentationTimeUs); + textureProducer.releaseOutputTexture(presentationTimeUs); + }, + /* textureOutputCapacity= */ 1) + .build() + .create( + getApplicationContext(), + DebugViewProvider.NONE, + /* outputColorInfo= */ ColorInfo.SDR_BT709_LIMITED, + /* renderFramesAutomatically= */ true, + MoreExecutors.directExecutor(), + new VideoFrameProcessor.Listener() { + @Override + public void onInputStreamRegistered( + @VideoFrameProcessor.InputType int inputType, + List effects, + FrameInfo frameInfo) { + videoFrameProcessorReadyCountDownLatch.countDown(); + } + + @Override + public void onOutputSizeChanged(int width, int height) {} + + @Override + public void onOutputFrameAvailableForRendering(long presentationTimeUs) { + actualPresentationTimesUs.add(presentationTimeUs); + } + + @Override + public void onError(VideoFrameProcessingException exception) { + videoFrameProcessingExceptionReference.set(exception); + videoFrameProcessorReadyCountDownLatch.countDown(); + videoFrameProcessingEndedCountDownLatch.countDown(); + } + + @Override + public void onEnded() { + videoFrameProcessingEndedCountDownLatch.countDown(); + } + })); + + defaultVideoFrameProcessor.getTaskExecutor().submit(blankFrameProducer::configureGlObjects); + // A frame needs to be registered despite not queuing any external input to ensure + // that the video frame processor knows about the stream offset. + checkNotNull(defaultVideoFrameProcessor) + .registerInputStream( + INPUT_TYPE_SURFACE, + /* effects= */ ImmutableList.of( + (GlEffect) (context, useHdr) -> blankFrameProducer, + // Use an overlay effect to generate bitmaps with timestamps on it. + new OverlayEffect( + ImmutableList.of( + new TextOverlay() { + @Override + public SpannableString getText(long presentationTimeUs) { + SpannableString text = + new SpannableString(String.valueOf(presentationTimeUs)); + textSpanConsumer.accept(text); + return text; + } + })), + glEffect), + new FrameInfo.Builder(ColorInfo.SDR_BT709_LIMITED, frameWidth, frameHeight).build()); + videoFrameProcessorReadyCountDownLatch.await(); + checkNoVideoFrameProcessingExceptionIsThrown(videoFrameProcessingExceptionReference); + blankFrameProducer.produceBlankFrames(presentationTimesUs); + defaultVideoFrameProcessor.signalEndOfInput(); + videoFrameProcessingEndedCountDownLatch.await(); + checkNoVideoFrameProcessingExceptionIsThrown(videoFrameProcessingExceptionReference); + } finally { + if (defaultVideoFrameProcessor != null) { + defaultVideoFrameProcessor.release(); + } + } + return actualPresentationTimesUs.build(); + } + + private static void checkNoVideoFrameProcessingExceptionIsThrown( + AtomicReference<@NullableType VideoFrameProcessingException> + videoFrameProcessingExceptionReference) + throws Exception { + @Nullable + Exception videoFrameProcessingException = videoFrameProcessingExceptionReference.get(); + if (videoFrameProcessingException != null) { + throw videoFrameProcessingException; + } + } + + private EffectsTestUtil() {} +} diff --git a/libraries/effect/src/androidTest/java/androidx/media3/effect/FrameDropTest.java b/libraries/effect/src/androidTest/java/androidx/media3/effect/FrameDropTest.java index 0a3d067fa4..2be1e91bac 100644 --- a/libraries/effect/src/androidTest/java/androidx/media3/effect/FrameDropTest.java +++ b/libraries/effect/src/androidTest/java/androidx/media3/effect/FrameDropTest.java @@ -15,43 +15,24 @@ */ package androidx.media3.effect; -import static androidx.media3.common.VideoFrameProcessor.INPUT_TYPE_SURFACE; import static androidx.media3.common.util.Assertions.checkNotNull; -import static androidx.media3.test.utils.BitmapPixelTestUtil.MAXIMUM_AVERAGE_PIXEL_ABSOLUTE_DIFFERENCE; -import static androidx.media3.test.utils.BitmapPixelTestUtil.getBitmapAveragePixelAbsoluteDifferenceArgb8888; -import static androidx.media3.test.utils.BitmapPixelTestUtil.maybeSaveTestBitmap; -import static androidx.media3.test.utils.BitmapPixelTestUtil.readBitmap; -import static androidx.test.core.app.ApplicationProvider.getApplicationContext; +import static androidx.media3.effect.EffectsTestUtil.generateAndProcessFrames; +import static androidx.media3.effect.EffectsTestUtil.getAndAssertOutputBitmaps; import static com.google.common.truth.Truth.assertThat; -import android.graphics.Bitmap; import android.graphics.Color; import android.text.Spannable; import android.text.SpannableString; import android.text.style.AbsoluteSizeSpan; import android.text.style.ForegroundColorSpan; import android.text.style.TypefaceSpan; -import androidx.annotation.Nullable; -import androidx.media3.common.ColorInfo; -import androidx.media3.common.DebugViewProvider; -import androidx.media3.common.Effect; -import androidx.media3.common.FrameInfo; -import androidx.media3.common.VideoFrameProcessingException; -import androidx.media3.common.VideoFrameProcessor; -import androidx.media3.common.util.NullableType; -import androidx.media3.common.util.Util; +import androidx.media3.common.util.Consumer; import androidx.media3.test.utils.TextureBitmapReader; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.common.collect.ImmutableList; -import com.google.common.util.concurrent.MoreExecutors; -import java.io.IOException; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicReference; import org.checkerframework.checker.nullness.qual.EnsuresNonNull; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.RequiresNonNull; -import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -64,12 +45,9 @@ public class FrameDropTest { @Rule public final TestName testName = new TestName(); private static final String ASSET_PATH = "media/bitmap/FrameDropTest"; - private static final int BLANK_FRAME_WIDTH = 100; - private static final int BLANK_FRAME_HEIGHT = 50; - private @MonotonicNonNull String testId; private @MonotonicNonNull TextureBitmapReader textureBitmapReader; - private @MonotonicNonNull DefaultVideoFrameProcessor defaultVideoFrameProcessor; + private @MonotonicNonNull String testId; @EnsuresNonNull({"textureBitmapReader", "testId"}) @Before @@ -78,24 +56,20 @@ public class FrameDropTest { testId = testName.getMethodName(); } - @After - public void tearDown() { - checkNotNull(defaultVideoFrameProcessor).release(); - } - @Test @RequiresNonNull({"textureBitmapReader", "testId"}) public void frameDrop_withDefaultStrategy_outputsFramesAtTheCorrectPresentationTimesUs() throws Exception { ImmutableList frameTimesUs = ImmutableList.of(0L, 16_000L, 32_000L, 48_000L, 58_000L, 71_000L, 86_000L); + FrameDropEffect frameDropEffect = + FrameDropEffect.createDefaultFrameDropEffect(/* targetFrameRate= */ 30); ImmutableList actualPresentationTimesUs = - processFramesToEndOfStream( - frameTimesUs, FrameDropEffect.createDefaultFrameDropEffect(/* targetFrameRate= */ 30)); + generateAndProcessBlackTimeStampedFrames(frameTimesUs, frameDropEffect); assertThat(actualPresentationTimesUs).containsExactly(0L, 32_000L, 71_000L).inOrder(); - getAndAssertOutputBitmaps(textureBitmapReader, actualPresentationTimesUs, testId); + getAndAssertOutputBitmaps(textureBitmapReader, actualPresentationTimesUs, testId, ASSET_PATH); } @Test @@ -104,162 +78,60 @@ public class FrameDropTest { throws Exception { ImmutableList frameTimesUs = ImmutableList.of(0L, 250_000L, 500_000L, 750_000L, 1_000_000L, 1_500_000L); + FrameDropEffect frameDropEffect = + FrameDropEffect.createSimpleFrameDropEffect( + /* expectedFrameRate= */ 6, /* targetFrameRate= */ 2); ImmutableList actualPresentationTimesUs = - processFramesToEndOfStream( - frameTimesUs, - FrameDropEffect.createSimpleFrameDropEffect( - /* expectedFrameRate= */ 6, /* targetFrameRate= */ 2)); + generateAndProcessBlackTimeStampedFrames(frameTimesUs, frameDropEffect); assertThat(actualPresentationTimesUs).containsExactly(0L, 750_000L).inOrder(); - getAndAssertOutputBitmaps(textureBitmapReader, actualPresentationTimesUs, testId); + getAndAssertOutputBitmaps(textureBitmapReader, actualPresentationTimesUs, testId, ASSET_PATH); } @Test @RequiresNonNull({"textureBitmapReader", "testId"}) public void frameDrop_withSimpleStrategy_outputsAllFrames() throws Exception { ImmutableList frameTimesUs = ImmutableList.of(0L, 333_333L, 666_667L); + FrameDropEffect frameDropEffect = + FrameDropEffect.createSimpleFrameDropEffect( + /* expectedFrameRate= */ 3, /* targetFrameRate= */ 3); ImmutableList actualPresentationTimesUs = - processFramesToEndOfStream( - frameTimesUs, - FrameDropEffect.createSimpleFrameDropEffect( - /* expectedFrameRate= */ 3, /* targetFrameRate= */ 3)); + generateAndProcessBlackTimeStampedFrames(frameTimesUs, frameDropEffect); assertThat(actualPresentationTimesUs).containsExactly(0L, 333_333L, 666_667L).inOrder(); - getAndAssertOutputBitmaps(textureBitmapReader, actualPresentationTimesUs, testId); + getAndAssertOutputBitmaps(textureBitmapReader, actualPresentationTimesUs, testId, ASSET_PATH); } - private static void getAndAssertOutputBitmaps( - TextureBitmapReader textureBitmapReader, List presentationTimesUs, String testId) - throws IOException { - for (int i = 0; i < presentationTimesUs.size(); i++) { - long presentationTimeUs = presentationTimesUs.get(i); - Bitmap actualBitmap = textureBitmapReader.getBitmapAtPresentationTimeUs(presentationTimeUs); - Bitmap expectedBitmap = - readBitmap(Util.formatInvariant("%s/pts_%d.png", ASSET_PATH, presentationTimeUs)); - maybeSaveTestBitmap( - testId, String.valueOf(presentationTimeUs), actualBitmap, /* path= */ null); - float averagePixelAbsoluteDifference = - getBitmapAveragePixelAbsoluteDifferenceArgb8888(expectedBitmap, actualBitmap, testId); - assertThat(averagePixelAbsoluteDifference) - .isAtMost(MAXIMUM_AVERAGE_PIXEL_ABSOLUTE_DIFFERENCE); - } - } - - @EnsuresNonNull("defaultVideoFrameProcessor") - private ImmutableList processFramesToEndOfStream( - List inputPresentationTimesUs, FrameDropEffect frameDropEffect) throws Exception { - AtomicReference<@NullableType VideoFrameProcessingException> - videoFrameProcessingExceptionReference = new AtomicReference<>(); - BlankFrameProducer blankFrameProducer = - new BlankFrameProducer(BLANK_FRAME_WIDTH, BLANK_FRAME_HEIGHT); - CountDownLatch videoFrameProcessorReadyCountDownLatch = new CountDownLatch(1); - CountDownLatch videoFrameProcessingEndedCountDownLatch = new CountDownLatch(1); - ImmutableList.Builder actualPresentationTimesUs = new ImmutableList.Builder<>(); - - defaultVideoFrameProcessor = - checkNotNull( - new DefaultVideoFrameProcessor.Factory.Builder() - .setTextureOutput( - (textureProducer, outputTexture, presentationTimeUs, token) -> { - checkNotNull(textureBitmapReader) - .readBitmap(outputTexture, presentationTimeUs); - textureProducer.releaseOutputTexture(presentationTimeUs); - }, - /* textureOutputCapacity= */ 1) - .build() - .create( - getApplicationContext(), - DebugViewProvider.NONE, - /* outputColorInfo= */ ColorInfo.SDR_BT709_LIMITED, - /* renderFramesAutomatically= */ true, - MoreExecutors.directExecutor(), - new VideoFrameProcessor.Listener() { - @Override - public void onInputStreamRegistered( - @VideoFrameProcessor.InputType int inputType, - List effects, - FrameInfo frameInfo) { - videoFrameProcessorReadyCountDownLatch.countDown(); - } - - @Override - public void onOutputSizeChanged(int width, int height) {} - - @Override - public void onOutputFrameAvailableForRendering(long presentationTimeUs) { - actualPresentationTimesUs.add(presentationTimeUs); - } - - @Override - public void onError(VideoFrameProcessingException exception) { - videoFrameProcessingExceptionReference.set(exception); - videoFrameProcessorReadyCountDownLatch.countDown(); - videoFrameProcessingEndedCountDownLatch.countDown(); - } - - @Override - public void onEnded() { - videoFrameProcessingEndedCountDownLatch.countDown(); - } - })); - - defaultVideoFrameProcessor.getTaskExecutor().submit(blankFrameProducer::configureGlObjects); - // A frame needs to be registered despite not queuing any external input to ensure - // that the video frame processor knows about the stream offset. - checkNotNull(defaultVideoFrameProcessor) - .registerInputStream( - INPUT_TYPE_SURFACE, - /* effects= */ ImmutableList.of( - (GlEffect) (context, useHdr) -> blankFrameProducer, - // Use an overlay effect to generate bitmaps with timestamps on it. - new OverlayEffect( - ImmutableList.of( - new TextOverlay() { - @Override - public SpannableString getText(long presentationTimeUs) { - SpannableString text = - new SpannableString(String.valueOf(presentationTimeUs)); - text.setSpan( - new ForegroundColorSpan(Color.BLACK), - /* start= */ 0, - text.length(), - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - text.setSpan( - new AbsoluteSizeSpan(/* size= */ 24), - /* start= */ 0, - text.length(), - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - text.setSpan( - new TypefaceSpan(/* family= */ "sans-serif"), - /* start= */ 0, - text.length(), - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - return text; - } - })), - frameDropEffect), - new FrameInfo.Builder( - ColorInfo.SDR_BT709_LIMITED, BLANK_FRAME_WIDTH, BLANK_FRAME_HEIGHT) - .build()); - videoFrameProcessorReadyCountDownLatch.await(); - checkNoVideoFrameProcessingExceptionIsThrown(videoFrameProcessingExceptionReference); - blankFrameProducer.produceBlankFrames(inputPresentationTimesUs); - defaultVideoFrameProcessor.signalEndOfInput(); - videoFrameProcessingEndedCountDownLatch.await(); - checkNoVideoFrameProcessingExceptionIsThrown(videoFrameProcessingExceptionReference); - return actualPresentationTimesUs.build(); - } - - private static void checkNoVideoFrameProcessingExceptionIsThrown( - AtomicReference<@NullableType VideoFrameProcessingException> - videoFrameProcessingExceptionReference) - throws Exception { - @Nullable - Exception videoFrameProcessingException = videoFrameProcessingExceptionReference.get(); - if (videoFrameProcessingException != null) { - throw videoFrameProcessingException; - } + private ImmutableList generateAndProcessBlackTimeStampedFrames( + ImmutableList frameTimesUs, FrameDropEffect frameDropEffect) throws Exception { + int blankFrameWidth = 100; + int blankFrameHeight = 50; + Consumer textSpanConsumer = + (text) -> { + text.setSpan( + new ForegroundColorSpan(Color.BLACK), + /* start= */ 0, + text.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan( + new AbsoluteSizeSpan(/* size= */ 24), + /* start= */ 0, + text.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan( + new TypefaceSpan(/* family= */ "sans-serif"), + /* start= */ 0, + text.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + }; + return generateAndProcessFrames( + blankFrameWidth, + blankFrameHeight, + frameTimesUs, + frameDropEffect, + checkNotNull(textureBitmapReader), + textSpanConsumer); } } diff --git a/libraries/effect/src/androidTest/java/androidx/media3/effect/GaussianBlurTest.java b/libraries/effect/src/androidTest/java/androidx/media3/effect/GaussianBlurTest.java new file mode 100644 index 0000000000..0ab5cfc083 --- /dev/null +++ b/libraries/effect/src/androidTest/java/androidx/media3/effect/GaussianBlurTest.java @@ -0,0 +1,102 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * 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 androidx.media3.effect; + +import static androidx.media3.effect.EffectsTestUtil.generateAndProcessFrames; +import static androidx.media3.effect.EffectsTestUtil.getAndAssertOutputBitmaps; +import static com.google.common.truth.Truth.assertThat; + +import android.graphics.Color; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.style.AbsoluteSizeSpan; +import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.TypefaceSpan; +import androidx.media3.common.util.Consumer; +import androidx.media3.test.utils.TextureBitmapReader; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; +import org.checkerframework.checker.nullness.qual.EnsuresNonNull; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestName; +import org.junit.runner.RunWith; + +/** Tests for {@link GaussianBlur}. */ +@RunWith(AndroidJUnit4.class) +public class GaussianBlurTest { + @Rule public final TestName testName = new TestName(); + + // Golden images were generated on an API 33 emulator. API 26 emulators have a different text + // rendering implementation that leads to a larger pixel difference. + private static final String ASSET_PATH = "media/bitmap/GaussianBlurTest"; + private static final int BLANK_FRAME_WIDTH = 200; + private static final int BLANK_FRAME_HEIGHT = 100; + private static final Consumer TEXT_SPAN_CONSUMER = + (text) -> { + text.setSpan( + new BackgroundColorSpan(Color.BLUE), + /* start= */ 0, + text.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan( + new ForegroundColorSpan(Color.WHITE), + /* start= */ 0, + text.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan( + new AbsoluteSizeSpan(/* size= */ 100), + /* start= */ 0, + text.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan( + new TypefaceSpan(/* family= */ "sans-serif"), + /* start= */ 0, + text.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + }; + + private @MonotonicNonNull String testId; + private @MonotonicNonNull TextureBitmapReader textureBitmapReader; + + @EnsuresNonNull({"textureBitmapReader", "testId"}) + @Before + public void setUp() { + textureBitmapReader = new TextureBitmapReader(); + testId = testName.getMethodName(); + } + + @Test + @RequiresNonNull({"textureBitmapReader", "testId"}) + public void gaussianBlur_blursFrame() throws Exception { + ImmutableList frameTimesUs = ImmutableList.of(32_000L); + ImmutableList actualPresentationTimesUs = + generateAndProcessFrames( + BLANK_FRAME_WIDTH, + BLANK_FRAME_HEIGHT, + frameTimesUs, + new GaussianBlur(/* sigma= */ 5f), + textureBitmapReader, + TEXT_SPAN_CONSUMER); + + assertThat(actualPresentationTimesUs).containsExactly(32_000L); + getAndAssertOutputBitmaps(textureBitmapReader, actualPresentationTimesUs, testId, ASSET_PATH); + } +} diff --git a/libraries/effect/src/main/assets/shaders/fragment_shader_separable_convolution_es2.glsl b/libraries/effect/src/main/assets/shaders/fragment_shader_separable_convolution_es2.glsl new file mode 100644 index 0000000000..2093c54aae --- /dev/null +++ b/libraries/effect/src/main/assets/shaders/fragment_shader_separable_convolution_es2.glsl @@ -0,0 +1,105 @@ +#version 100 +// Copyright 2023 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// 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. + +precision highp float; +varying vec2 vTexSamplingCoord; +uniform sampler2D uTexSampler; +// 1D function LUT, only 2D due to OpenGL ES 2.0 limitations. +uniform int uIsHorizontal; +// Size of one texel in the source image, along the axis of interest. +// To properly leverage the bilinear texture sampling for efficient weighted +// lookup, it is important to know exactly where texels are centered. +uniform float uSourceTexelSize; +// Size of source texture in texels. +uniform float uSourceFullSize; +// Starting point of the convolution, in units of the source texels. +uniform float uConvStartTexels; +// Width of the convolution, in units of the source texels. +uniform float uConvWidthTexels; +// Convolution function has a different resolution than the source texture. +// Need to be able convert steps in source texels to steps in the function +// lookup texture. +uniform float uFunctionLookupStepSize; +// Center position of the function in the lookup texture. +uniform vec2 uFunctionLookupCenter; +uniform sampler2D uFunctionLookupSampler; + +// Reference Implementation: +void main() { + const float epsilon = 0.0001; + vec2 singleTexelStep; + float centerPositionTexels; + if (uIsHorizontal > 0) { + singleTexelStep = vec2(uSourceTexelSize, 0.0); + centerPositionTexels = vTexSamplingCoord.x * uSourceFullSize; + } else { + singleTexelStep = vec2(0.0, uSourceTexelSize); + centerPositionTexels = vTexSamplingCoord.y * uSourceFullSize; + } + + float supportStartEdgeTexels = + max(0.0, centerPositionTexels + uConvStartTexels); + + // Perform calculations at texel centers. + // Find first texel center > supportStartEdge. + // Texels are centered at 1/2 pixel offsets. + float startSampleTexels = floor(supportStartEdgeTexels + 0.5 - epsilon) + 0.5; + // Make use of bilinear sampling below, so each step is actually 2 samples. + int numSteps = int(ceil(uConvWidthTexels / 2.0)); + + // Loop through, leveraging linear texture sampling to perform 2 texel + // samples at once. + vec4 accumulatedRgba = vec4(0.0, 0.0, 0.0, 0.0); + float accumulatedWeight = 0.0; + + vec2 functionLookupStepPerTexel = vec2(uFunctionLookupStepSize, 0.0); + + for (int i = 0; i < numSteps; ++i) { + float sample0Texels = startSampleTexels + float(2 * i); + + float sample0OffsetTexels = sample0Texels - centerPositionTexels; + float sample1OffsetTexels = sample0OffsetTexels + 1.0; + + vec2 function0Coord = uFunctionLookupCenter + + sample0OffsetTexels * functionLookupStepPerTexel; + vec2 function1Coord = uFunctionLookupCenter + + sample1OffsetTexels * functionLookupStepPerTexel; + + float sample0Weight = texture2D(uFunctionLookupSampler, function0Coord).x; + float sample1Weight = texture2D(uFunctionLookupSampler, function1Coord).x; + float totalSampleWeight = sample0Weight + sample1Weight; + + // Skip samples with very low weight to avoid unnecessary lookups and + // avoid dividing by 0. + if (abs(totalSampleWeight) > epsilon) { + // Select a coordinate so that a linear sample at that location + // intrinsically includes the relative sampling weights. + float sampleOffsetTexels = (sample0OffsetTexels * sample0Weight + + sample1OffsetTexels * sample1Weight) / + totalSampleWeight; + + vec2 textureSamplePos = + vTexSamplingCoord + sampleOffsetTexels * singleTexelStep; + + vec4 textureSampleColor = texture2D(uTexSampler, textureSamplePos); + accumulatedRgba += textureSampleColor * totalSampleWeight; + accumulatedWeight += totalSampleWeight; + } + } + + if (accumulatedWeight > 0.0) { + gl_FragColor = accumulatedRgba / accumulatedWeight; + } +} diff --git a/libraries/effect/src/main/java/androidx/media3/effect/ConvolutionFunction1D.java b/libraries/effect/src/main/java/androidx/media3/effect/ConvolutionFunction1D.java new file mode 100644 index 0000000000..f1ef2cd222 --- /dev/null +++ b/libraries/effect/src/main/java/androidx/media3/effect/ConvolutionFunction1D.java @@ -0,0 +1,41 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 androidx.media3.effect; + +import androidx.media3.common.util.UnstableApi; + +/** + * An interface for 1 dimensional convolution functions. + * + *

The domain defines the region over which the function operates, in pixels. + */ +@UnstableApi +public interface ConvolutionFunction1D { + + /** Returns the start of the domain. */ + float domainStart(); + + /** Returns the end of the domain. */ + float domainEnd(); + + /** Returns the width of the domain. */ + default float width() { + return domainEnd() - domainStart(); + } + + /** Returns the value of the function at the {@code samplePosition}. */ + float value(float samplePosition); +} diff --git a/libraries/effect/src/main/java/androidx/media3/effect/GaussianBlur.java b/libraries/effect/src/main/java/androidx/media3/effect/GaussianBlur.java new file mode 100644 index 0000000000..d92bd6d015 --- /dev/null +++ b/libraries/effect/src/main/java/androidx/media3/effect/GaussianBlur.java @@ -0,0 +1,61 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 androidx.media3.effect; + +import androidx.annotation.FloatRange; +import androidx.annotation.RequiresApi; +import androidx.media3.common.util.UnstableApi; + +/** + * A {@link SeparableConvolution} to apply a Gaussian blur on image data. + * + *

The width of the blur is specified in pixels and applied symmetrically. + */ +@UnstableApi +@RequiresApi(26) // See SeparableConvolution. +public final class GaussianBlur extends SeparableConvolution { + private final float sigma; + private final float numStandardDeviations; + + /** + * Creates an instance. + * + * @param sigma The half-width of 1 standard deviation, in pixels. + * @param numStandardDeviations The size of function domain, measured in the number of standard + * deviations. + */ + public GaussianBlur( + @FloatRange(from = 0.0, fromInclusive = false) float sigma, + @FloatRange(from = 0.0, fromInclusive = false) float numStandardDeviations) { + this.sigma = sigma; + this.numStandardDeviations = numStandardDeviations; + } + + /** + * Creates an instance with {@code numStandardDeviations} set to {@code 2.0f}. + * + * @param sigma The half-width of 1 standard deviation, in pixels. + */ + public GaussianBlur(float sigma) { + this.sigma = sigma; + this.numStandardDeviations = 2.0f; + } + + @Override + public ConvolutionFunction1D getConvolution() { + return new GaussianFunction(sigma, numStandardDeviations); + } +} diff --git a/libraries/effect/src/main/java/androidx/media3/effect/GaussianFunction.java b/libraries/effect/src/main/java/androidx/media3/effect/GaussianFunction.java new file mode 100644 index 0000000000..5eeec38439 --- /dev/null +++ b/libraries/effect/src/main/java/androidx/media3/effect/GaussianFunction.java @@ -0,0 +1,71 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 androidx.media3.effect; + +import static androidx.media3.common.util.Assertions.checkArgument; +import static java.lang.Math.PI; +import static java.lang.Math.exp; +import static java.lang.Math.sqrt; + +import androidx.annotation.FloatRange; +import androidx.media3.common.util.UnstableApi; + +/** + * Implementation of a symmetric Gaussian function with a limited domain. + * + *

The half-width of the domain is {@code sigma} times {@code numStdDev}. Values strictly outside + * of that range are zero. + */ +@UnstableApi +public final class GaussianFunction implements ConvolutionFunction1D { + private final float sigma; + private final float numStdDev; + + /** + * Creates an instance. + * + * @param sigma The one standard deviation, in pixels. + * @param numStandardDeviations The half-width of function domain, measured in the number of + * standard deviations. + */ + public GaussianFunction( + @FloatRange(from = 0.0, fromInclusive = false) float sigma, + @FloatRange(from = 0.0, fromInclusive = false) float numStandardDeviations) { + checkArgument(sigma > 0 && numStandardDeviations > 0); + this.sigma = sigma; + this.numStdDev = numStandardDeviations; + } + + @Override + public float domainStart() { + return -numStdDev * sigma; + } + + @Override + public float domainEnd() { + return numStdDev * sigma; + } + + @Override + public float value(float samplePosition) { + if (Math.abs(samplePosition) > numStdDev * sigma) { + return 0.0f; + } + float samplePositionOverSigma = samplePosition / sigma; + return (float) + (exp(-samplePositionOverSigma * samplePositionOverSigma / 2) / sqrt(2 * PI) / sigma); + } +} diff --git a/libraries/effect/src/main/java/androidx/media3/effect/SeparableConvolution.java b/libraries/effect/src/main/java/androidx/media3/effect/SeparableConvolution.java new file mode 100644 index 0000000000..1fd760bd96 --- /dev/null +++ b/libraries/effect/src/main/java/androidx/media3/effect/SeparableConvolution.java @@ -0,0 +1,57 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 androidx.media3.effect; + +import android.content.Context; +import androidx.annotation.RequiresApi; +import androidx.media3.common.VideoFrameProcessingException; +import androidx.media3.common.util.UnstableApi; + +/** + * A {@link GlEffect} for performing separable convolutions. + * + *

A single 1D convolution function is applied horizontally on a first pass and vertically on a + * second pass. + */ +@UnstableApi +@RequiresApi(26) // See SeparableConvolutionShaderProgram. +public abstract class SeparableConvolution implements GlEffect { + private final float scaleFactor; + + /** Creates an instance with a {@code scaleFactor} of {@code 1}. */ + public SeparableConvolution() { + this(/* scaleFactor= */ 1.0f); + } + + /** + * Creates an instance. + * + * @param scaleFactor The scaling factor used to determine the size of the output relative to the + * input. The aspect ratio remains constant. + */ + public SeparableConvolution(float scaleFactor) { + this.scaleFactor = scaleFactor; + } + + /** Returns a {@linkplain ConvolutionFunction1D 1D convolution function}. */ + public abstract ConvolutionFunction1D getConvolution(); + + @Override + public GlShaderProgram toGlShaderProgram(Context context, boolean useHdr) + throws VideoFrameProcessingException { + return new SeparableConvolutionShaderProgram(context, useHdr, this, scaleFactor); + } +} diff --git a/libraries/effect/src/main/java/androidx/media3/effect/SeparableConvolutionShaderProgram.java b/libraries/effect/src/main/java/androidx/media3/effect/SeparableConvolutionShaderProgram.java new file mode 100644 index 0000000000..70ace03a9c --- /dev/null +++ b/libraries/effect/src/main/java/androidx/media3/effect/SeparableConvolutionShaderProgram.java @@ -0,0 +1,446 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 androidx.media3.effect; + +import static androidx.media3.effect.MatrixUtils.getGlMatrixArray; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Matrix; +import android.opengl.GLES20; +import android.opengl.GLUtils; +import androidx.annotation.RequiresApi; +import androidx.media3.common.GlObjectsProvider; +import androidx.media3.common.GlTextureInfo; +import androidx.media3.common.VideoFrameProcessingException; +import androidx.media3.common.util.Assertions; +import androidx.media3.common.util.GlProgram; +import androidx.media3.common.util.GlUtil; +import androidx.media3.common.util.Size; +import com.google.common.util.concurrent.MoreExecutors; +import java.io.IOException; +import java.nio.ShortBuffer; +import java.util.concurrent.Executor; + +/** + * A {@link GlShaderProgram} for performing separable convolutions. + * + *

A single {@link ConvolutionFunction1D} is applied horizontally on a first pass and vertically + * on a second pass. + */ +@RequiresApi(26) // Uses Bitmap.Config.RGBA_F16. +/* package */ final class SeparableConvolutionShaderProgram implements GlShaderProgram { + private static final String VERTEX_SHADER_PATH = "shaders/vertex_shader_transformation_es2.glsl"; + private static final String FRAGMENT_SHADER_PATH = + "shaders/fragment_shader_separable_convolution_es2.glsl"; + + // Constants specifically for fp16FromFloat(). + // TODO (b/282767994): Fix TAP hanging issue and update samples per texel. + private static final int RASTER_SAMPLES_PER_TEXEL = 5; + // Apply some padding in the function LUT to avoid any issues from GL sampling off the texture. + private static final int FUNCTION_LUT_PADDING = RASTER_SAMPLES_PER_TEXEL; + + // BEGIN COPIED FP16 code. + // Source: libcore/luni/src/main/java/libcore/util/FP16.java + private static final int FP16_EXPONENT_BIAS = 15; + private static final int FP16_SIGN_SHIFT = 15; + private static final int FP16_EXPONENT_SHIFT = 10; + private static final int FP32_SIGN_SHIFT = 31; + private static final int FP32_EXPONENT_SHIFT = 23; + private static final int FP32_SHIFTED_EXPONENT_MASK = 0xff; + private static final int FP32_SIGNIFICAND_MASK = 0x7fffff; + private static final int FP32_EXPONENT_BIAS = 127; + // END FP16 copied code. + + private final GlProgram glProgram; + private final GlProgram sharpTransformGlProgram; + private final float[] sharpTransformMatrixValues; + private final boolean useHdr; + private final SeparableConvolution convolution; + private final float scaleFactor; + + private GlShaderProgram.InputListener inputListener; + private GlShaderProgram.OutputListener outputListener; + private GlShaderProgram.ErrorListener errorListener; + private Executor errorListenerExecutor; + private Size outputSize; + private Size lastInputSize; + private Size intermediateSize; + private GlTextureInfo outputTexture; + private boolean outputTextureInUse; + private GlTextureInfo intermediateTexture; + private GlTextureInfo functionLutTexture; // Values for the function LUT as a texture. + private float functionLutTexelStep; + private float functionLutCenterX; + private float functionLutDomainStart; + private float functionLutWidth; + + /** + * Creates an instance. + * + * @param context The {@link Context}. + * @param useHdr Whether input textures come from an HDR source. If {@code true}, colors will be + * in linear RGB BT.2020. If {@code false}, colors will be in linear RGB BT.709. + * @param convolution The {@link SeparableConvolution} to apply in each direction. + * @param scaleFactor The scaling factor used to determine the size of the output relative to the + * input. The aspect ratio remains constant. + * @throws VideoFrameProcessingException If a problem occurs while reading shader files. + */ + public SeparableConvolutionShaderProgram( + Context context, boolean useHdr, SeparableConvolution convolution, float scaleFactor) + throws VideoFrameProcessingException { + this.useHdr = useHdr; + this.convolution = convolution; + this.scaleFactor = scaleFactor; + inputListener = new InputListener() {}; + outputListener = new OutputListener() {}; + errorListener = (frameProcessingException) -> {}; + + errorListenerExecutor = MoreExecutors.directExecutor(); + lastInputSize = Size.ZERO; + intermediateSize = Size.ZERO; + outputSize = Size.ZERO; + try { + glProgram = new GlProgram(context, VERTEX_SHADER_PATH, FRAGMENT_SHADER_PATH); + sharpTransformGlProgram = + new GlProgram( + context, + "shaders/vertex_shader_transformation_es2.glsl", + "shaders/fragment_shader_copy_es2.glsl"); + } catch (IOException | GlUtil.GlException e) { + throw new VideoFrameProcessingException(e); + } + + Matrix sharpTransformMatrix = new Matrix(); + sharpTransformMatrix.setScale(/* sx= */ .5f, /* sy= */ 1f); + sharpTransformMatrixValues = getGlMatrixArray(sharpTransformMatrix); + functionLutTexture = GlTextureInfo.UNSET; + intermediateTexture = GlTextureInfo.UNSET; + outputTexture = GlTextureInfo.UNSET; + } + + @Override + public void setInputListener(InputListener inputListener) { + this.inputListener = inputListener; + if (!outputTextureInUse) { + inputListener.onReadyToAcceptInputFrame(); + } + } + + @Override + public void setOutputListener(OutputListener outputListener) { + this.outputListener = outputListener; + } + + @Override + public void setErrorListener(Executor errorListenerExecutor, ErrorListener errorListener) { + this.errorListenerExecutor = errorListenerExecutor; + this.errorListener = errorListener; + } + + @Override + public void queueInputFrame( + GlObjectsProvider glObjectsProvider, GlTextureInfo inputTexture, long presentationTimeUs) { + Assertions.checkState( + !outputTextureInUse, + "The shader program does not currently accept input frames. Release prior output frames" + + " first."); + try { + ensureTexturesAreConfigured( + glObjectsProvider, new Size(inputTexture.width, inputTexture.height)); + outputTextureInUse = true; + renderHorizontal(inputTexture); + renderVertical(); + float[] identityMatrix = GlUtil.create4x4IdentityMatrix(); + sharpTransformGlProgram.use(); + sharpTransformGlProgram.setSamplerTexIdUniform( + "uTexSampler", inputTexture.texId, /* texUnitIndex= */ 0); + sharpTransformGlProgram.setFloatsUniform("uTexTransformationMatrix", identityMatrix); + sharpTransformGlProgram.setFloatsUniform("uTransformationMatrix", sharpTransformMatrixValues); + sharpTransformGlProgram.setBufferAttribute( + "aFramePosition", + GlUtil.getNormalizedCoordinateBounds(), + GlUtil.HOMOGENEOUS_COORDINATE_VECTOR_SIZE); + sharpTransformGlProgram.bindAttributesAndUniforms(); + + // The four-vertex triangle strip forms a quad. + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, /* i1= */ 0, /* i2= */ 4); + GlUtil.checkGlError(); + inputListener.onInputFrameProcessed(inputTexture); + outputListener.onOutputFrameAvailable(outputTexture, presentationTimeUs); + } catch (GlUtil.GlException e) { + errorListenerExecutor.execute( + () -> errorListener.onError(VideoFrameProcessingException.from(e, presentationTimeUs))); + } + } + + @Override + public void releaseOutputFrame(GlTextureInfo outputTexture) { + outputTextureInUse = false; + inputListener.onReadyToAcceptInputFrame(); + } + + @Override + public void signalEndOfCurrentInputStream() { + outputListener.onCurrentOutputStreamEnded(); + } + + @Override + public void flush() { + outputTextureInUse = false; + inputListener.onFlush(); + inputListener.onReadyToAcceptInputFrame(); + } + + @Override + public void release() throws VideoFrameProcessingException { + try { + outputTexture.release(); + intermediateTexture.release(); + functionLutTexture.release(); + glProgram.delete(); + sharpTransformGlProgram.delete(); + } catch (GlUtil.GlException e) { + throw new VideoFrameProcessingException(e); + } + } + + private void renderOnePass(int inputTexId, boolean isHorizontal) throws GlUtil.GlException { + int size = isHorizontal ? lastInputSize.getWidth() : intermediateSize.getHeight(); + glProgram.use(); + glProgram.setSamplerTexIdUniform("uTexSampler", inputTexId, /* texUnitIndex= */ 0); + glProgram.setIntUniform("uIsHorizontal", isHorizontal ? 1 : 0); + glProgram.setFloatUniform("uSourceTexelSize", 1.0f / size); + glProgram.setFloatUniform("uSourceFullSize", (float) size); + glProgram.setFloatUniform("uConvStartTexels", functionLutDomainStart); + glProgram.setFloatUniform("uConvWidthTexels", functionLutWidth); + glProgram.setFloatUniform("uFunctionLookupStepSize", functionLutTexelStep); + glProgram.setFloatsUniform("uFunctionLookupCenter", new float[] {functionLutCenterX, 0.5f}); + glProgram.setSamplerTexIdUniform( + "uFunctionLookupSampler", functionLutTexture.texId, /* texUnitIndex= */ 1); + glProgram.bindAttributesAndUniforms(); + + // The four-vertex triangle strip forms a quad. + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + GlUtil.checkGlError(); + } + + private Size configure(Size inputSize) { + // Draw the frame on the entire normalized device coordinate space, from -1 to 1, for x and y. + glProgram.setBufferAttribute( + "aFramePosition", + GlUtil.getNormalizedCoordinateBounds(), + GlUtil.HOMOGENEOUS_COORDINATE_VECTOR_SIZE); + float[] identityMatrix = GlUtil.create4x4IdentityMatrix(); + glProgram.setFloatsUniform("uTransformationMatrix", identityMatrix); + glProgram.setFloatsUniform("uTexTransformationMatrix", identityMatrix); + + return new Size( + (int) (inputSize.getWidth() * scaleFactor * 2), + (int) (inputSize.getHeight() * scaleFactor)); + } + + private void renderHorizontal(GlTextureInfo inputTexture) throws GlUtil.GlException { + // Render horizontal reads from the input texture and renders to the intermediate texture. + GlUtil.focusFramebufferUsingCurrentContext( + intermediateTexture.fboId, intermediateTexture.width, intermediateTexture.height); + GlUtil.clearFocusedBuffers(); + renderOnePass(inputTexture.texId, /* isHorizontal= */ true); + } + + private void renderVertical() throws GlUtil.GlException { + // Render vertical reads from the intermediate and renders to the output texture. + GlUtil.focusFramebufferUsingCurrentContext( + outputTexture.fboId, outputTexture.width, outputTexture.height); + GlUtil.clearFocusedBuffers(); + renderOnePass(intermediateTexture.texId, /* isHorizontal= */ false); + } + + private void ensureTexturesAreConfigured(GlObjectsProvider glObjectsProvider, Size inputSize) + throws GlUtil.GlException { + // Always update the function texture, as it could change on each render cycle. + updateFunctionTexture(glObjectsProvider); + + // Only update intermediate and output textures if the size changes. + if (inputSize.equals(lastInputSize)) { + return; + } + + outputSize = configure(inputSize); + // If there is a size change with the filtering (for example, a scaling operation), the first + // pass is applied horizontally. As a result, width of the intermediate texture will match the + // output size, while the height will be unchanged from the input + intermediateSize = new Size(outputSize.getWidth(), inputSize.getHeight()); + intermediateTexture = + configurePixelTexture(glObjectsProvider, intermediateTexture, intermediateSize); + outputTexture = configurePixelTexture(glObjectsProvider, outputTexture, outputSize); + + this.lastInputSize = inputSize; + } + + /** + * Creates a function lookup table for the convolution, and stores it in a 16b floating point + * texture for GPU access. + */ + private void updateFunctionTexture(GlObjectsProvider glObjectsProvider) + throws GlUtil.GlException { + + ConvolutionFunction1D convolutionFunction = convolution.getConvolution(); + + int lutRasterSize = + (int) + Math.ceil( + convolutionFunction.width() * RASTER_SAMPLES_PER_TEXEL + 2 * FUNCTION_LUT_PADDING); + + // The function LUT is mapped to [0, 1] texture coords. We need to calculate what change + // in texture coordinated corresponds exactly with a size of 1 texel (or pixel) in the function. + // This is basically 1 / function_width, but due to the ceil() call above, it needs to be + // calculated based on the actual raster size. + this.functionLutTexelStep = 1.0f / ((float) lutRasterSize / RASTER_SAMPLES_PER_TEXEL); + + // The function values are stored in an FP16 texture. Setting FP16 values in a Bitmap requires + // multiple steps. For each step, calculate the function value as a Float, and then use the + // Half class to convert to FP16 and then read the value as a Short int + ShortBuffer functionShortBuffer = ShortBuffer.allocate(lutRasterSize * 4); + float rasterSampleStep = 1.0f / RASTER_SAMPLES_PER_TEXEL; + float functionDomainStart = convolutionFunction.domainStart(); + int index = 0; + + for (int i = 0; i < lutRasterSize; i++) { + float sampleValue = 0.0f; + int unpaddedI = i - FUNCTION_LUT_PADDING; + float samplePosition = functionDomainStart + unpaddedI * rasterSampleStep; + + if (unpaddedI >= 0 && i <= lutRasterSize - FUNCTION_LUT_PADDING) { + sampleValue = convolutionFunction.value(samplePosition); + } + + // Convert float to half (fp16) and read out the bits as a short. + // Texture for Bitmap is RGBA_F16, so we store the function value in RGB channels and 1.0 + // in A. + short shortEncodedValue = fp16FromFloat(sampleValue); + + // Set RGB + functionShortBuffer.put(index++, shortEncodedValue); + functionShortBuffer.put(index++, shortEncodedValue); + functionShortBuffer.put(index++, shortEncodedValue); + + // Set Alpha + functionShortBuffer.put(index++, fp16FromFloat(1.0f)); + } + + // Calculate the center of the function in the raster. The formula below is a slight + // adjustment on (value - min) / (max - min), where value = 0 at center and + // rasterSampleStep * lutRasterSize is equal to (max - min) over the range of the raster + // samples, which may be slightly different than the difference between the function's max + // and min domain values. + // To find the value associated at position 0 in the texture, is the value corresponding with + // the leading edge position of the first sample. This needs to account for the padding and + // the 1/2 texel offsets used in texture lookups (index 0 is centered at 0.5 / numTexels). + float minValueWithPadding = + functionDomainStart - rasterSampleStep * (FUNCTION_LUT_PADDING + 0.5f); + this.functionLutCenterX = -minValueWithPadding / (rasterSampleStep * lutRasterSize); + this.functionLutDomainStart = convolutionFunction.domainStart(); + this.functionLutWidth = convolutionFunction.width(); + + // TODO(b/276982847): Use alternative to Bitmap to create function LUT texture. + Bitmap functionLookupBitmap = + Bitmap.createBitmap(lutRasterSize, /* height= */ 1, Bitmap.Config.RGBA_F16); + functionLookupBitmap.copyPixelsFromBuffer(functionShortBuffer); + + // Create new GL texture if needed. + if (functionLutTexture == GlTextureInfo.UNSET || functionLutTexture.width != lutRasterSize) { + functionLutTexture.release(); + + // Need to use high precision to force 16FP color. + int functionLutTextureId = + GlUtil.createTexture( + lutRasterSize, /* height= */ 1, /* useHighPrecisionColorComponents= */ true); + + functionLutTexture = + glObjectsProvider.createBuffersForTexture( + functionLutTextureId, lutRasterSize, /* height= */ 1); + } + GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, /* level= */ 0, functionLookupBitmap, /* border= */ 0); + GlUtil.checkGlError(); + } + + private GlTextureInfo configurePixelTexture( + GlObjectsProvider glObjectsProvider, GlTextureInfo existingTexture, Size size) + throws GlUtil.GlException { + if (size.getWidth() == existingTexture.width && size.getHeight() == existingTexture.height) { + return existingTexture; + } + + existingTexture.release(); + int texId = GlUtil.createTexture(size.getWidth(), size.getHeight(), useHdr); + + return glObjectsProvider.createBuffersForTexture(texId, size.getWidth(), size.getHeight()); + } + + // BEGIN COPIED FP16 code. + // Source: libcore/luni/src/main/java/libcore/util/FP16.java + // Float to half float conversion, copied from FP16. This code is introduced in API26, so the + // one required method is copied here. + private static short fp16FromFloat(float f) { + int bits = Float.floatToRawIntBits(f); + int s = bits >>> FP32_SIGN_SHIFT; + int e = (bits >>> FP32_EXPONENT_SHIFT) & FP32_SHIFTED_EXPONENT_MASK; + int m = bits & FP32_SIGNIFICAND_MASK; + int outE = 0; + int outM = 0; + if (e == 0xff) { // Infinite or NaN + outE = 0x1f; + outM = (m != 0) ? 0x200 : 0; + } else { + e = e - FP32_EXPONENT_BIAS + FP16_EXPONENT_BIAS; + if (e >= 0x1f) { // Overflow + outE = 0x1f; + } else if (e <= 0) { // Underflow + if (e >= -10) { + // The fp32 value is a normalized float less than MIN_NORMAL, + // we convert to a denorm fp16 + m |= 0x800000; + int shift = 14 - e; + outM = m >>> shift; + int lowm = m & ((1 << shift) - 1); + int hway = 1 << (shift - 1); + // if above halfway or exactly halfway and outM is odd + if (lowm + (outM & 1) > hway) { + // Round to nearest even + // Can overflow into exponent bit, which surprisingly is OK. + // This increment relies on the +outM in the return statement below + outM++; + } + } + } else { + outE = e; + outM = m >>> 13; + // if above halfway or exactly halfway and outM is odd + if ((m & 0x1fff) + (outM & 0x1) > 0x1000) { + // Round to nearest even + // Can overflow into exponent bit, which surprisingly is OK. + // This increment relies on the +outM in the return statement below + outM++; + } + } + } + // The outM is added here as the +1 increments for outM above can + // cause an overflow in the exponent bit which is OK. + return (short) ((s << FP16_SIGN_SHIFT) | ((outE << FP16_EXPONENT_SHIFT) + outM)); + } + // END FP16 copied code. +} diff --git a/libraries/effect/src/test/java/androidx/media3/effect/GaussianFunctionTest.java b/libraries/effect/src/test/java/androidx/media3/effect/GaussianFunctionTest.java new file mode 100644 index 0000000000..d181a743c2 --- /dev/null +++ b/libraries/effect/src/test/java/androidx/media3/effect/GaussianFunctionTest.java @@ -0,0 +1,46 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 androidx.media3.effect; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link GaussianFunction}. */ +@RunWith(AndroidJUnit4.class) +public class GaussianFunctionTest { + + private final GaussianFunction function = + new GaussianFunction(/* sigma= */ 2.55f, /* numStandardDeviations= */ 4.45f); + + @Test + public void value_samplePositionAboveRange_returnsZero() { + assertThat(function.value(/* samplePosition= */ function.domainEnd() + .00001f)).isEqualTo(0); + } + + @Test + public void value_samplePositionBelowRange_returnsZero() { + assertThat(function.value(/* samplePosition= */ -10000000000000f)).isEqualTo(0); + } + + @Test + public void value_samplePositionInRange_returnsSymmetricGaussianFunction() { + assertThat(function.value(/* samplePosition= */ 9.999f)).isEqualTo(7.1712595E-5f); + assertThat(function.value(/* samplePosition= */ -9.999f)).isEqualTo(7.1712595E-5f); + } +} diff --git a/libraries/test_data/src/test/assets/media/bitmap/GaussianBlurTest/pts_32000.png b/libraries/test_data/src/test/assets/media/bitmap/GaussianBlurTest/pts_32000.png new file mode 100644 index 0000000000000000000000000000000000000000..1d6008eaa745e42ad9c742ab580a82f77042d54c GIT binary patch literal 14683 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 GA^#8eN~%Nv literal 0 HcmV?d00001