Skip to content

Commit 756eb4c

Browse files
committed
fix(android): calculate image orientation based on display orientation
Ensure captured images use the display rotation instead of relying on sensor-based orientation, which could lead to incorrect rotated images.
1 parent e94ec98 commit 756eb4c

File tree

2 files changed

+141
-90
lines changed

2 files changed

+141
-90
lines changed

android/src/main/java/com/michaelwolz/capacitorcameraview/CameraView.kt

Lines changed: 63 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,24 @@ import android.hardware.camera2.CameraCharacteristics
77
import android.hardware.camera2.CameraManager
88
import android.util.Base64
99
import android.util.Log
10+
import android.view.Surface
1011
import android.view.ViewGroup
1112
import android.webkit.WebView
1213
import androidx.annotation.OptIn
1314
import androidx.camera.camera2.interop.Camera2CameraInfo
1415
import androidx.camera.camera2.interop.ExperimentalCamera2Interop
1516
import androidx.camera.core.CameraSelector
17+
import androidx.camera.core.ExperimentalZeroShutterLag
1618
import androidx.camera.core.ImageAnalysis
1719
import androidx.camera.core.ImageCapture
1820
import androidx.camera.core.ImageCaptureException
21+
import androidx.camera.core.ImageProxy
1922
import androidx.camera.core.resolutionselector.AspectRatioStrategy
2023
import androidx.camera.core.resolutionselector.ResolutionSelector
2124
import androidx.camera.mlkit.vision.MlKitAnalyzer
22-
import androidx.camera.view.CameraController
2325
import androidx.camera.view.LifecycleCameraController
2426
import androidx.camera.view.PreviewView
2527
import androidx.core.content.ContextCompat
26-
import androidx.exifinterface.media.ExifInterface
2728
import androidx.lifecycle.LifecycleOwner
2829
import com.getcapacitor.Plugin
2930
import com.google.mlkit.vision.barcode.BarcodeScanner
@@ -35,8 +36,6 @@ import com.michaelwolz.capacitorcameraview.model.CameraDevice
3536
import com.michaelwolz.capacitorcameraview.model.CameraSessionConfiguration
3637
import com.michaelwolz.capacitorcameraview.model.ZoomFactors
3738
import java.io.ByteArrayOutputStream
38-
import java.io.File
39-
import java.util.UUID
4039
import java.util.concurrent.ExecutorService
4140
import java.util.concurrent.Executors
4241

