Last active
April 16, 2018 01:37
-
-
Save mozurin/bf8acb851595ac325add710ed5fbb9ac to your computer and use it in GitHub Desktop.
Convert local timestamps in Exif to UTC timestamps
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
''' | |
shift_exif_timezone.py - Convert local timestamps in Exif to UTC timestamps | |
Timestamps in Exif records are not timezone-aware. They are naive format. So | |
there will be protocol mismatch; some camera writes "local" timestamp in Exif, | |
but some software recognizes it as "UTC" on the other hand. To resolve the | |
situation, this snippet converts timestamps in '0th' and 'Exif' IFD to UTC | |
timestamps. | |
Note: GPS always uses UTC timestamps, so timestamps in 'GPS' IFD are not | |
problematic. | |
''' | |
import argparse | |
import datetime | |
import pathlib | |
import re | |
import shutil | |
import subprocess | |
import piexif | |
import pytz | |
import tzlocal | |
def shift_timestamp(local_timestamp_obj, from_zone=None): | |
''' | |
Convert local zone `datetime.datetime` object to UTC zone | |
`datetime.datetime` object. | |
:param local_timestamp_obj: Naive `datetime.datetime` object that | |
should be treated as local zone. | |
:param from_zone: Timezone object for the given local timestamp. Can | |
be None to use system locale instead. | |
:retvap: Converted UTC-aware `datetime.datetime` object. | |
''' | |
if from_zone is None: | |
from_zone = tzlocal.get_localzone() | |
return from_zone.localize(local_timestamp_obj).astimezone(pytz.utc) | |
def shift_exif_timestamp(local_timestamp, from_zone=None): | |
''' | |
Convert local Exif timestamp bytes to UTC timestamp bytes. | |
:param local_timestamp: Exif-format timestamp (yyyy:mm:dd hh:mm:ss), | |
in bytes object. | |
:param from_zone: Timezone object for the given local timestamp. Can | |
be None to use system locale instead. | |
:retvap: Converted UTC timestamp in byts object. | |
''' | |
FORMAT = '%Y:%m:%d %H:%M:%S' | |
return shift_timestamp( | |
datetime.datetime.strptime(local_timestamp.decode('utf-8'), FORMAT), | |
from_zone=from_zone | |
).strftime(FORMAT).encode('utf-8') | |
def shift_mov_timestamp(local_timestamp, from_zone=None): | |
''' | |
Convert local MOV timestamp string to UTC timestamp string. | |
:param local_timestamp: MOV-format timestamp (yyyy-mm-dd hh:mm:ss), | |
in str object. | |
:param from_zone: Timezone object for the given local timestamp. Can | |
be None to use system locale instead. | |
:retvap: Converted UTC timestamp in str object. | |
''' | |
FORMAT = '%Y-%m-%d %H:%M:%S' | |
return shift_timestamp( | |
datetime.datetime.strptime(local_timestamp, FORMAT), | |
from_zone=from_zone | |
).strftime(FORMAT) | |
if __name__ == '__main__': | |
# Parse command line arguments. | |
parser = argparse.ArgumentParser( | |
description=( | |
'Convert local timestamps in Exif to UTC timestamps. ' | |
'"*.JPG" and "*.MOV" files are supported ' | |
'(ffmpeg is required to convert "*.MOV" files). ' | |
'Note: "1st" and "thumbnail" IFDs will be removed because they ' | |
'can produce corrupted output files.' | |
) | |
) | |
parser.add_argument( | |
'-z', '--zone', | |
type=str, | |
help=( | |
'tz database timezone name of source timestamps. Omit to use ' | |
'system locale timezone.' | |
) | |
) | |
parser.add_argument( | |
'target_dir', | |
type=str, | |
help=( | |
'Path to the directory that contains target *.JPG / *.MOV files.' | |
) | |
) | |
parser.add_argument( | |
'output_dir', | |
type=str, | |
nargs='?', | |
help=( | |
'Path to the output directory. If omitted, "output" dirctory ' | |
'will be created under the target directory.' | |
) | |
) | |
args = parser.parse_args() | |
# Wrap arguments with appropriate objects. | |
if args.zone is not None: | |
args.zone = pytz.timezone(args.zone) | |
args.target_dir = pathlib.Path(args.target_dir).resolve() | |
if not args.target_dir.is_dir(): | |
raise RuntimeError('Target directory is not found.') | |
if args.output_dir is None: | |
args.output_dir = args.target_dir / 'output' | |
else: | |
args.output_dir = pathlib.Path(args.output_dir).resolve() | |
args.output_dir.mkdir(parents=False, exist_ok=True) | |
print('Target:', str(args.target_dir)) | |
print('Output:', str(args.output_dir)) | |
# Convert *.JPG files. | |
for in_path in args.target_dir.glob('*.jpg'): | |
out_path = args.output_dir / in_path.name | |
print('Processing:', in_path.name) | |
exif = piexif.load(str(in_path)) | |
for ifd_name, ifd_value in ( | |
('0th', piexif.ImageIFD.DateTime), | |
('Exif', piexif.ExifIFD.DateTimeOriginal), | |
('Exif', piexif.ExifIFD.DateTimeDigitized), | |
): | |
if ifd_name in exif and ifd_value in exif[ifd_name]: | |
exif[ifd_name][ifd_value] = shift_exif_timestamp( | |
exif[ifd_name][ifd_value], | |
args.zone | |
) | |
del exif['1st'] | |
del exif['thumbnail'] | |
shutil.copy(in_path, out_path) | |
piexif.insert(piexif.dump(exif), str(out_path)) | |
print(' OK') | |
# Convert *.MOV files | |
for in_path in args.target_dir.glob('*.mov'): | |
out_path = args.output_dir / in_path.name | |
print('Processing:', in_path.name) | |
r = subprocess.run( | |
['ffprobe', str(in_path)], | |
stdout=subprocess.PIPE, | |
stderr=subprocess.PIPE, | |
check=True | |
) | |
found_stamp = None | |
for line in r.stderr.decode('utf-8').split('\n'): | |
if line.strip().startswith('creation_time'): | |
found_stamp = line.split(':', 1)[1].strip() | |
break | |
if found_stamp is not None: | |
found_stamp = shift_mov_timestamp(found_stamp, args.zone) | |
subprocess.run( | |
[ | |
'ffmpeg', | |
'-i', | |
str(in_path), | |
'-acodec', | |
'copy', | |
'-vcodec', | |
'copy', | |
'-metadata', | |
'creation_time=%s' % found_stamp, | |
str(out_path), | |
], | |
check=True, | |
stdout=subprocess.PIPE, | |
stderr=subprocess.PIPE | |
) | |
else: | |
shutil.copy(in_path, out_path) | |
print(' OK') |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment