exif: use location for timezone

Signed-off-by: Varun Patil <varunpatil@ucla.edu>
pull/563/head
Varun Patil 2023-03-27 17:50:10 -07:00
parent 86ceaf7bb2
commit 3b24a62ba0
11 changed files with 245 additions and 87 deletions

View File

@ -2,6 +2,11 @@
This file is manually updated. Please file an issue if something is missing.
## Unreleased
- **Feature**: Use GPS location data for timezone calculation.
Many cameras do not store the timezone in EXIF data. This feature allows Memories to use the GPS location data to calculate the timezone. To take advantage of this, you will need to run `occ memories:places-setup` followed by `occ memories:index --clear` (or `occ memories:index -f`) to reindex your photos.
## v4.12.4 + v4.12.5 (2023-03-23)
- These releases significantly overhaul the application logic for better maintainability. If you run into any regressions, please [file a bug report](https://github.com/pulsejet/memories/issues).

View File

@ -71,6 +71,9 @@ class PlacesBackend extends Backend
$count = $query->func()->count($query->createFunction('DISTINCT m.fileid'), 'count');
$query->select('e.osm_id', 'e.name', $count)->from('memories_planet', 'e');
// WHERE these are not special clusters (e.g. timezone)
$query->where($query->expr()->gt('e.admin_level', $query->createNamedParameter(0)));
// WHERE there are items with this osm_id
$query->innerJoin('e', 'memories_places', 'mp', $query->expr()->eq('mp.osm_id', 'e.osm_id'));

View File

@ -202,9 +202,10 @@ class PlacesSetup extends Command
$boundaries = $data['geometry'];
// Skip some places
if ($adminLevel <= 1 || $adminLevel >= 10) {
if ($adminLevel > -2 && ($adminLevel <= 1 || $adminLevel >= 10)) {
// <=1: These are too general, e.g. "Earth"? or invalid
// >=10: These are too specific, e.g. "Community Board"
// <-1: These are special, e.g. "Timezone" = -7
continue;
}

View File

@ -72,6 +72,7 @@ trait TimelineQuerySingleItem
->from('memories_places', 'mp')
->innerJoin('mp', 'memories_planet', 'e', $qb->expr()->eq('mp.osm_id', 'e.osm_id'))
->where($qb->expr()->eq('mp.fileid', $qb->createNamedParameter($id, \PDO::PARAM_INT)))
->andWhere($qb->expr()->gt('e.admin_level', $qb->createNamedParameter(0)))
->orderBy('e.admin_level', 'DESC')
;
$places = $qb->executeQuery()->fetchAll(\PDO::FETCH_COLUMN);

View File

@ -10,7 +10,6 @@ use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\File;
use OCP\IDBConnection;
use OCP\IPreview;
use Psr\Log\LoggerInterface;
require_once __DIR__.'/../ExifFields.php';
@ -136,77 +135,44 @@ class TimelineWrite
return 2;
}
// Get more parameters
// Video parameters
$videoDuration = round((float) ($isvideo ? ($exif['Duration'] ?? $exif['TrackDuration'] ?? 0) : 0));
// Process location data
// This also modifies the exif array in-place to set the LocationTZID
[$lat, $lon, $mapCluster] = $this->processExifLocation($fileId, $exif, $prevRow);
// Get date parameters (after setting timezone offset)
$dateTaken = Exif::getDateTaken($file, $exif);
$dayId = floor($dateTaken / 86400);
$dateTaken = gmdate('Y-m-d H:i:s', $dateTaken);
// Store the acutal epoch with the EXIF data
$exif['DateTimeEpoch'] = $dateTaken->getTimestamp();
// Store the date taken in the database as UTC (local date) only
// Basically, assume everything happens in Greenwich
$dateLocalUtc = Exif::forgetTimezone($dateTaken)->getTimestamp();
$dateTakenStr = gmdate('Y-m-d H:i:s', $dateLocalUtc);
// We need to use the local time in UTC for the dayId
// This way two photos in different timezones on the same date locally
// end up in the same dayId group
$dayId = floor($dateLocalUtc / 86400);
// Get size of image
[$w, $h] = Exif::getDimensions($exif);
// Get live photo ID of video part
$liveid = $this->livePhoto->getLivePhotoId($file, $exif);
// Video parameters
$videoDuration = 0;
if ($isvideo) {
$videoDuration = round((float) ($exif['Duration'] ?? $exif['TrackDuration'] ?? 0));
}
// Clean up EXIF to keep only useful metadata
$filteredExif = [];
foreach ($exif as $key => $value) {
// Truncate any fields > 2048 chars
if (\is_string($value) && \strlen($value) > 2048) {
$value = substr($value, 0, 2048);
}
// Only keep fields in the whitelist
if (\array_key_exists($key, EXIF_FIELDS_LIST)) {
$filteredExif[$key] = $value;
}
}
// Store JSON string
$exifJson = json_encode($filteredExif);
// Store error if data > 64kb
if (\is_string($exifJson)) {
if (\strlen($exifJson) > 65535) {
$exifJson = json_encode(['error' => 'Exif data too large']);
}
} else {
$exifJson = json_encode(['error' => 'Exif data encoding error']);
}
// Store location data
$lat = \array_key_exists('GPSLatitude', $exif) ? (float) $exif['GPSLatitude'] : null;
$lon = \array_key_exists('GPSLongitude', $exif) ? (float) $exif['GPSLongitude'] : null;
$oldLat = $prevRow ? (float) $prevRow['lat'] : null;
$oldLon = $prevRow ? (float) $prevRow['lon'] : null;
$mapCluster = $prevRow ? (int) $prevRow['mapcluster'] : -1;
if ($lat || $lon || $oldLat || $oldLon) {
try {
$mapCluster = $this->mapGetCluster($mapCluster, $lat, $lon, $oldLat, $oldLon);
} catch (\Error $e) {
$logger = \OC::$server->get(LoggerInterface::class);
$logger->log(3, 'Error updating map cluster data: '.$e->getMessage(), ['app' => 'memories']);
}
try {
$this->updatePlacesData($fileId, $lat, $lon);
} catch (\Error $e) {
$logger = \OC::$server->get(LoggerInterface::class);
$logger->log(3, 'Error updating places data: '.$e->getMessage(), ['app' => 'memories']);
}
}
// NULL if invalid
$mapCluster = $mapCluster <= 0 ? null : $mapCluster;
// Get exif json
$exifJson = $this->getExifJson($exif);
// Parameters for insert or update
$params = [
'fileid' => $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT),
'objectid' => $query->createNamedParameter((string) $fileId, IQueryBuilder::PARAM_STR),
'dayid' => $query->createNamedParameter($dayId, IQueryBuilder::PARAM_INT),
'datetaken' => $query->createNamedParameter($dateTaken, IQueryBuilder::PARAM_STR),
'datetaken' => $query->createNamedParameter($dateTakenStr, IQueryBuilder::PARAM_STR),
'mtime' => $query->createNamedParameter($mtime, IQueryBuilder::PARAM_INT),
'isvideo' => $query->createNamedParameter($isvideo, IQueryBuilder::PARAM_INT),
'video_duration' => $query->createNamedParameter($videoDuration, IQueryBuilder::PARAM_INT),
@ -284,4 +250,38 @@ class TimelineWrite
$this->connection->executeStatement($p->getTruncateTableSQL('*PREFIX*'.$table, false));
}
}
/**
* Convert EXIF data to filtered JSON string.
*/
private function getExifJson(array $exif): string
{
// Clean up EXIF to keep only useful metadata
$filteredExif = [];
foreach ($exif as $key => $value) {
// Truncate any fields > 2048 chars
if (\is_string($value) && \strlen($value) > 2048) {
$value = substr($value, 0, 2048);
}
// Only keep fields in the whitelist
if (\array_key_exists($key, EXIF_FIELDS_LIST)) {
$filteredExif[$key] = $value;
}
}
// Store JSON string
$exifJson = json_encode($filteredExif);
// Store error if data > 64kb
if (\is_string($exifJson)) {
if (\strlen($exifJson) > 65535) {
$exifJson = json_encode(['error' => 'Exif data too large']);
}
} else {
$exifJson = json_encode(['error' => 'Exif data encoding error']);
}
return $exifJson;
}
}

