Create a public ImageProcessor interface
- ImageProcessor is the public API for ImageCapture effect. Hidden for now, and will be made public for API review.
- InternalImageProcessor, an internal wrapper of ImageProcessor/CameraEffect to provide additional info and make sure it's not called on the wrong thread.
Bug: 249593716
Test: manual test and ./gradlew bOS
Change-Id: Ibcccaab4e0e4f4d094c4f29c990f40955ebedc6e
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/CameraEffect.java b/camera/camera-core/src/main/java/androidx/camera/core/CameraEffect.java
index 4d7c8ea..ce8cf2b 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/CameraEffect.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/CameraEffect.java
@@ -15,7 +15,6 @@
*/
package androidx.camera.core;
-import static androidx.camera.core.impl.utils.executor.CameraXExecutors.mainThreadExecutor;
import static androidx.core.util.Preconditions.checkState;
import android.os.Build;
@@ -81,15 +80,22 @@
private final Executor mProcessorExecutor;
@Nullable
private final SurfaceProcessor mSurfaceProcessor;
+ @Nullable
+ private final ImageProcessor mImageProcessor;
/**
- * Private constructor as a workaround to allow @Nullable annotation on final fields.
+ * @param targets the target {@link UseCase} to which this effect should be applied.
+ * @param processorExecutor the {@link Executor} on which the processor will be invoked.
+ * @param imageProcessor a {@link ImageProcessor} implementation.
*/
- @SuppressWarnings("UnusedMethod") // TODO: remove once we add {@link ImageProcessor}.
- private CameraEffect(@Targets int targets) {
+ protected CameraEffect(
+ @Targets int targets,
+ @NonNull Executor processorExecutor,
+ @NonNull ImageProcessor imageProcessor) {
mTargets = targets;
- mProcessorExecutor = mainThreadExecutor();
+ mProcessorExecutor = processorExecutor;
mSurfaceProcessor = null;
+ mImageProcessor = imageProcessor;
}
/**
@@ -104,6 +110,7 @@
mTargets = targets;
mProcessorExecutor = processorExecutor;
mSurfaceProcessor = surfaceProcessor;
+ mImageProcessor = null;
}
/**
@@ -135,6 +142,16 @@
}
/**
+ * Gets the {@link ImageProcessor} associated with this effect.
+ *
+ * <p>This method returns the value set via {@link Builder#setImageProcessor}.
+ */
+ @Nullable
+ public ImageProcessor getImageProcessor() {
+ return mImageProcessor;
+ }
+
+ /**
* Builder class for {@link CameraEffect}.
*/
public static class Builder {
@@ -144,6 +161,8 @@
private Executor mProcessorExecutor;
@Nullable
private SurfaceProcessor mSurfaceProcessor;
+ @Nullable
+ private ImageProcessor mImageProcessor;
/**
* @param targets the target {@link UseCase} of the Effect. e.g. if the
@@ -161,6 +180,9 @@
* {@link SurfaceProcessor} on the {@link Executor}, and deliver the processed output
* frames to the app.
*
+ * <p>Only one processor can be set via {@code #setImageProcessor()} /
+ * {@code #setSurfaceProcessor}, or the {@link #build()} call will throw error.
+ *
* @param executor on which the {@link SurfaceProcessor} will be invoked.
* @param processor the post processor to be injected into CameraX pipeline.
*/
@@ -173,6 +195,27 @@
}
/**
+ * Sets a {@link ImageProcessor} for the effect.
+ *
+ * <p>Once the effect is active, CameraX will send original camera frames to the
+ * {@link ImageProcessor} on the {@link Executor}, and deliver the processed output
+ * frames to the app.
+ *
+ * <p>Only one processor can be set via {@code #setImageProcessor()} /
+ * {@code #setSurfaceProcessor}, or the {@link #build()} call will throw error.
+ *
+ * @param executor on which the {@link ImageProcessor} will be invoked.
+ * @param processor the post processor to be injected into CameraX pipeline.
+ */
+ @NonNull
+ public Builder setImageProcessor(@NonNull Executor executor,
+ @NonNull ImageProcessor processor) {
+ mProcessorExecutor = executor;
+ mImageProcessor = processor;
+ return this;
+ }
+
+ /**
* Builds a {@link CameraEffect} instance.
*
* <p>CameraX supports a selected set of configuration/processor combinations. This method
@@ -181,9 +224,14 @@
*/
@NonNull
public CameraEffect build() {
- checkState(mProcessorExecutor != null && mSurfaceProcessor != null,
- "Must set a processor.");
- return new CameraEffect(mTargets, mProcessorExecutor, mSurfaceProcessor);
+ checkState(mProcessorExecutor != null, "Must have a executor");
+ checkState(mImageProcessor != null ^ mSurfaceProcessor != null,
+ "Must have one and only one processor");
+ if (mSurfaceProcessor != null) {
+ return new CameraEffect(mTargets, mProcessorExecutor, mSurfaceProcessor);
+ } else {
+ return new CameraEffect(mTargets, mProcessorExecutor, mImageProcessor);
+ }
}
}
}
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/ImageProcessor.java b/camera/camera-core/src/main/java/androidx/camera/core/ImageProcessor.java
index 03d7ef7..3ac9d76 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/ImageProcessor.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/ImageProcessor.java
@@ -16,14 +16,129 @@
package androidx.camera.core;
+import android.graphics.PixelFormat;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
+import java.util.List;
+import java.util.concurrent.Executor;
+
/**
- * Interface for injecting a {@link ImageProxy}-based post-processing effect into CameraX.
+ * Interface for injecting a {@link ImageProxy} effect into CameraX.
+ *
+ * <p>Implement {@link ImageProcessor} to inject an effect into CameraX pipeline. For example, to
+ * edit the {@link ImageCapture} result, add a {@link CameraEffect} with the
+ * {@link ImageProcessor} targeting {@link CameraEffect#IMAGE_CAPTURE}. Once injected,
+ * {@link ImageCapture} forwards camera frames to the implementation, and delivers the processed
+ * frames to the app.
+ *
+ * <p>Code sample for creating a {@link ImageCapture} object:
+ * <pre><code>
+ * class ImageEffect implements CameraEffect {
+ * ImageEffect(Executor executor, ImageProcessor imageProcessorImpl) {
+ * super(IMAGE_CAPTURE, executor, imageProcessorImpl);
+ * }
+ * }
+ * </code></pre>
+ *
+ * <p>Code sample for injecting the effect into CameraX pipeline:
+ * <pre><code>
+ * UseCaseGroup useCaseGroup = UseCaseGroup.Builder()
+ * .addUseCase(imageCapture)
+ * .addEffect(new ImageEffect())
+ * .build();
+ * cameraProvider.bindToLifecycle(lifecycleOwner, cameraFilter, useCaseGroup);
+ * </code></pre>
*
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public interface ImageProcessor {
- // TODO(b/229629890): create the public interface for post-processing images.
+
+ /**
+ * Accepts original frames from CameraX and returns processed frames.
+ *
+ * <p>CameraX invokes this method for each batch of images from the camera. It's invoked on the
+ * {@link Executor} provided in {@link CameraEffect}'s constructor. It might be called in
+ * parallel, should the {@link Executor} allow multi-threading. The implementation must block
+ * the current calling thread until the output image is returned.
+ *
+ * <p>The implementation must follow the instruction in the {@link Request} to process the
+ * image. For example, it must produce an output image with the format following the JavaDoc of
+ * {@link Request#getInputImages()}. Failing to do so might cause the processing to
+ * fail. For example, for {@link ImageCapture}, when the processing fails, the app will
+ * receive a {@link ImageCapture.OnImageSavedCallback#onError} or
+ * {@link ImageCapture.OnImageCapturedCallback#onError} callback.
+ *
+ * <p>The implementation should throw exceptions if it runs into any unrecoverable errors.
+ * CameraX will catch the error and deliver it to the app via the error callbacks.
+ *
+ * @param request a {@link Request} that contains original images.
+ * @return a {@link Response} that contains processed image.
+ */
+ @NonNull
+ Response process(@NonNull Request request);
+
+ /**
+ * A request for processing one or many {@link ImageProxy}.
+ */
+ interface Request {
+
+ /**
+ * Gets the input images from Camera.
+ *
+ * <p>It may return a single image captured by the camera, or multiple images from a
+ * burst of capture depending on the configuration in {@link CameraEffect}.
+ *
+ * <p>Currently this method only returns a single image.
+ *
+ * @return one or many input images.
+ */
+ @NonNull
+ @AnyThread
+ List<ImageProxy> getInputImages();
+
+ /**
+ * Gets the output image format.
+ *
+ * <p>The {@link Response}'s {@link ImageProxy} must follow the instruction in this
+ * JavaDoc, or CameraX may throw error.
+ *
+ * <p>For {@link PixelFormat#RGBA_8888}, the output image must contain a single plane
+ * with a pixel stride of 4 and a row stride of width * 4. e.g. each pixel is stored on 4
+ * bytes and each RGBA channel is stored with 8 bits of precision. For more details, see the
+ * JavaDoc of {@code Bitmap.Config#ARGB_8888}.
+ *
+ * <p>Currently this method only returns {@link PixelFormat#RGBA_8888}.
+ */
+ @AnyThread
+ int getOutputFormat();
+ }
+
+ /**
+ * A response for injecting an {@link ImageProxy} back to CameraX.
+ */
+ interface Response {
+
+ /**
+ * Gets the output image of the {@link ImageProcessor}
+ *
+ * <p>{@link ImageProcessor} should implement the {@link ImageProxy} and
+ * {@link ImageProxy.PlaneProxy} interfaces to create the {@link ImageProxy} instance.
+ * CameraX will inject the image back to the processing pipeline.
+ *
+ * <p>The {@link ImageProxy} must follow the instruction in the request, or CameraX may
+ * throw error. For example, the format must match the value of
+ * {@link Request#getOutputFormat()}, and the pixel stride must match the description for
+ * that format in {@link Request#getOutputFormat()}'s JavaDoc.
+ *
+ * @return the output image.
+ */
+ @Nullable
+ @AnyThread
+ ImageProxy getOutputImage();
+ }
}
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/processing/ImageProcessorRequest.java b/camera/camera-core/src/main/java/androidx/camera/core/processing/ImageProcessorRequest.java
new file mode 100644
index 0000000..5d5af9c
--- /dev/null
+++ b/camera/camera-core/src/main/java/androidx/camera/core/processing/ImageProcessorRequest.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2022 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.camera.core.processing;
+
+import android.os.Build;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.camera.core.ImageProcessor;
+import androidx.camera.core.ImageProxy;
+
+import java.util.List;
+
+/**
+ * Internal implementation of {@link ImageProcessor.Request} for sending {@link ImageProxy} to
+ * effect implementations.
+ */
+@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
+public class ImageProcessorRequest implements ImageProcessor.Request {
+ @NonNull
+ private final List<ImageProxy> mImageProxies;
+ private final int mOutputFormat;
+
+ public ImageProcessorRequest(@NonNull List<ImageProxy> imageProxies, int outputFormat) {
+ mImageProxies = imageProxies;
+ mOutputFormat = outputFormat;
+ }
+
+ @NonNull
+ @Override
+ public List<ImageProxy> getInputImages() {
+ return mImageProxies;
+ }
+
+ @Override
+ public int getOutputFormat() {
+ return mOutputFormat;
+ }
+}
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/processing/InternalImageProcessor.java b/camera/camera-core/src/main/java/androidx/camera/core/processing/InternalImageProcessor.java
new file mode 100644
index 0000000..7db28130
--- /dev/null
+++ b/camera/camera-core/src/main/java/androidx/camera/core/processing/InternalImageProcessor.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2022 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.camera.core.processing;
+
+import static androidx.camera.core.ImageCapture.ERROR_UNKNOWN;
+import static androidx.core.util.Preconditions.checkArgument;
+
+import static java.util.Objects.requireNonNull;
+
+import android.os.Build;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.camera.core.CameraEffect;
+import androidx.camera.core.ImageCaptureException;
+import androidx.camera.core.ImageProcessor;
+import androidx.concurrent.futures.CallbackToFutureAdapter;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+
+/**
+ * An internal {@link ImageProcessor} that wraps a {@link CameraEffect} targeting
+ * {@link CameraEffect#IMAGE_CAPTURE}.
+ *
+ * <p>This class wrap calls to {@link ImageProcessor} with the effect-provided {@link Executor}.
+ * It also provides additional from Camera
+ */
+@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
+public class InternalImageProcessor {
+
+ @NonNull
+ private final Executor mExecutor;
+ @NonNull
+ private final ImageProcessor mImageProcessor;
+
+ public InternalImageProcessor(@NonNull CameraEffect cameraEffect) {
+ checkArgument(cameraEffect.getTargets() == CameraEffect.IMAGE_CAPTURE);
+ mExecutor = cameraEffect.getProcessorExecutor();
+ mImageProcessor = requireNonNull(cameraEffect.getImageProcessor());
+ }
+
+ /**
+ * Forwards the call to {@link ImageProcessor#process} on the effect-provided executor.
+ */
+ @NonNull
+ public ImageProcessor.Response safeProcess(@NonNull ImageProcessor.Request request)
+ throws ImageCaptureException {
+ try {
+ return CallbackToFutureAdapter.getFuture(
+ (CallbackToFutureAdapter.Resolver<ImageProcessor.Response>) completer -> {
+ mExecutor.execute(() -> {
+ try {
+ completer.set(mImageProcessor.process(request));
+ } catch (Exception e) {
+ // Catch all exceptions and forward it CameraX.
+ completer.setException(e);
+ }
+ });
+ return "InternalImageProcessor#process " + request.hashCode();
+ }).get();
+ } catch (ExecutionException | InterruptedException e) {
+ Throwable cause = e.getCause() != null ? e.getCause() : e;
+ throw new ImageCaptureException(
+ ERROR_UNKNOWN, "Failed to invoke ImageProcessor.", cause);
+ }
+ }
+}
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/processing/InternalImageProcessorTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/processing/InternalImageProcessorTest.kt
new file mode 100644
index 0000000..325bb1b
--- /dev/null
+++ b/camera/camera-core/src/test/java/androidx/camera/core/processing/InternalImageProcessorTest.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2022 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.camera.core.processing
+
+import android.graphics.PixelFormat
+import androidx.camera.core.CameraEffect
+import androidx.camera.core.ImageCaptureException
+import androidx.camera.core.ImageProcessor
+import androidx.camera.core.ImageProcessor.Response
+import androidx.camera.core.impl.utils.executor.CameraXExecutors.highPriorityExecutor
+import androidx.camera.testing.fakes.FakeImageInfo
+import androidx.camera.testing.fakes.FakeImageProxy
+import com.google.common.truth.Truth.assertThat
+import java.lang.Thread.currentThread
+import java.util.concurrent.Executors.newSingleThreadExecutor
+import org.junit.Assert.fail
+import org.junit.Test
+
+/**
+ * Unit tests for [InternalImageProcessor].
+ */
+class InternalImageProcessorTest {
+
+ companion object {
+ private const val THREAD_NAME = "thread_name"
+ }
+
+ @Test
+ fun processorThrowsError_errorIsPropagatedToCameraX() {
+ // Arrange.
+ val exception = RuntimeException()
+ val cameraEffect = CameraEffect.Builder(CameraEffect.IMAGE_CAPTURE)
+ .setImageProcessor(highPriorityExecutor()) { throw exception }
+ .build()
+ val imageProcessor = InternalImageProcessor(cameraEffect)
+
+ // Act.
+ try {
+ imageProcessor.safeProcess(
+ ImageProcessorRequest(
+ listOf(FakeImageProxy(FakeImageInfo())),
+ PixelFormat.RGBA_8888
+ )
+ )
+ fail("Processor should throw exception")
+ } catch (ex: ImageCaptureException) {
+ // Assert.
+ assertThat(ex.cause).isEqualTo(exception)
+ }
+ }
+
+ @Test
+ fun process_appCallbackInvokedOnAppExecutor() {
+ // Arrange.
+ val imageToEffect = FakeImageProxy(FakeImageInfo())
+ val imageFromEffect = FakeImageProxy(FakeImageInfo())
+ var calledThreadName = ""
+ val processor = ImageProcessor {
+ calledThreadName = currentThread().name
+ Response { imageFromEffect }
+ }
+ val executor = newSingleThreadExecutor { Thread(it, THREAD_NAME) }
+ val cameraEffect = CameraEffect.Builder(CameraEffect.IMAGE_CAPTURE)
+ .setImageProcessor(executor, processor)
+ .build()
+ val imageProcessor = InternalImageProcessor(cameraEffect)
+
+ // Act.
+ val outputImage = imageProcessor.safeProcess(
+ ImageProcessorRequest(listOf(imageToEffect), PixelFormat.RGBA_8888)
+ ).outputImage
+
+ // Assert.
+ assertThat(outputImage).isEqualTo(imageFromEffect)
+ assertThat(calledThreadName).isEqualTo(THREAD_NAME)
+ }
+}
\ No newline at end of file