AspectRatioUtil.java

/*
 * 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.impl.utils;

import static androidx.camera.core.internal.utils.SizeUtil.getArea;

import android.graphics.RectF;
import android.util.Rational;
import android.util.Size;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.camera.core.internal.utils.SizeUtil;
import androidx.core.util.Preconditions;

import java.util.Comparator;

/**
 * Utility class for aspect ratio related operations.
 */
@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
public final class AspectRatioUtil {
    public static final Rational ASPECT_RATIO_4_3 = new Rational(4, 3);
    public static final Rational ASPECT_RATIO_3_4 = new Rational(3, 4);
    public static final Rational ASPECT_RATIO_16_9 = new Rational(16, 9);
    public static final Rational ASPECT_RATIO_9_16 = new Rational(9, 16);

    private static final int ALIGN16 = 16;

    private AspectRatioUtil() {
    }

    /**
     * Returns true if the supplied resolution is a mod16 matching with the supplied aspect ratio.
     *
     * <p>A default lower bound resolution {@link SizeUtil#RESOLUTION_VGA} is adopted. That means
     * only do mod 16 calculation if the size is equal to or larger than
     * {@link SizeUtil#RESOLUTION_VGA}. It is because the aspect ratio will be affected
     * critically by mod 16 calculation if the size is small. It may result in unexpected outcome
     * such like 256x144 will be considered as 18.5:9.
     */
    public static boolean hasMatchingAspectRatio(@NonNull Size resolution,
            @Nullable Rational aspectRatio) {
        return hasMatchingAspectRatio(resolution, aspectRatio, SizeUtil.RESOLUTION_VGA);
    }

    /**
     * Returns true if the supplied resolution is a mod16 matching with the supplied aspect ratio.
     *
     * <p>Mod 16 calculation take effects only when the input resolution is smaller than
     * {@code mod16ResolutionLowerBound}.
     */
    public static boolean hasMatchingAspectRatio(@NonNull Size resolution,
            @Nullable Rational aspectRatio, @NonNull Size mod16ResolutionLowerBound) {
        boolean isMatch = false;
        if (aspectRatio == null) {
            isMatch = false;
        } else if (aspectRatio.equals(
                new Rational(resolution.getWidth(), resolution.getHeight()))) {
            isMatch = true;
        } else if (getArea(resolution) >= getArea(mod16ResolutionLowerBound)) {
            isMatch = isPossibleMod16FromAspectRatio(resolution, aspectRatio);
        }
        return isMatch;
    }

    /**
     * For codec performance improvement, OEMs may make the supported sizes to be mod16 alignment
     * . It means that the width or height of the supported size will be multiple of 16. The
     * result number after applying mod16 alignment can be the larger or smaller number that is
     * multiple of 16 and is closest to the original number. For example, a standard 16:9
     * supported size is 1920x1080. It may become 1920x1088 on some devices because 1088 is
     * multiple of 16. This function uses the target aspect ratio to calculate the possible
     * original width or height inversely. And then, checks whether the possibly original width or
     * height is in the range that the mod16 aligned height or width can support.
     */
    private static boolean isPossibleMod16FromAspectRatio(@NonNull Size resolution,
            @NonNull Rational aspectRatio) {
        int width = resolution.getWidth();
        int height = resolution.getHeight();

        Rational invAspectRatio = new Rational(/* numerator= */aspectRatio.getDenominator(),
                /* denominator= */aspectRatio.getNumerator());
        if (width % 16 == 0 && height % 16 == 0) {
            return ratioIntersectsMod16Segment(Math.max(0, height - ALIGN16), width, aspectRatio)
                    || ratioIntersectsMod16Segment(Math.max(0, width - ALIGN16), height,
                    invAspectRatio);
        } else if (width % 16 == 0) {
            return ratioIntersectsMod16Segment(height, width, aspectRatio);
        } else if (height % 16 == 0) {
            return ratioIntersectsMod16Segment(width, height, invAspectRatio);
        }
        return false;
    }

    private static boolean ratioIntersectsMod16Segment(int height, int mod16Width,
            Rational aspectRatio) {
        Preconditions.checkArgument(mod16Width % 16 == 0);
        double aspectRatioWidth =
                height * aspectRatio.getNumerator() / (double) aspectRatio.getDenominator();
        return aspectRatioWidth > Math.max(0, mod16Width - ALIGN16) && aspectRatioWidth < (
                mod16Width + ALIGN16);
    }