View File

@ -6,26 +6,76 @@ namespace OCA\Memories\Db;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use Psr\Log\LoggerInterface;
trait TimelineWritePlaces
{
protected IDBConnection $connection;
/**
* Process the location part of exif data.
*
* Also update the exif data with the tzid from location (LocationTZID)
*
* @param int $fileId The file ID
* @param array $exif The exif data
* @param array|bool $prevRow The previous row of data
*
* @return array Update values
*/
protected function processExifLocation(int $fileId, array &$exif, mixed $prevRow): array
{
// Store location data
$lat = \array_key_exists('GPSLatitude', $exif) ? (float) $exif['GPSLatitude'] : null;
$lon = \array_key_exists('GPSLongitude', $exif) ? (float) $exif['GPSLongitude'] : null;
$oldLat = $prevRow ? (float) $prevRow['lat'] : null;
$oldLon = $prevRow ? (float) $prevRow['lon'] : null;
$mapCluster = $prevRow ? (int) $prevRow['mapcluster'] : -1;
$osmIds = [];
if ($lat || $lon || $oldLat || $oldLon) {
try {
$mapCluster = $this->mapGetCluster($mapCluster, $lat, $lon, $oldLat, $oldLon);
} catch (\Error $e) {
$logger = \OC::$server->get(LoggerInterface::class);
$logger->log(3, 'Error updating map cluster data: '.$e->getMessage(), ['app' => 'memories']);
}
try {
$osmIds = $this->updatePlacesData($fileId, $lat, $lon);
} catch (\Error $e) {
$logger = \OC::$server->get(LoggerInterface::class);
$logger->log(3, 'Error updating places data: '.$e->getMessage(), ['app' => 'memories']);
}
}
// NULL if invalid
$mapCluster = $mapCluster <= 0 ? null : $mapCluster;
// Set tzid from location if not present
$this->setTzidFromLocation($exif, $osmIds);
// Return update values
return [$lat, $lon, $mapCluster, $osmIds];
}
/**
* Add places data for a file.
*
* @param int $fileId The file ID
* @param null|float $lat The latitude of the file
* @param null|float $lon The longitude of the file
*
* @return array The list of osm_id of the places
*/
protected function updatePlacesData(int $fileId, $lat, $lon): void
protected function updatePlacesData(int $fileId, $lat, $lon): array
{
// Get GIS type
$gisType = \OCA\Memories\Util::placesGISType();
// Check if valid
if ($gisType <= 0) {
return;
return [];
}
// Delete previous records
@ -37,7 +87,7 @@ trait TimelineWritePlaces
// Just remove from if the point is no longer valid
if (null === $lat || null === $lon) {
return;
return [];
}
// Construct WHERE clause depending on GIS type
@ -47,7 +97,7 @@ trait TimelineWritePlaces
} elseif (2 === $gisType) {
$where = "POINT('{$lon},{$lat}') <@ geometry";
} else {
return;
return [];
}
// Make query to memories_planet table
@ -82,5 +132,36 @@ trait TimelineWritePlaces
}
$this->connection->commit();
// Return list of osm_id
return array_map(fn ($row) => $row['osm_id'], $rows);
}
/**
* Set timezone offset from location if not present.
*
* @param array $exif The exif data
* @param array $osmIds The list of osm_id of the places
*/
private function setTzidFromLocation(array &$exif, array $osmIds): void
{
// Make sure we have some places
if (empty($osmIds)) {
return;
}
// Get timezone offset from places
$query = $this->connection->getQueryBuilder();
$query->select('name')
->from('memories_planet')
->where($query->expr()->in('osm_id', $query->createNamedParameter($osmIds, IQueryBuilder::PARAM_INT_ARRAY)))
->andWhere($query->expr()->eq('admin_level', $query->createNamedParameter(-7, IQueryBuilder::PARAM_INT)))
;
// Get name of timezone
$tzName = $query->executeQuery()->fetchOne();
if ($tzName) {
$exif['LocationTZID'] = $tzName;
}
}
}

