Skip to content

Commit

Permalink
[camera] Fix saving EXIF metadata (expo#6428)
Browse files Browse the repository at this point in the history
# Why

Resolves expo#6399.

# How

UIImage strips metadata (https://stackoverflow.com/a/25595495). So I've added it manually using `Image IO API`. Moreover, I've also deleted `updateImageSampleMetadata` function - it was unuse.

Android bitmap also strips metadata.
# TODO

- [x] iOS
- [x] Android

# Test Plan

- https://snack.expo.io/@lukaszkosmaty/github---camera---fix-metadata ✅(returns correct metadata)
- NCL ✅
  • Loading branch information
lukmccall authored and tsapeta committed Dec 16, 2019
1 parent 11d34f4 commit 6891e60
Show file tree
Hide file tree
Showing 5 changed files with 128 additions and 71 deletions.
12 changes: 12 additions & 0 deletions android/src/main/java/expo/modules/camera/CameraViewHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import com.google.android.cameraview.CameraView;

import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.List;
Expand Down Expand Up @@ -131,6 +132,17 @@ public static Bundle getExifData(ExifInterface exifInterface) {
return exifMap;
}

public static void addExifData(ExifInterface baseExif, ExifInterface additionalExif) throws IOException {
for (String[] tagInfo : CameraViewHelper.exifTags) {
String name = tagInfo[1];
String value = additionalExif.getAttribute(name);
if (value != null) {
baseExif.setAttribute(name, value);
}
}
baseExif.saveAttributes();
}

public static Bitmap generateSimulatorPhoto(int width, int height) {
Bitmap fakePhoto = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(fakePhoto);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
import android.graphics.BitmapFactory;
import android.graphics.Matrix;
import android.os.Bundle;

import androidx.exifinterface.media.ExifInterface;

import android.net.Uri;
import android.os.AsyncTask;
import android.util.Base64;
Expand All @@ -18,6 +20,7 @@
import java.util.Map;

import org.unimodules.core.Promise;

import expo.modules.camera.CameraViewHelper;
import expo.modules.camera.utils.FileSystemUtils;

Expand Down Expand Up @@ -70,7 +73,7 @@ public ResolveTakenPictureAsyncTask(Bitmap bitmap, Promise promise, Map<String,
private int getQuality() {
if (mOptions.get(QUALITY_KEY) instanceof Number) {
double requestedQuality = ((Number) mOptions.get(QUALITY_KEY)).doubleValue();
return (int)(requestedQuality * 100);
return (int) (requestedQuality * 100);
}

return DEFAULT_QUALITY * 100;
Expand All @@ -84,33 +87,30 @@ protected Bundle doInBackground(Void... voids) {
}

Bundle response = new Bundle();
ByteArrayInputStream inputStream = null;

// we need the stream only for photos from a device
if (mBitmap == null) {
mBitmap = BitmapFactory.decodeByteArray(mImageData, 0, mImageData.length);
inputStream = new ByteArrayInputStream(mImageData);
}

ByteArrayInputStream inputStream = new ByteArrayInputStream(mImageData);

try {
if (inputStream != null) {
ExifInterface exifInterface = new ExifInterface(inputStream);
// Get orientation of the image from mImageData via inputStream
int orientation = exifInterface.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_UNDEFINED
);

// Rotate the bitmap to the proper orientation if needed
if (orientation != ExifInterface.ORIENTATION_UNDEFINED) {
mBitmap = rotateBitmap(mBitmap, getImageRotation(orientation));
}
ExifInterface exifInterface = new ExifInterface(inputStream);
// Get orientation of the image from mImageData via inputStream
int orientation = exifInterface.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_UNDEFINED
);

// Rotate the bitmap to the proper orientation if needed
if (orientation != ExifInterface.ORIENTATION_UNDEFINED) {
mBitmap = rotateBitmap(mBitmap, getImageRotation(orientation));
}

// Write Exif data to the response if requested
if (isOptionEnabled(EXIF_KEY)) {
Bundle exifData = CameraViewHelper.getExifData(exifInterface);
response.putBundle(EXIF_KEY, exifData);
}
// Write Exif data to the response if requested
if (isOptionEnabled(EXIF_KEY)) {
Bundle exifData = CameraViewHelper.getExifData(exifInterface);
response.putBundle(EXIF_KEY, exifData);
}

// Upon rotating, write the image's dimensions to the response
Expand All @@ -120,9 +120,15 @@ protected Bundle doInBackground(Void... voids) {
// Cache compressed image in imageStream
ByteArrayOutputStream imageStream = new ByteArrayOutputStream();
mBitmap.compress(Bitmap.CompressFormat.JPEG, getQuality(), imageStream);

// Write compressed image to file in cache directory
String filePath = writeStreamToFile(imageStream);

// Save Exif data to the image if requested
if (isOptionEnabled(EXIF_KEY)) {
ExifInterface exifFromFile = new ExifInterface(filePath);
CameraViewHelper.addExifData(exifFromFile, exifInterface);
}

File imageFile = new File(filePath);
String fileUri = Uri.fromFile(imageFile).toString();
response.putString(URI_KEY, fileUri);
Expand All @@ -134,10 +140,8 @@ protected Bundle doInBackground(Void... voids) {

// Cleanup
imageStream.close();
if (inputStream != null) {
inputStream.close();
inputStream = null;
}
inputStream.close();
inputStream = null;

return response;
} catch (Resources.NotFoundException e) {
Expand Down Expand Up @@ -232,7 +236,7 @@ private String writeStreamToFile(ByteArrayOutputStream inputStream) throws Excep
FileOutputStream outputStream = null;

try {
outputPath = FileSystemUtils.generateOutputPath(mDirectory, DIRECTORY_NAME,EXTENSION);
outputPath = FileSystemUtils.generateOutputPath(mDirectory, DIRECTORY_NAME, EXTENSION);
outputStream = new FileOutputStream(outputPath);
inputStream.writeTo(outputStream);
} catch (IOException e) {
Expand Down
69 changes: 36 additions & 33 deletions ios/EXCamera/EXCamera.m
Original file line number Diff line number Diff line change
Expand Up @@ -388,9 +388,11 @@ - (void)captureOutput:(AVCapturePhotoOutput *)output
}

NSData *imageData = [AVCapturePhotoOutput JPEGPhotoDataRepresentationForJPEGSampleBuffer:photoSampleBuffer previewPhotoSampleBuffer:previewPhotoSampleBuffer];
CFDictionaryRef exifAttachments = CMGetAttachment(photoSampleBuffer, kCGImagePropertyExifDictionary, NULL);
NSDictionary *metadata = (__bridge NSDictionary *)exifAttachments;
[self handleCapturedImageData:imageData exifMetadata:metadata options:options resolver:resolve];

CGImageSourceRef sourceCGIImageRef = CGImageSourceCreateWithData((CFDataRef) imageData, NULL);
NSDictionary *sourceMetadata = (__bridge NSDictionary *) CGImageSourceCopyPropertiesAtIndex(sourceCGIImageRef, 0, NULL);
[self handleCapturedImageData:imageData metadata:sourceMetadata options:options resolver:resolve reject:reject];
CFRelease(sourceCGIImageRef);
}

- (void)captureOutput:(AVCapturePhotoOutput *)output didFinishProcessingPhoto:(AVCapturePhoto *)photo error:(NSError *)error API_AVAILABLE(ios(11.0))
Expand All @@ -413,60 +415,61 @@ - (void)captureOutput:(AVCapturePhotoOutput *)output didFinishProcessingPhoto:(A
}

NSData *imageData = [photo fileDataRepresentation];
[self handleCapturedImageData:imageData exifMetadata:photo.metadata[(NSString *)kCGImagePropertyExifDictionary] options:options resolver:resolve];
[self handleCapturedImageData:imageData metadata:photo.metadata options:options resolver:resolve reject:reject];
}

- (void)handleCapturedImageData:(NSData *)imageData exifMetadata:(NSDictionary *)exifMetadata options:(NSDictionary *)options resolver:(UMPromiseResolveBlock)resolve
- (void)handleCapturedImageData:(NSData *)imageData metadata:(NSDictionary *)metadata options:(NSDictionary *)options resolver:(UMPromiseResolveBlock)resolve reject:(UMPromiseRejectBlock)reject
{
UIImage *takenImage = [UIImage imageWithData:imageData];
BOOL useFastMode = [options[@"fastMode"] boolValue];
if (useFastMode) {
resolve(nil);
}

CGImageRef takenCGImage = takenImage.CGImage;

CGSize previewSize;
if (UIInterfaceOrientationIsPortrait([[UIApplication sharedApplication] statusBarOrientation])) {
previewSize = CGSizeMake(self.previewLayer.frame.size.height, self.previewLayer.frame.size.width);
} else {
previewSize = CGSizeMake(self.previewLayer.frame.size.width, self.previewLayer.frame.size.height);
}


CGImageRef takenCGImage = takenImage.CGImage;
CGRect cropRect = CGRectMake(0, 0, CGImageGetWidth(takenCGImage), CGImageGetHeight(takenCGImage));
CGRect croppedSize = AVMakeRectWithAspectRatioInsideRect(previewSize, cropRect);
takenImage = [EXCameraUtils cropImage:takenImage toRect:croppedSize];

float quality = [options[@"quality"] floatValue];
NSData *takenImageData = UIImageJPEGRepresentation(takenImage, quality);

NSString *path = [self.fileSystem generatePathInDirectory:[self.fileSystem.cachesDirectory stringByAppendingPathComponent:@"Camera"] withExtension:@".jpg"];
float width = takenImage.size.width;
float height = takenImage.size.height;
NSData *processedImageData = nil;
float quality = [options[@"quality"] floatValue];

NSMutableDictionary *response = [[NSMutableDictionary alloc] init];
response[@"uri"] = [EXCameraUtils writeImage:takenImageData toPath:path];
response[@"width"] = @(takenImage.size.width);
response[@"height"] = @(takenImage.size.height);

if ([options[@"base64"] boolValue]) {
response[@"base64"] = [takenImageData base64EncodedStringWithOptions:0];
if ([options[@"exif"] boolValue]) {
NSMutableDictionary *updatedExif = [EXCameraUtils updateExifMetadata:metadata[(NSString *)kCGImagePropertyExifDictionary] withAdditionalData:@{ @"Orientation": @([EXCameraUtils exportImageOrientation:takenImage.imageOrientation]) }];
updatedExif[(NSString *)kCGImagePropertyExifPixelYDimension] = @(width);
updatedExif[(NSString *)kCGImagePropertyExifPixelXDimension] = @(height);
response[@"exif"] = updatedExif;

NSMutableDictionary *updatedMetadata = [metadata mutableCopy];
updatedMetadata[(NSString *)kCGImagePropertyExifDictionary] = updatedExif;

// UIImage does not contain metadata information. We need to add them to CGImage manually.
processedImageData = [EXCameraUtils dataFromImage:takenImage withMetadata:updatedMetadata imageQuality:quality];
} else {
processedImageData = UIImageJPEGRepresentation(takenImage, quality);
}

if (!processedImageData) {
return reject(@"E_IMAGE_SAVE_FAILED", @"Could not save the image.", nil);
}

response[@"uri"] = [EXCameraUtils writeImage:processedImageData toPath:path];
response[@"width"] = @(width);
response[@"height"] = @(height);

if ([options[@"exif"] boolValue]) {
int imageRotation;
switch (takenImage.imageOrientation) {
case UIImageOrientationLeft:
imageRotation = 90;
break;
case UIImageOrientationRight:
imageRotation = -90;
break;
case UIImageOrientationDown:
imageRotation = 180;
break;
default:
imageRotation = 0;
}
[EXCameraUtils updateExifMetadata:exifMetadata withAdditionalData:@{ @"Orientation": @(imageRotation) } inResponse:response]; // TODO
if ([options[@"base64"] boolValue]) {
response[@"base64"] = [processedImageData base64EncodedStringWithOptions:0];
}

if ([options[@"fastMode"] boolValue]) {
Expand Down
5 changes: 3 additions & 2 deletions ios/EXCamera/Utilities/EXCameraUtils.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@
+ (NSString *)captureSessionPresetForVideoResolution:(EXCameraVideoResolution)resolution;
+ (AVCaptureVideoOrientation)videoOrientationForDeviceOrientation:(UIDeviceOrientation)orientation;
+ (AVCaptureVideoOrientation)videoOrientationForInterfaceOrientation:(UIInterfaceOrientation)orientation;
+ (int)exportImageOrientation:(UIImageOrientation)orientation;

+ (UIImage *)generatePhotoOfSize:(CGSize)size;
+ (UIImage *)cropImage:(UIImage *)image toRect:(CGRect)rect;
+ (NSString *)writeImage:(NSData *)image toPath:(NSString *)path;
+ (void)updateExifMetadata:(NSDictionary *)metadata withAdditionalData:(NSDictionary *)additionalData inResponse:(NSMutableDictionary *)response;
+ (void)updateImageSampleMetadata:(CMSampleBufferRef)imageSampleBuffer withAdditionalData:(NSDictionary *)additionalData inResponse:(NSMutableDictionary *)response;
+ (NSMutableDictionary *)updateExifMetadata:(NSDictionary *)metadata withAdditionalData:(NSDictionary *)additionalData;
+ (NSData *)dataFromImage:(UIImage *)image withMetadata:(NSDictionary *)exif imageQuality:(float)quality;

@end

55 changes: 46 additions & 9 deletions ios/EXCamera/Utilities/EXCameraUtils.m
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,20 @@ + (NSString *)captureSessionPresetForVideoResolution:(EXCameraVideoResolution)re
}
}

+ (int)exportImageOrientation:(UIImageOrientation)orientation
{
switch (orientation) {
case UIImageOrientationLeft:
return 90;
case UIImageOrientationRight:
return -90;
case UIImageOrientationDown:
return 180;
default:
return 0;
}
}

# pragma mark - Image utilities

+ (UIImage *)generatePhotoOfSize:(CGSize)size
Expand Down Expand Up @@ -121,19 +135,42 @@ + (NSString *)writeImage:(NSData *)image toPath:(NSString *)path
return [fileURL absoluteString];
}

+ (void)updateImageSampleMetadata:(CMSampleBufferRef)imageSampleBuffer withAdditionalData:(NSDictionary *)additionalData inResponse:(NSMutableDictionary *)response
+ (NSData *)dataFromImage:(UIImage *)image withMetadata:(NSDictionary *)metadata imageQuality:(float)quality
{
CFDictionaryRef exifAttachments = CMGetAttachment(imageSampleBuffer, kCGImagePropertyExifDictionary, NULL);
NSDictionary *metadata = (__bridge NSDictionary *)exifAttachments;
[self updateExifMetadata:metadata withAdditionalData:additionalData inResponse:response];
// Get metadata (includes the EXIF data)
CGImageSourceRef sourceCGIImageRef = CGImageSourceCreateWithData((CFDataRef) UIImageJPEGRepresentation(image, 1.0f), NULL);
NSDictionary *sourceMetadata = (__bridge NSDictionary *) CGImageSourceCopyPropertiesAtIndex(sourceCGIImageRef, 0, NULL);

NSMutableDictionary *updatedMetadata = [sourceMetadata mutableCopy];

for (id key in metadata) {
updatedMetadata[key] = metadata[key];
}

// Set compression quality
[updatedMetadata setObject:@(quality) forKey:(__bridge NSString *)kCGImageDestinationLossyCompressionQuality];

// Create an image destination
NSMutableData *processedImageData = [NSMutableData data];
CGImageDestinationRef destinationCGImageRef = CGImageDestinationCreateWithData((__bridge CFMutableDataRef)processedImageData, CGImageSourceGetType(sourceCGIImageRef), 1, NULL);

// Add image to the destination
// Note: it'll save only these value which are under the kCGImageProperty* key.
CGImageDestinationAddImage(destinationCGImageRef, image.CGImage, (__bridge CFDictionaryRef) updatedMetadata);

// Finalize the destination
if (CGImageDestinationFinalize(destinationCGImageRef)) {
CFRelease(sourceCGIImageRef);
CFRelease(destinationCGImageRef);

return processedImageData;
}
return nil;
}

+ (void)updateExifMetadata:(NSDictionary *)metadata withAdditionalData:(NSDictionary *)additionalData inResponse:(NSMutableDictionary *)response
+ (NSMutableDictionary *)updateExifMetadata:(NSDictionary *)metadata withAdditionalData:(NSDictionary *)additionalData
{
NSMutableDictionary *mutableMetadata = [[NSMutableDictionary alloc] initWithDictionary:metadata];
mutableMetadata[(NSString *)kCGImagePropertyExifPixelYDimension] = response[@"width"];
mutableMetadata[(NSString *)kCGImagePropertyExifPixelXDimension] = response[@"height"];

for (id key in additionalData) {
mutableMetadata[key] = additionalData[key];
}
Expand All @@ -146,7 +183,7 @@ + (void)updateExifMetadata:(NSDictionary *)metadata withAdditionalData:(NSDictio
}
}

response[@"exif"] = mutableMetadata;
return mutableMetadata;
}

@end
Expand Down

0 comments on commit 6891e60

Please sign in to comment.