From 78c419e566dc929c34428e4977e4b1071837dd27 Mon Sep 17 00:00:00 2001 From: tofunmi Date: Fri, 12 Jan 2024 04:50:24 -0800 Subject: [PATCH] Extension to Gaussian Blur: support changing blur over time PiperOrigin-RevId: 597809380 --- .../media3/effect/GaussianBlurTest.java | 24 ++++++++++++++++++ .../androidx/media3/effect/GaussianBlur.java | 3 +-- .../media3/effect/GaussianFunction.java | 19 ++++++++++++++ .../media3/effect/SeparableConvolution.java | 8 ++++-- .../SeparableConvolutionShaderProgram.java | 23 +++++++++++------ .../bitmap/GaussianBlurTest/pts_71000.png | Bin 0 -> 8635 bytes 6 files changed, 65 insertions(+), 12 deletions(-) create mode 100644 libraries/test_data/src/test/assets/media/bitmap/GaussianBlurTest/pts_71000.png diff --git a/libraries/effect/src/androidTest/java/androidx/media3/effect/GaussianBlurTest.java b/libraries/effect/src/androidTest/java/androidx/media3/effect/GaussianBlurTest.java index bb2cd74f1f..c967e0f225 100644 --- a/libraries/effect/src/androidTest/java/androidx/media3/effect/GaussianBlurTest.java +++ b/libraries/effect/src/androidTest/java/androidx/media3/effect/GaussianBlurTest.java @@ -99,4 +99,28 @@ public class GaussianBlurTest { assertThat(actualPresentationTimesUs).containsExactly(32_000L); getAndAssertOutputBitmaps(textureBitmapReader, actualPresentationTimesUs, testId, ASSET_PATH); } + + @Test + @RequiresNonNull({"textureBitmapReader", "testId"}) + public void gaussianBlur_sigmaChangesWithTime_differentFramesHaveDifferentBlurs() + throws Exception { + ImmutableList frameTimesUs = ImmutableList.of(32_000L, 71_000L); + ImmutableList actualPresentationTimesUs = + generateAndProcessFrames( + BLANK_FRAME_WIDTH, + BLANK_FRAME_HEIGHT, + frameTimesUs, + new SeparableConvolution() { + @Override + public ConvolutionFunction1D getConvolution(long presentationTimeUs) { + return new GaussianFunction( + presentationTimeUs < 40_000L ? 5f : 20f, /* numStandardDeviations= */ 2.0f); + } + }, + textureBitmapReader, + TEXT_SPAN_CONSUMER); + + assertThat(actualPresentationTimesUs).containsExactly(32_000L, 71_000L); + getAndAssertOutputBitmaps(textureBitmapReader, actualPresentationTimesUs, testId, ASSET_PATH); + } } diff --git a/libraries/effect/src/main/java/androidx/media3/effect/GaussianBlur.java b/libraries/effect/src/main/java/androidx/media3/effect/GaussianBlur.java index c7a699dba0..e4c2e06939 100644 --- a/libraries/effect/src/main/java/androidx/media3/effect/GaussianBlur.java +++ b/libraries/effect/src/main/java/androidx/media3/effect/GaussianBlur.java @@ -51,11 +51,10 @@ public final class GaussianBlur extends SeparableConvolution { */ public GaussianBlur(float sigma) { this(sigma, /* numStandardDeviations= */ 2.0f); - ; } @Override - public ConvolutionFunction1D getConvolution() { + public ConvolutionFunction1D getConvolution(long presentationTimeUs) { 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 index 5eeec38439..0cffa35c0d 100644 --- a/libraries/effect/src/main/java/androidx/media3/effect/GaussianFunction.java +++ b/libraries/effect/src/main/java/androidx/media3/effect/GaussianFunction.java @@ -21,7 +21,9 @@ import static java.lang.Math.exp; import static java.lang.Math.sqrt; import androidx.annotation.FloatRange; +import androidx.annotation.Nullable; import androidx.media3.common.util.UnstableApi; +import java.util.Objects; /** * Implementation of a symmetric Gaussian function with a limited domain. @@ -68,4 +70,21 @@ public final class GaussianFunction implements ConvolutionFunction1D { return (float) (exp(-samplePositionOverSigma * samplePositionOverSigma / 2) / sqrt(2 * PI) / sigma); } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (!(o instanceof GaussianFunction)) { + return false; + } + GaussianFunction that = (GaussianFunction) o; + return Float.compare(that.sigma, sigma) == 0 && Float.compare(that.numStdDev, numStdDev) == 0; + } + + @Override + public int hashCode() { + return Objects.hash(sigma, numStdDev); + } } diff --git a/libraries/effect/src/main/java/androidx/media3/effect/SeparableConvolution.java b/libraries/effect/src/main/java/androidx/media3/effect/SeparableConvolution.java index 1fd760bd96..2cf51f5db6 100644 --- a/libraries/effect/src/main/java/androidx/media3/effect/SeparableConvolution.java +++ b/libraries/effect/src/main/java/androidx/media3/effect/SeparableConvolution.java @@ -46,8 +46,12 @@ public abstract class SeparableConvolution implements GlEffect { this.scaleFactor = scaleFactor; } - /** Returns a {@linkplain ConvolutionFunction1D 1D convolution function}. */ - public abstract ConvolutionFunction1D getConvolution(); + /** + * Returns a {@linkplain ConvolutionFunction1D 1D convolution function}. + * + * @param presentationTimeUs The presentation timestamp of the input frame, in microseconds. + */ + public abstract ConvolutionFunction1D getConvolution(long presentationTimeUs); @Override public GlShaderProgram toGlShaderProgram(Context context, boolean useHdr) diff --git a/libraries/effect/src/main/java/androidx/media3/effect/SeparableConvolutionShaderProgram.java b/libraries/effect/src/main/java/androidx/media3/effect/SeparableConvolutionShaderProgram.java index 70ace03a9c..1b2b7aeab9 100644 --- a/libraries/effect/src/main/java/androidx/media3/effect/SeparableConvolutionShaderProgram.java +++ b/libraries/effect/src/main/java/androidx/media3/effect/SeparableConvolutionShaderProgram.java @@ -34,6 +34,7 @@ import com.google.common.util.concurrent.MoreExecutors; import java.io.IOException; import java.nio.ShortBuffer; import java.util.concurrent.Executor; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** * A {@link GlShaderProgram} for performing separable convolutions. @@ -87,6 +88,7 @@ import java.util.concurrent.Executor; private float functionLutCenterX; private float functionLutDomainStart; private float functionLutWidth; + private @MonotonicNonNull ConvolutionFunction1D lastConvolutionFunction; /** * Creates an instance. @@ -108,11 +110,12 @@ import java.util.concurrent.Executor; inputListener = new InputListener() {}; outputListener = new OutputListener() {}; errorListener = (frameProcessingException) -> {}; - errorListenerExecutor = MoreExecutors.directExecutor(); lastInputSize = Size.ZERO; intermediateSize = Size.ZERO; outputSize = Size.ZERO; + lastConvolutionFunction = null; + try { glProgram = new GlProgram(context, VERTEX_SHADER_PATH, FRAGMENT_SHADER_PATH); sharpTransformGlProgram = @@ -160,7 +163,7 @@ import java.util.concurrent.Executor; + " first."); try { ensureTexturesAreConfigured( - glObjectsProvider, new Size(inputTexture.width, inputTexture.height)); + glObjectsProvider, new Size(inputTexture.width, inputTexture.height), presentationTimeUs); outputTextureInUse = true; renderHorizontal(inputTexture); renderVertical(); @@ -269,10 +272,15 @@ import java.util.concurrent.Executor; renderOnePass(intermediateTexture.texId, /* isHorizontal= */ false); } - private void ensureTexturesAreConfigured(GlObjectsProvider glObjectsProvider, Size inputSize) + private void ensureTexturesAreConfigured( + GlObjectsProvider glObjectsProvider, Size inputSize, long presentationTimeUs) throws GlUtil.GlException { - // Always update the function texture, as it could change on each render cycle. - updateFunctionTexture(glObjectsProvider); + ConvolutionFunction1D currentConvolutionFunction = + convolution.getConvolution(presentationTimeUs); + if (!currentConvolutionFunction.equals(lastConvolutionFunction)) { + updateFunctionTexture(glObjectsProvider, currentConvolutionFunction); + lastConvolutionFunction = currentConvolutionFunction; + } // Only update intermediate and output textures if the size changes. if (inputSize.equals(lastInputSize)) { @@ -295,11 +303,10 @@ import java.util.concurrent.Executor; * 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) + private void updateFunctionTexture( + GlObjectsProvider glObjectsProvider, ConvolutionFunction1D convolutionFunction) throws GlUtil.GlException { - ConvolutionFunction1D convolutionFunction = convolution.getConvolution(); - int lutRasterSize = (int) Math.ceil( diff --git a/libraries/test_data/src/test/assets/media/bitmap/GaussianBlurTest/pts_71000.png b/libraries/test_data/src/test/assets/media/bitmap/GaussianBlurTest/pts_71000.png new file mode 100644 index 0000000000000000000000000000000000000000..fbcd00b8857f7f750e2d087d21f041cbdbf57123 GIT binary patch literal 8635 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 N002ovPDHLkV1j