Add video geotagging support using avmetareadwrite on OS X

This commit is contained in:
Jaisen Mathai 2015-10-20 01:17:09 -07:00
parent 0f7bfd9f23
commit 8221e3c020
7 changed files with 355 additions and 58 deletions

View File

@ -1,3 +1 @@
from media import Media
from photo import Photo
from video import Video
from media import *

View File

@ -19,6 +19,10 @@ import time
Media class for general video operations
"""
class Media(object):
# class / static variable accessible through get_valid_extensions()
video_extensions = ('avi','m4v','mov','mp4','3gp')
photo_extensions = ('jpg', 'jpeg', 'nef', 'dng')
"""
@param, source, string, The fully qualified path to the video file
"""
@ -201,12 +205,17 @@ class Media(object):
return mimetype[0]
def get_class_by_file(Media, _file):
@classmethod
def get_class_by_file(Media, _file, classes):
extension = os.path.splitext(_file)[1][1:].lower()
if(extension in Photo.get_valid_extensions()):
return Photo
elif(extension in Video.get_valid_extensions()):
return Video
else:
return None
name = None
if(extension in Media.photo_extensions):
name = 'Photo'
elif(extension in Media.video_extensions):
name = 'Video'
for i in classes:
if(name == i.__name__):
return i(_file)
return None

View File

@ -9,17 +9,17 @@ from datetime import datetime
import mimetypes
import os
import pyexiv2
import re
import time
from elodie.media.media import Media
from media import Media
from elodie import geolocation
"""
Video class for general photo operations
Photo class for general photo operations
"""
class Photo(Media):
# class / static variable accessible through get_valid_extensions()
__valid_extensions = ('jpg', 'jpeg', 'nef', 'dng')
"""
@param, source, string, The fully qualified path to the photo file
@ -48,11 +48,77 @@ class Photo(Media):
return re.search('(\d{2}:\d{2}.\d{2})', key).group(1).replace('.', ':')
return None
"""
Set album for a photo
@param, name, string, Name of album
@returns, boolean
"""
def set_album(self, name):
if(name is None):
return False
source = self.source
exif_metadata = pyexiv2.ImageMetadata(source)
exif_metadata.read()
exif_metadata['Xmp.elodie.album'] = name
exif_metadata.write()
"""
Set the date/time a photo was taken
@param, time, datetime, datetime object of when the photo was taken
@returns, boolean
"""
def set_datetime(self, time):
if(time is None):
return False
source = self.source
exif_metadata = pyexiv2.ImageMetadata(source)
exif_metadata.read()
exif_metadata['Exif.Photo.DateTimeOriginal'].value = time
exif_metadata['Exif.Image.DateTime'].value = time
exif_metadata.write()
return True
"""
Set lat/lon for a photo
@param, latitude, float, Latitude of the file
@param, longitude, float, Longitude of the file
@returns, boolean
"""
def set_location(self, latitude, longitude):
if(latitude is None or longitude is None):
return False
source = self.source
exif_metadata = pyexiv2.ImageMetadata(source)
exif_metadata.read()
exif_metadata['Exif.GPSInfo.GPSLatitude'] = geolocation.decimal_to_dms(latitude)
exif_metadata['Exif.GPSInfo.GPSLatitudeRef'] = pyexiv2.ExifTag('Exif.GPSInfo.GPSLatitudeRef', 'N' if latitude >= 0 else 'S')
exif_metadata['Exif.GPSInfo.GPSLongitude'] = geolocation.decimal_to_dms(longitude)
exif_metadata['Exif.GPSInfo.GPSLongitudeRef'] = pyexiv2.ExifTag('Exif.GPSInfo.GPSLongitudeRef', 'E' if longitude >= 0 else 'W')
exif_metadata.write()
return True
"""
Static method to access static __valid_extensions variable.
def set_location(self, latitude, longitude):
return None
@returns, tuple
"""
@classmethod
def get_valid_extensions(Photo):
return Photo.__valid_extensions
return Media.photo_extensions

View File