@@ -53,9 +52,6 @@ class CameraView(plugin: Plugin) {
5352
private var currentCameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
5453
private var currentFlashMode: Int = ImageCapture.FLASH_MODE_OFF
5554

56-
// Camera use cases
57-
private var imageCapture: ImageCapture? = null
58-
5955
// Plugin context
6056
private var lifecycleOwner: LifecycleOwner? = null
6157
private var pluginDelegate: Plugin = plugin
@@ -95,8 +91,6 @@ class CameraView(plugin: Plugin) {
9591
cameraController?.unbind()
9692

9793
try {
98-
imageCapture = null
99-
10094
previewView?.let { view ->
10195
try {
10296
(webView.parent as? ViewGroup)?.removeView(view)
@@ -125,35 +119,46 @@ class CameraView(plugin: Plugin) {
125119
}
126120

127121
/** Capture a photo with the current camera configuration */
122+
@OptIn(ExperimentalZeroShutterLag::class)
128123
fun capturePhoto(quality: Int, callback: (String?, Exception?) -> Unit) {
129-
val timeStart = System.currentTimeMillis()
130-
val controller = this.cameraController
124+
val startTime = System.currentTimeMillis()
125+
val controller =
126+
this.cameraController
127+
?: run {
128+
callback(null, Exception("Camera controller not initialized"))
129+
return
130+
}
131+
132+
val preview = previewView
131133
?: run {
132-
callback(null, Exception("Camera controller not initialized"))
134+
callback(null, Exception("Camera preview not initialized"))
133135
return
134136
}
135137

136138
mainHandler.post {
137-
try {
138-
// Create temporary file for the captured image
139-
val tempFile =
140-
File.createTempFile(UUID.randomUUID().toString(), ".jpg", context.cacheDir)
141-
val outputOptions = ImageCapture.OutputFileOptions.Builder(tempFile).build()
139+
val cameraInfo = controller.cameraInfo
140+
val isFrontFacing = controller.cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA
141+
val sensorRotationDegrees = cameraInfo?.sensorRotationDegrees ?: 0
142+
val displayRotationDegrees = preview.display?.rotation ?: Surface.ROTATION_0
143+
val imageRotationDegrees = calculateImageRotationBasedOnDisplayRotation(
144+
displayRotationDegrees,
145+
sensorRotationDegrees,
146+
isFrontFacing
147+
)
142148

149+
try {
143150
controller.takePicture(
144-
outputOptions,
145151
cameraExecutor,
146-
object : ImageCapture.OnImageSavedCallback {
147-
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
152+
object : ImageCapture.OnImageCapturedCallback() {
153+
override fun onCaptureSuccess(image: ImageProxy) {
148154
Log.d(
149155
TAG,
150-
"Image stored to temp file in ${System.currentTimeMillis() - timeStart} ms"
156+
"Image captured successfully in ${System.currentTimeMillis() - startTime}ms"
151157
)
152-
handleImageSaved(tempFile, quality, callback)
158+
handleCaptureSuccess(image, quality, imageRotationDegrees, callback)
153159
}
154160

155161
override fun onError(exception: ImageCaptureException) {
156-
tempFile.delete()
157162
Log.e(TAG, "Error capturing image", exception)
158163
callback(null, exception)
159164
}
@@ -167,58 +172,25 @@ class CameraView(plugin: Plugin) {
167172
}
168173

169174
/**
170-
* Handles the image saved callback, re-encodes the JPEG if quality is specified
171-
* and returns the Base64 encoded string as the callback result.
175+
* Handles the successful capture of an image, converting it to a Base64 string
172176
*/
173-
private fun handleImageSaved(
174-
tempFile: File,
177+
fun handleCaptureSuccess(
178+
image: ImageProxy,
175179
quality: Int,
180+
rotationDegrees: Int,
176181
callback: (String?, Exception?) -> Unit
177182
) {
178183
val startTime = System.currentTimeMillis()
179184
try {
180-
val jpegBytes = tempFile.readBytes()
181-
Log.d(TAG, "Image size : ${jpegBytes.size} bytes")
182-
val base64String = if (quality == 100) {
183-
// If quality is 100, return the original JPEG without re-encoding
184-
Log.d(TAG, "Encoding original JPEG with quality 100")
185-
Base64.encodeToString(jpegBytes, Base64.NO_WRAP)
186-
} else {
187-
// Otherwise, re-encode the JPEG with the specified quality
188-
// which is a little bit more expensive
189-
Log.d(TAG, "Re-encoding JPEG with quality $quality")
190-
val originalExif = ExifInterface(tempFile.absolutePath)
191-
val orientation = originalExif.getAttributeInt(
192-
ExifInterface.TAG_ORIENTATION,
193-
ExifInterface.ORIENTATION_UNDEFINED
194-
)
195-
196-
val bitmap =
197-
android.graphics.BitmapFactory.decodeByteArray(jpegBytes, 0, jpegBytes.size)
198-
val compressedFile =
199-
File.createTempFile(UUID.randomUUID().toString(), ".jpg", context.cacheDir)
200-
val outputStream = compressedFile.outputStream()
201-
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
202-
outputStream.close()
203-
204-
val newExif = ExifInterface(compressedFile.absolutePath)
205-
newExif.setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
206-
newExif.saveAttributes()
207-
208-
val compressedBytes = compressedFile.readBytes()
209-
compressedFile.delete()
210-
Log.d(TAG, "Re-encoded image size: ${compressedBytes.size} bytes")
211-
Base64.encodeToString(compressedBytes, Base64.NO_WRAP)
212-
}
213-
Log.d(
214-
TAG,
215-
"Image processing took ${System.currentTimeMillis() - startTime} ms (quality: $quality)"
216-
)
217-
tempFile.delete()
185+
// Turn the image into a Base64 encoded string and apply rotation if necessary
186+
val base64String = imageProxyToBase64(image, quality, rotationDegrees)
187+
Log.d(TAG, "Image processed to Base64 in ${System.currentTimeMillis() - startTime}ms")
218188
callback(base64String, null)
219189
} catch (e: Exception) {
220-
tempFile.delete()
190+
Log.e(TAG, "Error processing captured image", e)
221191
callback(null, e)
192+
} finally {
193+
image.close()
222194
}
223195
}
224196

@@ -262,9 +234,9 @@ class CameraView(plugin: Plugin) {
262234
/** Flip between front and back cameras */
263235
fun flipCamera(callback: (Exception?) -> Unit) {
264236
currentCameraSelector = when (currentCameraSelector) {
265-
CameraSelector.DEFAULT_FRONT_CAMERA -> CameraSelector.DEFAULT_BACK_CAMERA
266-
else -> CameraSelector.DEFAULT_FRONT_CAMERA
267-
}
237+
CameraSelector.DEFAULT_FRONT_CAMERA -> CameraSelector.DEFAULT_BACK_CAMERA
238+
else -> CameraSelector.DEFAULT_FRONT_CAMERA
239+
}
268240

269241
val controller =
270242
this.cameraController
@@ -409,7 +381,6 @@ class CameraView(plugin: Plugin) {
409381

410382
// Clear references
411383
lifecycleOwner = null
412-
imageCapture = null
413384

414385
// Shutdown executor
415386
if (!cameraExecutor.isShutdown) {
@@ -441,7 +412,7 @@ class CameraView(plugin: Plugin) {
441412
(webView.parent as? ViewGroup)?.addView(previewView, 0)
442413
}
443414

444-
@OptIn(ExperimentalCamera2Interop::class)
415+
@OptIn(ExperimentalCamera2Interop::class, ExperimentalZeroShutterLag::class)
445416
private fun initializeCamera(
446417
context: Context,
447418
lifecycleOwner: LifecycleOwner,
@@ -451,33 +422,35 @@ class CameraView(plugin: Plugin) {
451422
setupPreviewView(context)
452423

453424
currentCameraSelector = if (config.position == "front") {
454-
CameraSelector.DEFAULT_FRONT_CAMERA
455-
} else {
456-
CameraSelector.DEFAULT_BACK_CAMERA
457-
}
425+
CameraSelector.DEFAULT_FRONT_CAMERA
426+
} else {
427+
CameraSelector.DEFAULT_BACK_CAMERA
428+
}
458429

459430
if (config.deviceId != null) {
460431
// Prefer specific device id over position
461432
currentCameraSelector = CameraSelector.Builder()
462-
.addCameraFilter { cameraInfos ->
463-
cameraInfos.filter { info ->
464-
val cameraId = Camera2CameraInfo.from(info).cameraId
465-
cameraId == config.deviceId
433+
.addCameraFilter { cameraInfos ->
434+
cameraInfos.filter { info ->
435+
val cameraId = Camera2CameraInfo.from(info).cameraId
436+
cameraId == config.deviceId
437+
}
466438
}
467-
}
468-
.build()
439+
.build()
469440
}
470441

471442
// Initialize camera controller
472-
val controller = LifecycleCameraController(context).apply {
473-
cameraSelector = currentCameraSelector
474-
imageCapture = ImageCapture.Builder()
475-
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
476-
.build()
477-
imageCaptureResolutionSelector = ResolutionSelector.Builder()
478-
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
479-
.build()
480-
}
443+
val controller =
444+
LifecycleCameraController(context).apply {
445+
cameraSelector = currentCameraSelector
446+
imageCaptureMode = ImageCapture.CAPTURE_MODE_ZERO_SHUTTER_LAG
447+
imageCaptureResolutionSelector =
448+
ResolutionSelector.Builder()
449+
.setAspectRatioStrategy(
450+
AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY
451+
)
452+
.build()
453+
}
481454

482455
cameraController = controller
483456
previewView?.controller = controller

android/src/main/java/com/michaelwolz/capacitorcameraview/utils.kt

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
package com.michaelwolz.capacitorcameraview
22

3+
import android.graphics.Bitmap
4+
import android.graphics.BitmapFactory
5+
import android.graphics.Matrix
36
import android.graphics.Rect
7+
import android.util.Base64
8+
import android.view.Surface
49
import android.view.View
510
import android.view.ViewGroup.MarginLayoutParams
11+
import androidx.camera.core.ImageProxy
612
import androidx.camera.view.PreviewView
713
import com.getcapacitor.PluginCall
814
import com.google.mlkit.vision.barcode.common.Barcode
915
import com.michaelwolz.capacitorcameraview.model.CameraSessionConfiguration
1016
import com.michaelwolz.capacitorcameraview.model.WebBoundingRect
17+
import java.io.ByteArrayOutputStream
1118

1219
/** Converts a barcode format code to a readable string. */
1320
fun getBarcodeFormatString(format: Int): String {
@@ -84,3 +91,74 @@ fun sessionConfigFromPluginCall(call: PluginCall): CameraSessionConfiguration {
8491
zoomFactor = call.getFloat("zoomFactor") ?: 1.0f
8592
)
8693
}
94+
95+
/**
96+
* Calculates the image orientation based on the display rotation and sensor rotation degrees.
97+
*
98+
* This is because CameraController will set the image orientation based on the device's
99+
* motion sensor, which may not match the display rotation and in this case not what we actually
100+
* want.
101+
*
102+
* @param displayRotation The current display rotation (0, 1, 2, or 3).
103+
* @param sensorRotationDegrees The rotation of the camera sensor in degrees (0, 90, 180, or 270).
104+
* @param isFrontFacing Whether the camera is front-facing or back-facing.
105+
* @return The calculated image orientation in degrees.
106+
*/
107+
fun calculateImageRotationBasedOnDisplayRotation(
108+
displayRotation: Int,
109+
sensorRotationDegrees: Int,
110+
isFrontFacing: Boolean
111+
): Int {
112+
val surfaceRotationDegrees = when (displayRotation) {
113+
Surface.ROTATION_0 -> 0
114+
Surface.ROTATION_90 -> 90
115+
Surface.ROTATION_180 -> 180
116+
Surface.ROTATION_270 -> 270
117+
else -> 0
118+
}
119+
120+
return if (isFrontFacing) {
121+
(sensorRotationDegrees + surfaceRotationDegrees) % 360
122+
} else {
123+
(sensorRotationDegrees - surfaceRotationDegrees + 360) % 360
124+
}
125+
}
126+
127+
/**
128+
* Converts an ImageProxy to a Base64 encoded string and applies rotation if necessary.
129+
*
130+
* @param image The ImageProxy to convert.
131+
* @param quality The JPEG compression quality (0-100).
132+
* @param rotationDegrees The degrees to rotate the image (0, 90, 180, 270).
133+
*/
134+
fun imageProxyToBase64(image: ImageProxy, quality: Int, rotationDegrees: Int): String {
135+
val buffer = image.planes[0].buffer
136+
val bytes = ByteArray(buffer.remaining())
137+
buffer.get(bytes)
138+
139+
var bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
140+
?: throw IllegalArgumentException("Failed to decode image")
141+
142+
try {
143+
// Apply rotation if needed
144+
if (rotationDegrees != 0) {
145+
val matrix = Matrix().apply {
146+
postRotate(rotationDegrees.toFloat())
147+
}
148+
val rotatedBitmap =
149+
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
150+
// Recycle the original bitmap to prevent memory leaks
151+
bitmap.recycle()
152+
bitmap = rotatedBitmap
153+
}
154+
155+
val outputStream = ByteArrayOutputStream()
156+
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
157+
val byteArray = outputStream.toByteArray()
158+
159+
return Base64.encodeToString(byteArray, Base64.NO_WRAP)
160+
} finally {
161+
// Ensure bitmap is always recycled
162+
bitmap.recycle()
163+
}
164+
}

0 commit comments

Comments
 (0)