TransformUtil.kt

/*
 * Copyright 2024 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.viewfinder.compose.internal

import android.graphics.Matrix
import android.graphics.Rect
import android.graphics.RectF
import android.util.Size
import android.view.Surface
import android.view.TextureView
import androidx.core.graphics.toRect

// Normalized space (-1, -1) - (1, 1).
private val NORMALIZED_RECT = RectF(-1f, -1f, 1f, 1f)

object TransformUtil {

    /**
     * Converts [Surface] rotation to rotation degrees: 90, 180, 270 or 0.
     */
    @JvmStatic
    fun surfaceRotationToRotationDegrees(rotationValue: Int): Int = when (rotationValue) {
        Surface.ROTATION_0 -> 0
        Surface.ROTATION_90 -> 90
        Surface.ROTATION_180 -> 180
        Surface.ROTATION_270 -> 270
        else -> throw UnsupportedOperationException(
            "Unsupported display rotation: $rotationValue"
        )
    }

    /**
     * Calculates the delta between a source rotation and destination rotation.
     *
     * <p>A typical use of this method would be calculating the angular difference between the
     * display orientation (destRotationDegrees) and camera sensor orientation
     * (sourceRotationDegrees).
     *
     * @param destRotationDegrees   The destination rotation relative to the device's natural
     *                              rotation.
     * @param sourceRotationDegrees The source rotation relative to the device's natural rotation.
     * @param isOppositeFacing      Whether the source and destination planes are facing opposite
     *                              directions.
     */
    @JvmStatic
    fun calculateRelativeImageRotationDegrees(
        destRotationDegrees: Int,
        sourceRotationDegrees: Int,
        isOppositeFacing: Boolean
    ): Int = if (isOppositeFacing) {
        (sourceRotationDegrees - destRotationDegrees + 360) % 360
    } else {
        (sourceRotationDegrees + destRotationDegrees) % 360
    }

    /**
     * Calculates the transformation and applies it to the inner view of [TextureView] preview.
     *
     * [TextureView] needs a preliminary correction since it doesn't handle the
     * display rotation.
     */
    @JvmStatic
    fun transformTextureView(
        preview: TextureView,
        containerViewSize: Size,
        resolution: Size,
        targetRotation: Int,
        sensorRotationDegrees: Int,
        isOppositeFacing: Boolean
    ) {
        // For TextureView, correct the orientation to match the target rotation.
        preview.setTransform(getTextureViewCorrectionMatrix(resolution, targetRotation))

        val surfaceRectInPreview = getTransformedSurfaceRect(
            containerViewSize,
            resolution,
            calculateRelativeImageRotationDegrees(
                surfaceRotationToRotationDegrees(targetRotation),
                sensorRotationDegrees,
                isOppositeFacing
            )
        )

        preview.pivotX = 0f
        preview.pivotY = 0f
        preview.scaleX = surfaceRectInPreview.width() / resolution.width
        preview.scaleY = surfaceRectInPreview.height() / resolution.height
        preview.translationX = surfaceRectInPreview.left - preview.left
        preview.translationY = surfaceRectInPreview.top - preview.top
    }

    /**
     * Creates a matrix that makes [TextureView]'s rotation matches the target rotation.
     *
     * The value should be applied by calling [TextureView.setTransform]. Usually the target
     * rotation is the display rotation. In that case, this matrix will just make a [TextureView]
     * works like a SurfaceView. If not, then it will further correct it to the desired rotation.
     *
     */
    @JvmStatic
    private fun getTextureViewCorrectionMatrix(resolution: Size, targetRotation: Int): Matrix {
        val surfaceRect = RectF(0f, 0f, resolution.width.toFloat(), resolution.height.toFloat())
        val rotationDegrees = -surfaceRotationToRotationDegrees(targetRotation)
        return getRectToRect(surfaceRect, surfaceRect, rotationDegrees)
    }

    /**
     * Gets the transform from one {@link Rect} to another with rotation degrees.
     *
     * <p> Following is how the source is mapped to the target with a 90° rotation. The rect
     * <a, b, c, d> is mapped to <a', b', c', d'>.
     *
     * <pre>
     *  a----------b               d'-----------a'
     *  |  source  |    -90°->     |            |
     *  d----------c               |   target   |
     *                             |            |
     *                             c'-----------b'
     * </pre>
     */
    @JvmStatic
    internal fun getRectToRect(
        source: RectF,
        target: RectF,
        rotationDegrees: Int
    ): Matrix = Matrix().apply {
        // Map source to normalized space.
        setRectToRect(source, NORMALIZED_RECT, Matrix.ScaleToFit.FILL)
        // Add rotation.
        postRotate(rotationDegrees.toFloat())
        // Restore the normalized space to target's coordinates.
        postConcat(getNormalizedToBuffer(target))
    }

    /**
     * Gets the transform from a normalized space (-1, -1) - (1, 1) to the given rect.
     */
    @JvmStatic
    private fun getNormalizedToBuffer(viewPortRect: RectF): Matrix = Matrix().apply {
        setRectToRect(
            NORMALIZED_RECT,
            viewPortRect,
            Matrix.ScaleToFit.FILL
        )
    }

    /**
     * Gets the transformed [Surface] rect in the preview coordinates.
     *
     * Returns desired rect of the inner view that once applied, the only part visible to
     * end users is the crop rect.
     */
    @JvmStatic
    private fun getTransformedSurfaceRect(
        containerViewSize: Size,
        resolution: Size,
        rotationDegrees: Int
    ): RectF {
        val surfaceToPreviewMatrix = getSurfaceToPreviewMatrix(
            containerViewSize,
            resolution,
            rotationDegrees
        )
        val rect =
            RectF(0f, 0f, resolution.width.toFloat(), resolution.height.toFloat())
        surfaceToPreviewMatrix.mapRect(rect)
        return rect
    }

    /**
     * Calculates the transformation from [Surface] coordinates to the preview coordinates.
     *
     *  The calculation is based on making the crop rect to center fill the preview.
     */
    @JvmStatic
    private fun getSurfaceToPreviewMatrix(
        containerViewSize: Size,
        resolution: Size,
        rotationDegrees: Int
    ): Matrix {
        val surfaceRect = RectF(0f, 0f, resolution.width.toFloat(), resolution.height.toFloat())

        // Get the target of the mapping, the coordinates of the crop rect in the preview.
        val previewCropRect = getPreviewCropRect(
            containerViewSize, surfaceRect.toRect(), rotationDegrees
        )

        return getRectToRect(surfaceRect, previewCropRect, rotationDegrees)
    }

    /**
     * Gets the crop rect in the preview coordinates.
     */
    @JvmStatic
    private fun getPreviewCropRect(
        containerViewSize: Size,
        surfaceCropRect: Rect,
        rotationDegrees: Int
    ): RectF {
        val containerViewRect = RectF(
            0f, 0f, containerViewSize.width.toFloat(),
            containerViewSize.height.toFloat()
        )
        val rotatedCropRectSize = getRotatedCropRectSize(surfaceCropRect, rotationDegrees)
        val rotatedCropRect =
            RectF(0f, 0f, rotatedCropRectSize.width.toFloat(), rotatedCropRectSize.height.toFloat())

        Matrix().apply {
            // android.graphics.Matrix doesn't support fill scale types. The workaround is
            // mapping inversely from destination to source, then invert the matrix.
            setRectToRect(containerViewRect, rotatedCropRect, Matrix.ScaleToFit.CENTER)
            invert(this)
            mapRect(rotatedCropRect)
        }

        return rotatedCropRect
    }

    /**
     * Returns crop rect size with target rotation applied.
     */
    @JvmStatic
    private fun getRotatedCropRectSize(surfaceCropRect: Rect, rotationDegrees: Int): Size =
        if (is90or270(rotationDegrees)) {
            Size(surfaceCropRect.height(), surfaceCropRect.width())
        } else Size(surfaceCropRect.width(), surfaceCropRect.height())

    /**
     * Returns true if the rotation degrees is 90 or 270.
     */
    @JvmStatic
    internal fun is90or270(rotationDegrees: Int): Boolean {
        if (rotationDegrees == 90 || rotationDegrees == 270) {
            return true
        }
        if (rotationDegrees == 0 || rotationDegrees == 180) {
            return false
        }
        throw IllegalArgumentException("Invalid rotation degrees: $rotationDegrees")
    }

    /**
     * Checks if aspect ratio matches while tolerating rounding error.
     *
     *
     *  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 ViewFinder's
     * width/height are prime numbers 601x797, the crop rect from other use cases cannot have a
     * matching aspect ratio even if they are based on the same viewport. This method checks the
     * aspect ratio while tolerating a rounding error.
     *
     * @param size1       the rounded size1
     * @param isAccurate1 if size1 is accurate. e.g. it's true if it's the PreviewView's
     * dimension which viewport is based on
     * @param size2       the rounded size2
     * @param isAccurate2 if size2 is accurate.
     */
    fun isAspectRatioMatchingWithRoundingError(
        size1: Size,
        isAccurate1: Boolean,
        size2: Size,
        isAccurate2: Boolean
    ): Boolean {
        // The crop rect coordinates are rounded values. Each value is at most .5 away from their
        // true values. So the width/height, which is the difference of 2 coordinates, are at most
        // 1.0 away from their true value.
        // First figure out the possible range of the aspect ratio's true value.
        val ratio1UpperBound: Float
        val ratio1LowerBound: Float
        if (isAccurate1) {
            ratio1UpperBound = size1.width.toFloat() / size1.height
            ratio1LowerBound = ratio1UpperBound
        } else {
            ratio1UpperBound = (size1.width + 1f) / (size1.height - 1f)
            ratio1LowerBound = (size1.width - 1f) / (size1.height + 1f)
        }
        val ratio2UpperBound: Float
        val ratio2LowerBound: Float
        if (isAccurate2) {
            ratio2UpperBound = size2.width.toFloat() / size2.height
            ratio2LowerBound = ratio2UpperBound
        } else {
            ratio2UpperBound = (size2.width + 1f) / (size2.height - 1f)
            ratio2LowerBound = (size2.width - 1f) / (size2.height + 1f)
        }
        // Then we check if the true value range overlaps.
        return ratio1UpperBound >= ratio2LowerBound && ratio2UpperBound >= ratio1LowerBound
    }
}