@ -5,23 +5,25 @@ Video package that handles all video operations
# load modules
from distutils.spawn import find_executable
import tempfile
from sys import argv
from datetime import datetime
import mimetypes
import os
import re
import shutil
import subprocess
import time
from elodie.media.media import Media
from media import Media
"""
Video class for general video operations
"""
class Video(Media):
# class / static variable accessible through get_valid_extensions()
__valid_extensions = ('avi','m4v','mov','mp4','3gp')
"""
@param, source, string, The fully qualified path to the video file
@ -29,8 +31,63 @@ class Video(Media):
def __init__(self, source=None):
super(Video, self).__init__(source)
# We only want to parse EXIF once so we store it here
self.exif = None
"""
Get latitude or longitude of photo from EXIF
@returns, time object or None for non-video files or 0 timestamp
"""
def get_coordinate(self, type='latitude'):
exif_data = self.get_exif()
if(exif_data is None):
return None
coords = re.findall('(GPS %s +: .+)' % type.capitalize(), exif_data)
if(coords is None or len(coords) == 0):
return None
coord_string = coords[0]
coordinate = re.findall('([0-9.]+)', coord_string)
direction = re.search('[NSEW]$', coord_string)
if(coordinate is None or direction is None):
return None
direction = direction.group(0)
decimal_degrees = float(coordinate[0]) + float(coordinate[1])/60 + float(coordinate[2])/3600
if(direction == 'S' or direction == 'W'):
decimal_degrees = decimal_degrees * -1
return decimal_degrees
"""
Get the date which the video was taken.
The date value returned is defined by the min() of mtime and ctime.
@returns, time object or None for non-video files or 0 timestamp
"""
def get_date_taken(self):
if(not self.is_valid()):
return None
source = self.source
seconds_since_epoch = min(os.path.getmtime(source), os.path.getctime(source))
# We need to parse a string from EXIF into a timestamp.
# we use date.strptime -> .timetuple -> time.mktime to do the conversion in the local timezone
exif_data = self.get_exif()
for key in ['Creation Date', 'Media Create Date']:
date = re.search('%s +: +([0-9: ]+)' % key, exif_data)
if(date is not None):
date_string = date.group(1)
try:
seconds_since_epoch = time.mktime(datetime.strptime(date_string, '%Y:%m:%d %H:%M:%S').timetuple())
break
except:
pass
if(seconds_since_epoch == 0):
return None
return time.gmtime(seconds_since_epoch)
"""
Get the duration of a video in seconds.
@ -50,6 +107,183 @@ class Video(Media):
return re.search('(\d{2}:\d{2}.\d{2})', key).group(1).replace('.', ':')
return None
"""
Get exif data from video file.
Not all video files have exif and this currently relies on the CLI exiftool program
@returns, string or None if exiftool is not found
"""
def get_exif(self):
exiftool = find_executable('exiftool')
if(exiftool is None):
return None
source = self.source
process_output = subprocess.Popen(['%s "%s"' % (exiftool, source)], stdout=subprocess.PIPE, shell=True)
return process_output.stdout.read()
"""
Get a dictionary of metadata for a video.
All keys will be present and have a value of None if not obtained.
@returns, dictionary or None for non-video files
"""
def get_metadata(self):
if(not self.is_valid()):
return None
source = self.source
metadata = {
"date_taken": self.get_date_taken(),
"latitude": self.get_coordinate('latitude'),
"longitude": self.get_coordinate('longitude'),
"album": None,
#"length": self.get_duration(),
"mime_type": self.get_mimetype(),
"base_name": os.path.splitext(os.path.basename(source))[0],
"extension": self.get_extension()
}
return metadata
"""
Set album for a photo
Not yet implemented
@param, name, string, Name of album
@returns, boolean
"""
def set_album(self, name):
if(name is None):
return False
"""
Set the date/time a photo was taken
@param, time, datetime, datetime object of when the photo was taken
@returns, boolean
"""
def set_datetime(self, time):
if(time is None):
return False
source = self.source
exif_metadata = pyexiv2.ImageMetadata(source)
exif_metadata.read()
exif_metadata['Exif.Photo.DateTimeOriginal'].value = time
exif_metadata['Exif.Image.DateTime'].value = time
exif_metadata.write()
return True
"""
Set lat/lon for a video
@param, latitude, float, Latitude of the file
@param, longitude, float, Longitude of the file
@returns, boolean
"""
def set_location(self, latitude, longitude):
if(latitude is None or longitude is None):
return False
print 'SET LOCATION %s %s' % (latitude, longitude)
result = self.__update_using_plist(latitude=latitude, longitude=longitude)
return result
def __update_using_plist(self, **kwargs):
if('latitude' not in kwargs and 'longitude' not in kwargs):
print 'No lat/lon passed into __create_plist'
return False
avmetareadwrite = find_executable('avmetareadwrite')
if(avmetareadwrite is None):
print 'Could not find avmetareadwrite'
return False
source = self.source
# First we need to write the plist for an existing file to a temporary location
with tempfile.NamedTemporaryFile() as plist_temp:
# We need to write the plist file in a child process but also block for it to be complete.
# http://stackoverflow.com/a/5631819/1318758
avmetareadwrite_generate_plist_command = '%s -p "%s" "%s"' % (avmetareadwrite, plist_temp.name, source)
write_process = subprocess.Popen([avmetareadwrite_generate_plist_command], stdout=subprocess.PIPE, shell=True)
streamdata = write_process.communicate()[0]
if(write_process.returncode != 0):
print 'Failed to generate plist file'
return False
with open(plist_temp.name, 'r') as plist_written:
plist_text = plist_written.read()
# Once the plist file has been written we need to open the file to read and update it.
plist_final = None
with open(plist_temp.name, 'w') as plist_written:
# Depending on the kwargs that were passed in we regex the plist_text before we write it back.
if('latitude' in kwargs and 'longitude' in kwargs):
latitude = kwargs['latitude']
longitude = kwargs['longitude']
# Add a literal '+' to the lat/lon if it is positive.
# Do this first because we convert longitude to a string below.
lat_sign = '+' if latitude > 0 else ''
# We need to zeropad the longitude.
# No clue why - ask Apple.
# We set the sign to + or - and then we take the absolute value and fill it.
lon_sign = '+' if longitude > 0 else '-'
longitude_str = '{:9.5f}'.format(abs(longitude)).replace(' ', '0')
print longitude_str
print '>%s%s%s%s' % (lat_sign, latitude, lon_sign, longitude_str)
plist_updated_text = re.sub('\>([+-])([0-9.]+)([+-])([0-9.]+)', '>%s%s%s%s' % (lat_sign, latitude, lon_sign, longitude_str), plist_text);
plist_final = plist_written.name
plist_written.write(plist_updated_text)
f = open('/Users/jaisenmathai/dev/tools/elodie/script.plist', 'w')
f.write(plist_updated_text)
f.close()
print plist_updated_text
# If we've written to the plist file then we proceed
if(plist_final is None):
print 'plist file was not be written to'
return False
# We create a temporary file to save the modified file to.
# If the modification is successful we will update the existing file.
temp_movie = None
with tempfile.NamedTemporaryFile() as temp_file:
temp_movie = temp_file.name
# We need to block until the child process completes.
# http://stackoverflow.com/a/5631819/1318758
avmetareadwrite_command = '%s -w %s "%s" "%s"' % (avmetareadwrite, plist_written.name, source, temp_movie)
update_process = subprocess.Popen([avmetareadwrite_command], stdout=subprocess.PIPE, shell=True)
streamdata = update_process.communicate()[0]
print streamdata
if(update_process.returncode != 0):
print '%s did not complete successfully' % avmetareadwrite_command
return False
# Before we do anything destructive we confirm that the file is in tact.
metadata = self.get_metadata()
if(metadata['latitude'] is None or metadata['longitude'] is None or metadata['date_taken'] is None):
print 'Something went wrong updating video metadata'
return False
# Copy file information from original source to temporary file before copying back over
print 'copy from %s to %s' % (temp_movie, source)
shutil.copystat(source, temp_movie)
shutil.move(temp_movie, source)
return True
"""
Static method to access static __valid_extensions variable.
@ -57,7 +291,7 @@ class Video(Media):
"""
@classmethod
def get_valid_extensions(Video):
return Video.__valid_extensions
return Media.video_extensions
class Transcode(object):
# Constructor takes a video object as it's parameter

