Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Write proper photo metadata (orientation & isMirrored) #2660

Merged
merged 2 commits into from
Mar 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
package com.mrousavy.camera

import android.annotation.SuppressLint
import android.content.Context
import android.graphics.ImageFormat
import android.hardware.camera2.*
import android.util.Log
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableMap
import com.mrousavy.camera.core.InsufficientStorageError
import com.mrousavy.camera.core.Photo
import com.mrousavy.camera.types.Flash
import com.mrousavy.camera.utils.*
import java.io.IOException
import kotlinx.coroutines.*

private const val TAG = "CameraView.takePhoto"
Expand All @@ -31,57 +26,15 @@ suspend fun CameraView.takePhoto(optionsMap: ReadableMap): WritableMap {
orientation
)

photo.use {
Log.i(TAG, "Successfully captured ${photo.image.width} x ${photo.image.height} photo!")
Log.i(TAG, "Successfully captured ${photo.width} x ${photo.height} photo!")

val path = try {
savePhotoToFile(context, photo)
} catch (e: IOException) {
if (e.message?.contains("no space left", true) == true) {
throw InsufficientStorageError()
} else {
throw e
}
}
val map = Arguments.createMap()
map.putString("path", photo.path)
map.putInt("width", photo.width)
map.putInt("height", photo.height)
map.putString("orientation", photo.orientation.unionValue)
map.putBoolean("isRawPhoto", false)
map.putBoolean("isMirrored", photo.isMirrored)

Log.i(TAG, "Successfully saved photo to file! $path")

val map = Arguments.createMap()
map.putString("path", path)
map.putInt("width", photo.image.width)
map.putInt("height", photo.image.height)
map.putString("orientation", photo.orientation.unionValue)
map.putBoolean("isRawPhoto", photo.isRawPhoto)
map.putBoolean("isMirrored", photo.isMirrored)

return map
}
return map
}