    /**
     * Comparator based on how close they are to the target aspect ratio by comparing the
     * transformed mapping area in the full FOV ratio space.
     *
     * The mapping area will be the region that the images of the specific aspect ratio cropped
     * from the full FOV images. Therefore, we can compare the mapping areas to know which one is
     * closer to the mapping area of the target aspect ratio setting.
     */
    @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
    public static final class CompareAspectRatiosByMappingAreaInFullFovAspectRatioSpace implements
            Comparator<Rational> {
        private final Rational mTargetRatio;
        private final RectF mTransformedMappingArea;
        private final Rational mFullFovRatio;

        public CompareAspectRatiosByMappingAreaInFullFovAspectRatioSpace(
                @NonNull Rational targetRatio, @Nullable Rational fullFovRatio) {
            mTargetRatio = targetRatio;
            mFullFovRatio = fullFovRatio != null ? fullFovRatio : new Rational(4, 3);
            mTransformedMappingArea = getTransformedMappingArea(mTargetRatio);
        }

        @Override
        public int compare(Rational lhs, Rational rhs) {
            if (lhs.equals(rhs)) {
                return 0;
            }

            RectF lhsMappingArea = getTransformedMappingArea(lhs);
            RectF rhsMappingArea = getTransformedMappingArea(rhs);

            boolean isCoveredByLhs = isMappingAreaCovered(lhsMappingArea,
                    mTransformedMappingArea);
            boolean isCoveredByRhs = isMappingAreaCovered(rhsMappingArea,
                    mTransformedMappingArea);

            if (isCoveredByLhs && isCoveredByRhs) {
                // When both ratios can cover the transformed target aspect mapping area in the
                // full FOV space, checks which area is smaller to determine which ratio is
                // closer to the target aspect ratio.
                return (int) Math.signum(
                        getMappingAreaSize(lhsMappingArea) - getMappingAreaSize(rhsMappingArea));
            } else if (isCoveredByLhs) {
                return -1;
            } else if (isCoveredByRhs) {
                return 1;
            } else {
                // When both ratios can't cover the transformed target aspect mapping area in the
                // full FOV space, checks which overlapping area is larger to determine which
                // ratio is closer to the target aspect ratio.
                float lhsOverlappingArea = getOverlappingAreaSize(lhsMappingArea,
                        mTransformedMappingArea);
                float rhsOverlappingArea = getOverlappingAreaSize(rhsMappingArea,
                        mTransformedMappingArea);
                return -((int) Math.signum(lhsOverlappingArea - rhsOverlappingArea));
            }
        }

        /**
         * Returns the rectangle after transforming the input rational into full FOV aspect ratio
         * space.
         */
        private RectF getTransformedMappingArea(Rational ratio) {
            if (ratio.floatValue() == mFullFovRatio.floatValue()) {
                return new RectF(0, 0, mFullFovRatio.getNumerator(),
                        mFullFovRatio.getDenominator());
            } else if (ratio.floatValue() > mFullFovRatio.floatValue()) {
                return new RectF(0, 0, mFullFovRatio.getNumerator(),
                        (float) ratio.getDenominator() * (float) mFullFovRatio.getNumerator()
                                / (float) ratio.getNumerator());
            } else {
                return new RectF(0, 0,
                        (float) ratio.getNumerator() * (float) mFullFovRatio.getDenominator()
                                / (float) ratio.getDenominator(), mFullFovRatio.getDenominator());
            }
        }

        /**
         * Returns {@code true} if the source transformed mapping area can fully cover the target
         * transformed mapping area. Otherwise, returns {@code false};
         */
        private boolean isMappingAreaCovered(RectF sourceMappingArea, RectF targetMappingArea) {
            return sourceMappingArea.width() >= targetMappingArea.width()
                    && sourceMappingArea.height() >= targetMappingArea.height();
        }

        /**
         * Returns the input mapping area's size value.
         */
        private float getMappingAreaSize(RectF mappingArea) {
            return mappingArea.width() * mappingArea.height();
        }

        /**
         * Returns the overlapping area value between the input two mapping areas in the full FOV
         * space.
         */
        private float getOverlappingAreaSize(RectF mappingArea1, RectF mappingArea2) {
            float overlappingAreaWidth = mappingArea1.width() < mappingArea2.width()
                    ? mappingArea1.width() : mappingArea2.width();
            float overlappingAreaHeight = mappingArea1.height() < mappingArea2.height()
                    ? mappingArea1.height() : mappingArea2.height();
            return overlappingAreaWidth * overlappingAreaHeight;
        }
    }
}