View File

@ -5,26 +5,24 @@ import shutil
import sys
from elodie import arguments
from elodie.media.photo import Media
from elodie.media.photo import Photo
from elodie.media.video import Video
def main(argv):
args = arguments.parse(argv, None, ['file=','type='], './import.py --type=<photo or video> --file=<path to file>')
args = arguments.parse(argv, None, ['file='], './import.py --file=<path to file>')
if('file' not in args):
print 'No file specified'
sys.exit(1)
if('type' in args and args['type'] == 'video'):
media_type = Video
else:
media_type = Photo
media = Media.get_class_by_file(args['file'], [Photo, Video])
if(media is None):
print 'Not a valid file'
sys.exit(1)
media = media_type(args['file'])
metadata = media.get_metadata()
print '%r' % metadata
output = {'date_taken': metadata['date_taken']}
print '%r' % output

View File

@ -6,6 +6,7 @@ import sys
from elodie import arguments
from elodie import geolocation
from elodie.media.photo import Media
from elodie.media.photo import Photo
from elodie.media.video import Video
@ -16,12 +17,12 @@ def main(argv):
print 'No file specified'
sys.exit(1)
if('type' in args and args['type'] == 'photo'):
media_type = Photo
else:
media_type = Video
media = Media.get_class_by_file(args['file'], [Photo, Video])
if(media is None):
print 'Not a valid file'
sys.exit(1)
media = media_type(args['file'])
metadata = media.get_metadata()
place_name = geolocation.place_name(metadata['latitude'], metadata['longitude'])

