From 5de5b7e88e4a5b01f77d32d3c1628d8111f5e12e Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 11 Nov 2020 12:42:54 +0900 Subject: [PATCH] overlay: fixed getting shooting details with ExifInterface --- .../aves/channel/calls/MetadataHandler.kt | 78 ++++++++++++------- .../aves/metadata/ExifInterfaceHelper.kt | 36 ++++++++- lib/model/image_metadata.dart | 25 +++--- lib/services/metadata_service.dart | 2 +- lib/widgets/fullscreen/overlay/bottom.dart | 2 +- 5 files changed, 104 insertions(+), 39 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index b21a75ec0..514e7bb80 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -28,7 +28,9 @@ import com.drew.metadata.xmp.XmpDirectory import deckers.thibault.aves.metadata.ExifInterfaceHelper import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis +import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt +import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeRational import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription @@ -38,7 +40,6 @@ import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis -import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDescription import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString @@ -353,37 +354,62 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } val metadataMap = HashMap() - if (isVideo(mimeType) || !isSupportedByMetadataExtractor(mimeType)) { + if (isVideo(mimeType)) { result.success(metadataMap) return } - try { - StorageUtils.openInputStream(context, uri)?.use { input -> - val metadata = ImageMetadataReader.readMetadata(input) - for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { - dir.getSafeDescription(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it } - dir.getSafeDescription(ExifSubIFDDirectory.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it } - dir.getSafeDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = "ISO$it" } - dir.getSafeRational(ExifSubIFDDirectory.TAG_EXPOSURE_TIME) { - // TAG_EXPOSURE_TIME as a string is sometimes a ratio, sometimes a decimal - // so we explicitly request it as a rational (e.g. 1/100, 1/14, 71428571/1000000000, 4000/1000, 2000000000/500000000) - // and process it to make sure the numerator is `1` when the ratio value is less than 1 - val num = it.numerator - val denom = it.denominator - metadataMap[KEY_EXPOSURE_TIME] = when { - num >= denom -> "${it.toSimpleString(true)}″" - num != 1L && num != 0L -> Rational(1, (denom / num.toDouble()).roundToLong()).toString() - else -> it.toString() - } + + val saveExposureTime: (value: Rational) -> Unit = { + // `TAG_EXPOSURE_TIME` as a string is sometimes a ratio, sometimes a decimal + // so we explicitly request it as a rational (e.g. 1/100, 1/14, 71428571/1000000000, 4000/1000, 2000000000/500000000) + // and process it to make sure the numerator is `1` when the ratio value is less than 1 + val num = it.numerator + val denom = it.denominator + metadataMap[KEY_EXPOSURE_TIME] = when { + num >= denom -> "${it.toSimpleString(true)}″" + num != 1L && num != 0L -> Rational(1, (denom / num.toDouble()).roundToLong()).toString() + else -> it.toString() + } + } + + var foundExif = false + if (isSupportedByMetadataExtractor(mimeType)) { + try { + StorageUtils.openInputStream(context, uri)?.use { input -> + val metadata = ImageMetadataReader.readMetadata(input) + for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { + foundExif = true + dir.getSafeRational(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator } + dir.getSafeRational(ExifSubIFDDirectory.TAG_EXPOSURE_TIME, saveExposureTime) + dir.getSafeRational(ExifSubIFDDirectory.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it.numerator.toDouble() / it.denominator } + dir.getSafeInt(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = it } } } - result.success(metadataMap) - } ?: result.error("getOverlayMetadata-noinput", "failed to get metadata for uri=$uri", null) - } catch (e: Exception) { - result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message) - } catch (e: NoClassDefFoundError) { - result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message) + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e) + } catch (e: NoClassDefFoundError) { + Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e) + } } + + if (!foundExif) { + // fallback to read EXIF via ExifInterface + try { + StorageUtils.openInputStream(context, uri)?.use { input -> + val exif = ExifInterface(input) + exif.getSafeDouble(ExifInterface.TAG_F_NUMBER) { metadataMap[KEY_APERTURE] = it } + exif.getSafeRational(ExifInterface.TAG_EXPOSURE_TIME, saveExposureTime) + exif.getSafeDouble(ExifInterface.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it } + exif.getSafeInt(ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY) { metadataMap[KEY_ISO] = it } + } + } catch (e: Exception) { + // ExifInterface initialization can fail with a RuntimeException + // caused by an internal MediaMetadataRetriever failure + Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=$uri", e) + } + } + + result.success(metadataMap) } private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt index 2e324310a..e7815d942 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt @@ -10,12 +10,15 @@ import com.drew.metadata.exif.makernotes.OlympusImageProcessingMakernoteDirector import com.drew.metadata.exif.makernotes.OlympusMakernoteDirectory import deckers.thibault.aves.utils.LogUtils import java.util.* +import kotlin.math.abs import kotlin.math.floor import kotlin.math.roundToLong object ExifInterfaceHelper { private val LOG_TAG = LogUtils.createTag(ExifInterfaceHelper::class.java) + private const val precisionErrorTolerance = 1e-10 + // ExifInterface always states it has the following attributes // and returns "0" instead of "null" when they are actually missing private val neverNullTags = listOf( @@ -279,7 +282,7 @@ object ExifInterfaceHelper { private fun toRational(s: String?): Rational? { s ?: return null - // convert "12345/100" + // e.g. "12345/100" to Rational(12345, 100) val parts = s.split("/") if (parts.size == 2) { val numerator = parts[0].toLongOrNull() ?: return null @@ -287,9 +290,20 @@ object ExifInterfaceHelper { return Rational(numerator, denominator) } - // convert "123.45" var d = s.toDoubleOrNull() ?: return null if (d == 0.0) return Rational(0, 1) + + // e.g. "0.02564102564102564" to Rational(1, 39) + if (d < 1) { + val numerator = 1L + val f = numerator / d + val denominator = f.roundToLong() + if (abs(f - denominator) < precisionErrorTolerance) { + return Rational(numerator, denominator) + } + } + + // e.g. "123.45" to Rational(12345, 100) var denominator: Long = 1 while (d != floor(d)) { denominator *= 10 @@ -321,6 +335,24 @@ object ExifInterfaceHelper { } } + fun ExifInterface.getSafeDouble(tag: String, save: (value: Double) -> Unit) { + if (this.hasAttribute(tag)) { + val value = this.getAttributeDouble(tag, Double.NaN) + if (!value.isNaN()) { + save(value) + } + } + } + + fun ExifInterface.getSafeRational(tag: String, save: (value: Rational) -> Unit) { + if (this.hasAttribute(tag)) { + val value = toRational(this.getAttribute(tag)) + if (value != null) { + save(value) + } + } + } + fun ExifInterface.getSafeDateMillis(tag: String, save: (value: Long) -> Unit) { if (this.hasAttribute(tag)) { // TODO TLAD parse date with "yyyy:MM:dd HH:mm:ss" or find the original long diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index 2bcc28be6..42ec4f5fd 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:geocoder/model.dart'; +import 'package:intl/intl.dart'; class DateMetadata { final int contentId, dateMillis; @@ -109,19 +110,25 @@ class CatalogMetadata { class OverlayMetadata { final String aperture, exposureTime, focalLength, iso; + static final apertureFormat = NumberFormat('0.0', 'en_US'); + static final focalLengthFormat = NumberFormat('0.#', 'en_US'); + OverlayMetadata({ - String aperture, - this.exposureTime, - this.focalLength, - this.iso, - }) : aperture = aperture?.replaceFirst('f', 'ƒ'); + double aperture, + String exposureTime, + double focalLength, + int iso, + }) : aperture = aperture != null ? 'ƒ/${apertureFormat.format(aperture)}' : null, + exposureTime = exposureTime, + focalLength = focalLength != null ? '${focalLengthFormat.format(focalLength)} mm' : null, + iso = iso != null ? 'ISO$iso' : null; factory OverlayMetadata.fromMap(Map map) { return OverlayMetadata( - aperture: map['aperture'], - exposureTime: map['exposureTime'], - focalLength: map['focalLength'], - iso: map['iso'], + aperture: map['aperture'] as double, + exposureTime: map['exposureTime'] as String, + focalLength: map['focalLength'] as double, + iso: map['iso'] as int, ); } diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index fbe5ca6f1..abf7ea68d 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -64,7 +64,7 @@ class MetadataService { if (entry.isSvg) return null; try { - // return map with string descriptions for: 'aperture' 'exposureTime' 'focalLength' 'iso' + // return map with values for: 'aperture' (double), 'exposureTime' (description), 'focalLength' (double), 'iso' (int) final result = await platform.invokeMethod('getOverlayMetadata', { 'mimeType': entry.mimeType, 'uri': entry.uri, diff --git a/lib/widgets/fullscreen/overlay/bottom.dart b/lib/widgets/fullscreen/overlay/bottom.dart index 58fbe7047..4a3369206 100644 --- a/lib/widgets/fullscreen/overlay/bottom.dart +++ b/lib/widgets/fullscreen/overlay/bottom.dart @@ -151,7 +151,7 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget { final positionTitle = [ if (position != null) position, if (entry.bestTitle != null) entry.bestTitle, - ].join(' — '); // em dash + ].join(' • '); final hasShootingDetails = details != null && !details.isEmpty && settings.showOverlayShootingDetails; return Column( mainAxisSize: MainAxisSize.min,