View File

@ -157,12 +157,8 @@ class Exif
/**
* Parse date from exif format and throw error if invalid.
*
* @param array $exif
*
* @return int unix timestamp
*/
public static function parseExifDate(array $exif)
public static function parseExifDate(array $exif): \DateTime
{
// Get date from exif
$exifDate = $exif['SubSecDateTimeOriginal'] ?? $exif['DateTimeOriginal'] ?? $exif['CreateDate'] ?? null;
@ -171,13 +167,16 @@ class Exif
}
// Get timezone from exif
$exifTz = $exif["OffsetTimeOriginal"] ?? $exif["OffsetTime"] ?? null;
try {
$parseTz = new \DateTimeZone($exifTz);
$exifTz = $exif['OffsetTimeOriginal'] ?? $exif['OffsetTime'] ?? $exif['LocationTZID'] ?? null;
$exifTz = new \DateTimeZone($exifTz);
} catch (\Error $e) {
$parseTz = new \DateTimeZone('UTC');
$exifTz = null;
}
// Force UTC if no timezone found
$parseTz = $exifTz ?? new \DateTimeZone('UTC');
// https://github.com/pulsejet/memories/pull/397
// https://github.com/pulsejet/memories/issues/485
@ -199,23 +198,28 @@ class Exif
}
}
// If we couldn't parse the date, throw an error
if (!$parsedDate) {
throw new \Exception("Invalid date: {$exifDate}");
}
// Filter out dates before 1800 A.D.
if ($parsedDate->getTimestamp() < -5364662400) { // 1800 A.D.
throw new \Exception("Date too old: {$exifDate}");
}
return $parsedDate->getTimestamp();
// Force the timezone to be the same as parseTz
if ($exifTz) {
$parsedDate->setTimezone($exifTz);
}
return $parsedDate;
}
/**
* Get the date taken from either the file or exif data if available.
*
* @return int unix timestamp
*/
public static function getDateTaken(File $file, array $exif)
public static function getDateTaken(File $file, array $exif): \DateTime
{
try {
return self::parseExifDate($exif);
@ -224,7 +228,24 @@ class Exif
}
// Fall back to modification time
return $file->getMtime();
try {
$parseTz = new \DateTimeZone(getenv('TZ')); // debian
} catch (\Error $e) {
$parseTz = new \DateTimeZone('UTC');
}
$dt = new \DateTime('@'.$file->getMtime(), $parseTz);
$dt->setTimezone($parseTz);
return self::forgetTimezone($dt);
}
/**
* Convert time to local date in UTC.
*/
public static function forgetTimezone(\DateTime $date): \DateTime
{
return new \DateTime($date->format('Y-m-d H:i:s'), new \DateTimeZone('UTC'));
}
/**

View File

@ -9,6 +9,10 @@ const EXIF_FIELDS_LIST = [
'OffsetTime' => true,
'ModifyDate' => true,
// Generated date fields
'DateTimeEpoch' => true,
'LocationTZID' => true,
// Camera Info
'Make' => true,
'Model' => true,

20
package-lock.json generated
View File

@ -20,6 +20,7 @@
"leaflet": "^1.9.3",
"leaflet-edgebuffer": "^1.0.6",
"moment": "^2.29.4",
"moment-timezone": "^0.5.42",
"path-posix": "^1.0.0",
"photoswipe": "^5.3.6",
"plyr": "^3.7.7",
@ -7248,6 +7249,17 @@
"node": "*"
}
},
"node_modules/moment-timezone": {
"version": "0.5.42",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.42.tgz",
"integrity": "sha512-tjI9goqwzkflKSTxJo+jC/W8riTFwEjjunssmFvAWlvNVApjbkJM7UHggyKO0q1Fd/kZVKY77H7C9A0XKhhAFw==",
"dependencies": {
"moment": "^2.29.4"
},
"engines": {
"node": "*"
}
},
"node_modules/mpd-parser": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.0.1.tgz",
@ -16810,6 +16822,14 @@
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w=="
},
"moment-timezone": {
"version": "0.5.42",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.42.tgz",
"integrity": "sha512-tjI9goqwzkflKSTxJo+jC/W8riTFwEjjunssmFvAWlvNVApjbkJM7UHggyKO0q1Fd/kZVKY77H7C9A0XKhhAFw==",
"requires": {
"moment": "^2.29.4"
}
},
"mpd-parser": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.0.1.tgz",

View File

@ -40,6 +40,7 @@
"leaflet": "^1.9.3",
"leaflet-edgebuffer": "^1.0.6",
"moment": "^2.29.4",
"moment-timezone": "^0.5.42",
"path-posix": "^1.0.0",
"photoswipe": "^5.3.6",
"plyr": "^3.7.7",

View File

@ -58,7 +58,9 @@ import NcLoadingIcon from "@nextcloud/vue/dist/Components/NcLoadingIcon";
import axios from "@nextcloud/axios";
import { subscribe, unsubscribe } from "@nextcloud/event-bus";
import { getCanonicalLocale } from "@nextcloud/l10n";
import moment from "moment";
import "moment-timezone";
import * as utils from "../services/Utils";
@ -176,26 +178,45 @@ export default defineComponent({
/** Date taken info */
dateOriginal(): moment.Moment | null {
const m = moment.utc(this.baseInfo.datetaken * 1000);
const epoch = this.exif.DateTimeEpoch || this.baseInfo.datetaken;
const m = moment.utc(epoch * 1000);
if (!m.isValid()) return null;
m.locale(getCanonicalLocale());
// set timezeon
const tz = this.exif["OffsetTimeOriginal"] || this.exif["OffsetTime"];
if (tz) m.utcOffset(tz);
// The fallback to datetaken can be eventually removed
// and then this can be discarded
if (this.exif.DateTimeEpoch) {
const tzOffset =
this.exif["OffsetTimeOriginal"] || this.exif["OffsetTime"];
const tzId = this.exif["LocationTZID"];
if (tzOffset) {
m.utcOffset(tzOffset);
} else if (tzId) {
m.tz(tzId);
}
}
return m;
},
dateOriginalStr(): string | null {
if (!this.dateOriginal) return null;
return utils.getLongDateStr(this.dateOriginal.toDate(), true);
return utils.getLongDateStr(
new Date(this.baseInfo.datetaken * 1000),
true
);
},
dateOriginalTime(): string[] | null {
if (!this.dateOriginal) return null;
return [this.dateOriginal.format("h:mm A Z")];
let format = "h:mm A";
const fields = ["OffsetTimeOriginal", "OffsetTime", "LocationTZID"];
if (fields.some((key) => this.exif[key])) {
format += " Z";
}
return [this.dateOriginal.format(format)];
},
/** Camera make and model info */