View File

@ -29,10 +29,6 @@ def parse_arguments(args):
return config
def main(config, args):
try:
pyexiv2.xmp.register_namespace('https://github.com/jmathai/elodie/', 'elodie')
except KeyError:
pass
location_coords = None
for arg in args:
if(arg[:2] == '--'):
@ -54,19 +50,20 @@ def main(config, args):
if(_class is None):
continue
write = False
exif_metadata = pyexiv2.ImageMetadata(file_path)
exif_metadata.read()
media = _class(file_path)
updated = False
if(config['location'] is not None):
if(location_coords is None):
location_coords = geolocation.coordinates_by_name(config['location'])
if(location_coords is not None and 'latitude' in location_coords and 'longitude' in location_coords):
exif_metadata['Exif.GPSInfo.GPSLatitude'] = geolocation.decimal_to_dms(location_coords['latitude'])
exif_metadata['Exif.GPSInfo.GPSLatitudeRef'] = pyexiv2.ExifTag('Exif.GPSInfo.GPSLatitudeRef', 'N' if location_coords['latitude'] >= 0 else 'S')
exif_metadata['Exif.GPSInfo.GPSLongitude'] = geolocation.decimal_to_dms(location_coords['longitude'])
exif_metadata['Exif.GPSInfo.GPSLongitudeRef'] = pyexiv2.ExifTag('Exif.GPSInfo.GPSLongitudeRef', 'E' if location_coords['longitude'] >= 0 else 'W')
write = True
location_status = media.set_location(location_coords['latitude'], location_coords['longitude'])
if(location_status != True):
print 'Failed to update location'
sys.exit(1)
updated = True
if(config['time'] is not None):
time_string = config['time']
@ -79,21 +76,15 @@ def main(config, args):
sys.exit(1)
if(time_format is not None):
exif_metadata['Exif.Photo.DateTimeOriginal'].value = datetime.strptime(time_string, time_format)
exif_metadata['Exif.Image.DateTime'].value = datetime.strptime(time_string, time_format)
write = True
time = datetime.strptime(time_string, time_format)
media.set_datetime(time)
updated = True
if(config['album'] is not None):
exif_metadata['Xmp.elodie.album'] = config['album']
write = True
media.set_album(config['album'])
updated = True
if(write == True):
exif_metadata.write()
exif_metadata = pyexiv2.ImageMetadata(file_path)
exif_metadata.read()
media = _class(file_path)
if(updated == True):
dest_path = filesystem.process_file(file_path, destination, media, move=True, allowDuplicate=True)
print '%s -> %s' % (file_path, dest_path)