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