Checks if the cropped aspect ratio matches the output size

Mismatched aspect ratio leads to stretched output, which is undesirable.

Bug: 259308680
Test: manual test and ./gradlew bOS
Change-Id: Iad5099d9662f614def9c4696985fc6632aa718f2
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/utils/TransformUtils.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/utils/TransformUtils.java
index 87c1c95..b0ddbab 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/impl/utils/TransformUtils.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/utils/TransformUtils.java
@@ -207,6 +207,17 @@
     /**
      * Checks if aspect ratio matches while tolerating rounding error.
      *
+     * @see #isAspectRatioMatchingWithRoundingError(Size, boolean, Size, boolean)
+     */
+    public static boolean isAspectRatioMatchingWithRoundingError(
+            @NonNull Size size1, @NonNull Size size2) {
+        return isAspectRatioMatchingWithRoundingError(
+                size1, /*isAccurate1=*/ false, size2, /*isAccurate2=*/ false);
+    }
+
+    /**
+     * Checks if aspect ratio matches while tolerating rounding error.
+     *
      * <p> One example of the usage is comparing the viewport-based crop rect from different use
      * cases. The crop rect is rounded because pixels are integers, which may introduce an error
      * when we check if the aspect ratio matches. For example, when
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceProcessorNode.java b/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceProcessorNode.java
index d00f151..a72db62 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceProcessorNode.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceProcessorNode.java
@@ -18,10 +18,12 @@
 
 import static androidx.camera.core.impl.utils.TransformUtils.getRectToRect;
 import static androidx.camera.core.impl.utils.TransformUtils.getRotatedSize;
+import static androidx.camera.core.impl.utils.TransformUtils.isAspectRatioMatchingWithRoundingError;
 import static androidx.camera.core.impl.utils.TransformUtils.sizeToRect;
 import static androidx.camera.core.impl.utils.TransformUtils.sizeToRectF;
 import static androidx.camera.core.impl.utils.TransformUtils.within360;
 import static androidx.camera.core.impl.utils.executor.CameraXExecutors.mainThreadExecutor;
+import static androidx.core.util.Preconditions.checkArgument;
 
 import android.graphics.Rect;
 import android.util.Size;
@@ -132,8 +134,11 @@
                 sizeToRectF(outConfig.getSize()), rotationDegrees, mirroring);
         sensorToBufferTransform.postConcat(imageTransform);
 
-        // TODO(b/259308680): Checks that the aspect ratio of the rotated crop rect matches the
-        //  output size.
+        // The aspect ratio of the output must match the aspect ratio of the crop rect. Otherwise
+        // the output will be stretched.
+        Size rotatedCropSize = getRotatedSize(outConfig.getCropRect(), rotationDegrees);
+        checkArgument(isAspectRatioMatchingWithRoundingError(rotatedCropSize, outConfig.getSize()));
+
         outputSurface = new SettableSurface(
                 outConfig.getTargets(),
                 outConfig.getSize(),
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceProcessorNodeTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceProcessorNodeTest.kt
index 9139c14..a5a6cde 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceProcessorNodeTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceProcessorNodeTest.kt
@@ -29,6 +29,7 @@
 import androidx.camera.core.SurfaceRequest.TransformationInfo
 import androidx.camera.core.impl.utils.TransformUtils.is90or270
 import androidx.camera.core.impl.utils.TransformUtils.rectToSize
+import androidx.camera.core.impl.utils.TransformUtils.rotateSize
 import androidx.camera.core.impl.utils.TransformUtils.sizeToRect
 import androidx.camera.core.impl.utils.executor.CameraXExecutors.mainThreadExecutor
 import androidx.camera.core.processing.SurfaceProcessorNode.OutConfig
@@ -111,8 +112,10 @@
         for (rotationDegrees in arrayOf(0, 90, 180, 270)) {
             // Arrange.
             createSurfaceProcessorNode()
+            val videoOutputSize = rotateSize(VIDEO_SIZE, rotationDegrees - ROTATION_DEGREES)
             createInputEdge(
-                previewRotationDegrees = rotationDegrees
+                previewRotationDegrees = rotationDegrees,
+                videoOutputSize = videoOutputSize
             )
             // The result cropRect should have zero left and top.
             val expectedCropRect = if (is90or270(rotationDegrees))
@@ -130,8 +133,8 @@
             assertThat(previewOutput.cropRect).isEqualTo(expectedCropRect)
             assertThat(previewOutput.rotationDegrees).isEqualTo(0)
             val videoOutput = nodeOutput[videoOutConfig]!!
-            assertThat(videoOutput.size).isEqualTo(VIDEO_SIZE)
-            assertThat(videoOutput.cropRect).isEqualTo(sizeToRect(VIDEO_SIZE))
+            assertThat(videoOutput.size).isEqualTo(videoOutputSize)
+            assertThat(videoOutput.cropRect).isEqualTo(sizeToRect(videoOutputSize))
             assertThat(videoOutput.rotationDegrees).isEqualTo(0)
 
             // Clean up.
@@ -141,6 +144,15 @@
         }
     }
 
+    @Test(expected = IllegalArgumentException::class)
+    fun cropSizeMismatchesOutputSize_throwsException() {
+        createSurfaceProcessorNode()
+        createInputEdge(
+            videoOutputSize = Size(VIDEO_SIZE.width - 2, VIDEO_SIZE.height + 2)
+        )
+        node.transform(nodeInput)
+    }
+
     @Test
     fun transformInput_applyCropRotateAndMirroring_outputHasNoMirroring() {
         for (mirroring in arrayOf(false, true)) {
@@ -267,6 +279,7 @@
         previewCropRect: Rect = PREVIEW_CROP_RECT,
         previewRotationDegrees: Int = ROTATION_DEGREES,
         mirroring: Boolean = MIRRORING,
+        videoOutputSize: Size = VIDEO_SIZE
     ) {
         val surface = SettableSurface(
             previewTarget,
@@ -281,7 +294,7 @@
         videoOutConfig = OutConfig.of(
             VIDEO_CAPTURE,
             VIDEO_CROP_RECT,
-            VIDEO_SIZE
+            videoOutputSize
         )
         previewOutConfig = OutConfig.of(surface)
         nodeInput = SurfaceProcessorNode.In.of(
diff --git a/camera/camera-view/src/main/java/androidx/camera/view/transform/CoordinateTransform.java b/camera/camera-view/src/main/java/androidx/camera/view/transform/CoordinateTransform.java
index 9c441dc..6994283 100644
--- a/camera/camera-view/src/main/java/androidx/camera/view/transform/CoordinateTransform.java
+++ b/camera/camera-view/src/main/java/androidx/camera/view/transform/CoordinateTransform.java
@@ -83,9 +83,8 @@
         //  the transform from sensor to surface. But it will require the view artifact to
         //  depend on a new internal API in the core artifact, which we can't do at the
         //  moment because of the version mismatch between view and core.
-        if (!isAspectRatioMatchingWithRoundingError(
-                source.getViewPortSize(), /* isAccurate1= */ false,
-                target.getViewPortSize(), /* isAccurate2= */ false)) {
+        if (!isAspectRatioMatchingWithRoundingError(source.getViewPortSize(),
+                target.getViewPortSize())) {
             // Mismatched aspect ratio means the outputs are not associated with the same Viewport.
             Logger.w(TAG, String.format(MISMATCH_MSG, source.getViewPortSize(),
                     target.getViewPortSize()));