private suspend fun savePhotoToFile(context: Context, photo: Photo): String =
withContext(Dispatchers.IO) {
when (photo.image.format) {
ImageFormat.JPEG, ImageFormat.DEPTH_JPEG -> {
// When the format is JPEG or DEPTH JPEG we can simply save the bytes as-is
val file = FileUtils.createTempFile(context, ".jpg")
FileUtils.writePhotoToFile(photo, file)
return@withContext file.absolutePath
}

ImageFormat.RAW_SENSOR -> {
// When the format is RAW we use the DngCreator utility library
throw Error("Writing RAW photos is currently not supported!")
// TODO: Write RAW photos using DngCreator?
// val dngCreator = DngCreator(cameraCharacteristics, photo.metadata)
// val file = FileUtils.createTempFile(context, ".dng")
// FileOutputStream(file).use { stream ->
// dngCreator.writeImage(stream, photo.image.image)
// }
// return@withContext file.absolutePath
}

else -> {
throw Error("Failed to save Photo to file, image format is not supported! ${photo.image.format}")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import android.util.Log
import android.view.Gravity
import android.view.ScaleGestureDetector
import android.widget.FrameLayout
import androidx.camera.core.Preview.SurfaceProvider
import androidx.camera.view.PreviewView
import com.google.mlkit.vision.barcode.common.Barcode
import com.mrousavy.camera.core.CameraConfiguration
Expand Down Expand Up @@ -143,7 +142,13 @@ class CameraView(context: Context) :
}
}

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
override fun onLayout(
changed: Boolean,
left: Int,
top: Int,
right: Int,
bottom: Int
) {
val width = right - left
val height = bottom - top

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.util.Log
import android.util.Range
import android.util.Size
Expand Down Expand Up @@ -447,9 +448,18 @@ class CameraSession(private val context: Context, private val callback: Callback
photoOutput.flashMode = flash.toFlashMode()
photoOutput.targetRotation = outputOrientation.toDegrees()

val image = photoOutput.takePicture(enableShutterSound, callback, CameraQueues.cameraExecutor)
val isMirrored = camera.cameraInfo.lensFacing == CameraSelector.LENS_FACING_FRONT
return Photo(image, isMirrored)
val photoFile = photoOutput.takePicture(context, enableShutterSound, callback, CameraQueues.cameraExecutor)
val isMirrored = photoFile.metadata.isReversedHorizontal

val bitmapOptions = BitmapFactory.Options().also {
it.inJustDecodeBounds = true
}
BitmapFactory.decodeFile(photoFile.uri.path, bitmapOptions)
val width = bitmapOptions.outWidth
val height = bitmapOptions.outHeight
val orientation = outputOrientation

return Photo(photoFile.uri.path, width, height, orientation, isMirrored)
}

@OptIn(ExperimentalPersistentRecording::class)
Expand Down
14 changes: 1 addition & 13 deletions package/android/src/main/java/com/mrousavy/camera/core/Photo.kt
Original file line number Diff line number Diff line change
@@ -1,17 +1,5 @@
package com.mrousavy.camera.core

import android.graphics.ImageFormat
import androidx.camera.core.ImageProxy
import com.mrousavy.camera.types.Orientation
import java.io.Closeable

data class Photo(val image: ImageProxy, val isMirrored: Boolean) : Closeable {
val orientation: Orientation
get() = Orientation.fromRotationDegrees(image.imageInfo.rotationDegrees)
val isRawPhoto
get() = image.format in listOf(ImageFormat.RAW_SENSOR, ImageFormat.RAW10, ImageFormat.RAW12, ImageFormat.RAW_PRIVATE)

override fun close() {
image.close()
}
}
data class Photo(val path: String, val width: Int, val height: Int, val orientation: Orientation, val isMirrored: Boolean)
Original file line number Diff line number Diff line change
@@ -1,25 +1,46 @@
package com.mrousavy.camera.extensions

import android.annotation.SuppressLint
import android.content.Context
import android.media.MediaActionSound
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCapture.OutputFileOptions
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.ImageProxy
import com.mrousavy.camera.core.CameraSession
import com.mrousavy.camera.types.ShutterType
import com.mrousavy.camera.utils.FileUtils
import java.net.URI
import java.util.concurrent.Executor
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine

suspend inline fun ImageCapture.takePicture(enableShutterSound: Boolean, callback: CameraSession.Callback, executor: Executor): ImageProxy =
data class PhotoFileInfo(val uri: URI, val metadata: ImageCapture.Metadata)

@SuppressLint("RestrictedApi")
suspend inline fun ImageCapture.takePicture(
context: Context,
enableShutterSound: Boolean,
callback: CameraSession.Callback,
executor: Executor
): PhotoFileInfo =
suspendCancellableCoroutine { continuation ->
// Shutter sound
val shutterSound = if (enableShutterSound) MediaActionSound() else null
shutterSound?.load(MediaActionSound.SHUTTER_CLICK)

val file = FileUtils.createTempFile(context, ".jpg")
val outputFileOptionsBuilder = OutputFileOptions.Builder(file).also { options ->
val metadata = ImageCapture.Metadata()
metadata.isReversedHorizontal = camera?.isFrontFacing == true
options.setMetadata(metadata)
}
val outputFileOptions = outputFileOptionsBuilder.build()

takePicture(
outputFileOptions,
executor,
object : ImageCapture.OnImageCapturedCallback() {
object : ImageCapture.OnImageSavedCallback {
override fun onCaptureStarted() {
super.onCaptureStarted()
if (enableShutterSound) {
Expand All @@ -29,15 +50,15 @@ suspend inline fun ImageCapture.takePicture(enableShutterSound: Boolean, callbac
callback.onShutter(ShutterType.PHOTO)
}

override fun onCaptureSuccess(image: ImageProxy) {
super.onCaptureSuccess(image)
@SuppressLint("RestrictedApi")
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
if (continuation.isActive) {
continuation.resume(image)
val info = PhotoFileInfo(file.toURI(), outputFileOptions.metadata)
continuation.resume(info)
}
}

override fun onError(exception: ImageCaptureException) {
super.onError(exception)
if (continuation.isActive) {
continuation.resumeWithException(exception)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,8 @@ package com.mrousavy.camera.utils

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import com.mrousavy.camera.core.InvalidImageTypeError
import com.mrousavy.camera.core.Photo
import java.io.File
import java.io.FileOutputStream
import java.nio.ByteBuffer

class FileUtils {
companion object {
Expand All @@ -17,29 +12,6 @@ class FileUtils {
it.deleteOnExit()
}

private fun writeBufferToFile(byteBuffer: ByteBuffer, isMirrored: Boolean, file: File) {
if (isMirrored) {
val imageBytes = ByteArray(byteBuffer.remaining()).apply { byteBuffer.get(this) }
val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
val matrix = Matrix()
matrix.preScale(-1f, 1f)
val processedBitmap =
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, false)
FileOutputStream(file).use { stream ->
processedBitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream)
}
} else {
FileOutputStream(file).use { stream ->
stream.channel.write(byteBuffer)
}
}
}

fun writePhotoToFile(photo: Photo, file: File) {
val plane = photo.image.planes[0] ?: throw InvalidImageTypeError()
writeBufferToFile(plane.buffer, photo.isMirrored, file)
}

fun writeBitmapTofile(bitmap: Bitmap, file: File, quality: Int = 100) {
FileOutputStream(file).use { stream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, stream)
Expand Down
4 changes: 2 additions & 2 deletions package/example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@ PODS:
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.10)
- SocketRocket (0.6.1)
- VisionCamera (4.0.0-beta.5):
- VisionCamera (4.0.0-beta.6):
- React
- React-callinvoker
- React-Core
Expand Down Expand Up @@ -724,7 +724,7 @@ SPEC CHECKSUMS:
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17
VisionCamera: 81972e59d3cac50c97a37fa109dafd63720a8bd0
VisionCamera: 7a9e59cc5e65d458d9809a9ac23700265df0f14e
Yoga: 4c3aa327e4a6a23eeacd71f61c81df1bcdf677d5

PODFILE CHECKSUM: 27f53791141a3303d814e09b55770336416ff4eb
Expand Down
Loading