1 /*
<lambda>null2  * Copyright 2025 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.camera.viewfinder.core.impl
18 
19 import android.graphics.Matrix
20 import android.graphics.RectF
21 import android.util.Size
22 import android.util.SizeF
23 import android.view.Surface
24 import android.view.TextureView
25 import androidx.camera.viewfinder.core.ScaleType
26 import androidx.camera.viewfinder.core.TransformationInfo
27 
28 // Normalized space (-1, -1) - (1, 1).
29 private val NORMALIZED_RECT = RectF(-1f, -1f, 1f, 1f)
30 
31 object Transformations {
32     /**
33      * Creates a matrix that makes [TextureView]'s rotation matches the display rotation.
34      *
35      * The value should be applied by calling [TextureView.setTransform].
36      */
37     @JvmStatic
38     fun getTextureViewCorrectionMatrix(
39         displayRotationDegrees: Int,
40         width: Int,
41         height: Int
42     ): Matrix {
43         val surfaceRect = RectF(0f, 0f, width.toFloat(), height.toFloat())
44         return getRectToRect(surfaceRect, surfaceRect, -displayRotationDegrees)
45     }
46 
47     /** Converts [Surface] rotation to rotation degrees: 90, 180, 270 or 0. */
48     @JvmStatic
49     fun surfaceRotationToRotationDegrees(@RotationValue rotationValue: Int): Int =
50         when (rotationValue) {
51             Surface.ROTATION_0 -> 0
52             Surface.ROTATION_90 -> 90
53             Surface.ROTATION_180 -> 180
54             Surface.ROTATION_270 -> 270
55             else ->
56                 throw UnsupportedOperationException("Unsupported surface rotation: $rotationValue")
57         }
58 
59     @JvmStatic
60     fun getSurfaceToViewfinderMatrix(
61         viewfinderSize: Size,
62         surfaceResolution: Size,
63         transformationInfo: TransformationInfo,
64         layoutDirection: Int,
65         scaleType: ScaleType
66     ) =
67         getSurfaceToViewfinderMatrix(
68             viewfinderSize = viewfinderSize,
69             surfaceResolution = surfaceResolution,
70             transformationInfo = transformationInfo,
71             layoutDirection = layoutDirection,
72             contentScale = scaleType.contentScale,
73             alignment = scaleType.alignment
74         )
75 
76     @JvmStatic
77     fun getSurfaceToViewfinderMatrix(
78         viewfinderSize: Size,
79         surfaceResolution: Size,
80         transformationInfo: TransformationInfo,
81         layoutDirection: Int,
82         contentScale: ContentScale,
83         alignment: Alignment
84     ): Matrix {
85         val rotatedViewportSize = transformationInfo.rotatedViewportFor(surfaceResolution)
86         // Get the target of the mapping, the coordinates of the crop rect in view finder.
87         val viewfinderCropRect: RectF =
88             if (isViewportAspectRatioMatchViewfinder(rotatedViewportSize, viewfinderSize)) {
89                 // If crop rect has the same aspect ratio as view finder, scale the crop rect to
90                 // fill the entire view finder. This happens if the scale type is FILL_* AND a
91                 // view-finder-based viewport is used.
92                 RectF(0f, 0f, viewfinderSize.width.toFloat(), viewfinderSize.height.toFloat())
93             } else {
94                 // If the aspect ratios don't match, it could be 1) scale type is FIT_*, 2) the
95                 // Viewport is not based on the view finder or 3) both.
96                 getViewfinderViewportRectForMismatchedAspectRatios(
97                     rotatedViewportSize = rotatedViewportSize,
98                     viewfinderSize = viewfinderSize,
99                     layoutDirection = layoutDirection,
100                     contentScale = contentScale,
101                     alignment = alignment
102                 )
103             }
104 
105         val surfaceCropRect = transformationInfo.cropRectFor(surfaceResolution)
106 
107         val matrix =
108             getRectToRect(surfaceCropRect, viewfinderCropRect, transformationInfo.sourceRotation)
109 
110         if (transformationInfo.isSourceMirroredHorizontally) {
111             matrix.preScale(-1f, 1f, surfaceCropRect.centerX(), surfaceCropRect.centerY())
112         }
113 
114         if (transformationInfo.isSourceMirroredVertically) {
115             matrix.preScale(1f, -1f, surfaceCropRect.centerX(), surfaceCropRect.centerY())
116         }
117         return matrix
118     }
119 
120     private fun getViewfinderViewportRectForMismatchedAspectRatios(
121         rotatedViewportSize: SizeF,
122         viewfinderSize: Size,
123         layoutDirection: Int,
124         contentScale: ContentScale,
125         alignment: Alignment,
126     ): RectF {
127         val matrix =
128             Matrix().apply {
129                 setTransform(
130                     source = rotatedViewportSize,
131                     destination = viewfinderSize,
132                     layoutDirection = layoutDirection,
133                     contentScale = contentScale,
134                     alignment = alignment
135                 )
136             }
137         return RectF(
138                 0f,
139                 0f,
140                 rotatedViewportSize.width.toFloat(),
141                 rotatedViewportSize.height.toFloat()
142             )
143             .also(matrix::mapRect)
144     }
145 
146     internal fun isViewportAspectRatioMatchViewfinder(
147         rotatedViewportSize: SizeF,
148         viewfinderSize: Size
149     ): Boolean =
150         isAspectRatioMatchingWithRoundingError(rotatedViewportSize, false, viewfinderSize, true)
151 
152     /**
153      * Gets the transform from one {@link Rect} to another with rotation degrees.
154      *
155      * <p> Following is how the source is mapped to the target with a 90° rotation. The rect <a, b,
156      * c, d> is mapped to <a', b', c', d'>.
157      * <pre>
158      *  a----------b               d'-----------a'
159      *  |  source  |    -90°->     |            |
160      *  d----------c               |   target   |
161      *                             |            |
162      *                             c'-----------b'
163      * </pre>
164      */
165     private fun getRectToRect(source: RectF, target: RectF, rotationDegrees: Int): Matrix =
166         Matrix().apply {
167             // Map source to normalized space.
168             setRectToRect(source, NORMALIZED_RECT, Matrix.ScaleToFit.FILL)
169             // Add rotation.
170             postRotate(rotationDegrees.toFloat())
171             // Restore the normalized space to target's coordinates.
172             postConcat(getNormalizedToBuffer(target))
173         }
174 
175     /** Returns true if the rotation degrees is 90 or 270. */
176     private fun is90or270(rotationDegrees: Int) =
177         when (rotationDegrees) {
178             90,
179             270 -> true
180             0,
181             180 -> false
182             else -> throw IllegalArgumentException("Invalid rotation degrees: $rotationDegrees")
183         }
184 
185     private fun Matrix.setTransform(
186         source: SizeF,
187         destination: Size,
188         layoutDirection: Int,
189         contentScale: ContentScale,
190         alignment: Alignment,
191     ) {
192         contentScale.computeScaleFactor(source, destination.toSizeF()).let { scaleFactor ->
193             this@setTransform.setScale(scaleFactor.scaleX, scaleFactor.scaleY)
194 
195             val scaledSource =
196                 SizeF(source.width * scaleFactor.scaleX, source.height * scaleFactor.scaleY)
197             alignment.align(scaledSource, destination.toSizeF(), layoutDirection).let { offset ->
198                 this@setTransform.postTranslate(offset.x, offset.y)
199             }
200         }
201     }
202 
203     /**
204      * Checks if aspect ratio matches while tolerating rounding error.
205      *
206      * One example of the usage is comparing the viewport-based crop rect from different use cases.
207      * The crop rect is rounded because pixels are integers, which may introduce an error when we
208      * check if the aspect ratio matches. For example, when Viewfinder's width/height are prime
209      * numbers 601x797, the crop rect from other use cases cannot have a matching aspect ratio even
210      * if they are based on the same viewport. This method checks the aspect ratio while tolerating
211      * a rounding error.
212      *
213      * @param size1 the rounded size1
214      * @param isAccurate1 if size1 is accurate. e.g. it's true if it's the PreviewView's dimension
215      *   which viewport is based on
216      * @param size2 the rounded size2
217      * @param isAccurate2 if size2 is accurate.
218      */
219     private fun isAspectRatioMatchingWithRoundingError(
220         size1: SizeF,
221         isAccurate1: Boolean,
222         size2: Size,
223         isAccurate2: Boolean
224     ): Boolean {
225         // The crop rect coordinates are rounded values. Each value is at most .5 away from their
226         // true values. So the width/height, which is the difference of 2 coordinates, are at most
227         // 1.0 away from their true value.
228         // First figure out the possible range of the aspect ratio's true value.
229         val ratio1UpperBound: Float
230         val ratio1LowerBound: Float
231         if (isAccurate1) {
232             ratio1UpperBound = size1.width / size1.height
233             ratio1LowerBound = ratio1UpperBound
234         } else {
235             ratio1UpperBound = (size1.width + 1f) / (size1.height - 1f)
236             ratio1LowerBound = (size1.width - 1f) / (size1.height + 1f)
237         }
238         val ratio2UpperBound: Float
239         val ratio2LowerBound: Float
240         if (isAccurate2) {
241             ratio2UpperBound = size2.width.toFloat() / size2.height
242             ratio2LowerBound = ratio2UpperBound
243         } else {
244             ratio2UpperBound = (size2.width + 1f) / (size2.height - 1f)
245             ratio2LowerBound = (size2.width - 1f) / (size2.height + 1f)
246         }
247         // Then we check if the true value range overlaps.
248         return ratio1UpperBound >= ratio2LowerBound && ratio2UpperBound >= ratio1LowerBound
249     }
250 
251     /** Gets the transform from a normalized space (-1, -1) - (1, 1) to the given rect. */
252     private fun getNormalizedToBuffer(viewPortRect: RectF): Matrix =
253         Matrix().apply { setRectToRect(NORMALIZED_RECT, viewPortRect, Matrix.ScaleToFit.FILL) }
254 
255     private fun Size.toSizeF(): SizeF = SizeF(width.toFloat(), height.toFloat())
256 
257     /** Transforms the resolution into a crop rect, replacing any NaN values with real values. */
258     private fun TransformationInfo.cropRectFor(resolution: Size): RectF =
259         RectF(
260             cropRectLeft.let { if (it.isNaN()) 0f else it },
261             cropRectTop.let { if (it.isNaN()) 0f else it },
262             cropRectRight.let { if (it.isNaN()) resolution.width.toFloat() else it },
263             cropRectBottom.let { if (it.isNaN()) resolution.height.toFloat() else it }
264         )
265 
266     private fun TransformationInfo.rotatedViewportFor(resolution: Size): SizeF =
267         cropRectFor(resolution).let {
268             if (is90or270(sourceRotation)) {
269                 SizeF(it.height(), it.width())
270             } else {
271                 SizeF(it.width(), it.height())
272             }
273         }
274 }
275 
276 /**
277  * Transform from one rectangle to another.
278  *
279  * Modeled after Compose's `ContentScale` class, but using Android classes since this module does
280  * not depend on Compose.
281  */
282 interface ContentScale {
computeScaleFactornull283     fun computeScaleFactor(srcSize: SizeF, dstSize: SizeF): ScaleFactorF
284 }
285 
286 /**
287  * Transform for how one rectangle is placed in a space.
288  *
289  * Modeled after Compose's `Alignment` class, but using Android classes since this module does not
290  * depend on Compose. This also uses float types rather than integer types in order to allow for
291  * sub-pixel placement.
292  */
293 interface Alignment {
294     fun align(size: SizeF, space: SizeF, layoutDirection: Int): OffsetF
295 }
296 
ScaleFactorFnull297 fun ScaleFactorF(scaleX: Float, scaleY: Float) = ScaleFactorF(packFloats(scaleX, scaleY))
298 
299 @JvmInline
300 value class ScaleFactorF(private val packedScales: Long) {
301     val scaleX: Float
302         get() = unpackFloat1(packedScales)
303 
304     val scaleY: Float
305         get() = unpackFloat2(packedScales)
306 }
307 
OffsetFnull308 fun OffsetF(x: Float, y: Float) = OffsetF(packFloats(x, y))
309 
310 @JvmInline
311 value class OffsetF(private val packedOffsets: Long) {
312     val x: Float
313         get() = unpackFloat1(packedOffsets)
314 
315     val y: Float
316         get() = unpackFloat2(packedOffsets)
317 }
318 
floatFromBitsnull319 private fun floatFromBits(bits: Int): Float = java.lang.Float.intBitsToFloat(bits)
320 
321 /** Packs two Float values into one Long value for use in inline classes. */
322 private fun packFloats(val1: Float, val2: Float): Long {
323     val v1 = val1.toRawBits().toLong()
324     val v2 = val2.toRawBits().toLong()
325     return (v1 shl 32) or (v2 and 0xFFFFFFFF)
326 }
327 
328 /** Unpacks the first Float value in [packFloats] from its returned Long. */
unpackFloat1null329 private fun unpackFloat1(value: Long): Float {
330     return floatFromBits((value shr 32).toInt())
331 }
332 
333 /** Unpacks the second Float value in [packFloats] from its returned Long. */
unpackFloat2null334 private fun unpackFloat2(value: Long): Float {
335     return floatFromBits((value and 0xFFFFFFFF).toInt())
336 }
337