Skip to content

Instantly share code, notes, and snippets.

@mozurin
Last active April 16, 2018 01:37
Show Gist options
  • Save mozurin/bf8acb851595ac325add710ed5fbb9ac to your computer and use it in GitHub Desktop.
Save mozurin/bf8acb851595ac325add710ed5fbb9ac to your computer and use it in GitHub Desktop.
Convert local timestamps in Exif to UTC timestamps
'''
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