#! /usr/bin/python
# -*- coding: iso-8859-15 -*-

##
## exiftimeadjust.py
##  - ExifTimeAdjust, a tool for correcting digital foto timestamps,
##    assuming a systematic, linar drift the digital camera's clock
## See http://www.ohrner.net/ for latest news and updates, please.
## 
## Copyright (C) 2006  Gunter Ohrner "gunter _(@)_ ohrner.net"
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program; if not, write to the Free Software
## Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
##


##
## Note: This is a tiny script and its designed to be tiny and simple.
## This intentionally reduces flexibility and extensibility in favour
## of simplicity.
## It simply writes a all messages to stdout / stderr, does not support
## proper logging or error / exception management. In case of an error,
## the script simply aborts with an exit code of 1.
##


import EXIF

import csv
import optparse
import os
import sys
import time


class TimestampPair( object ):
	def __init__(self, ts1, ts2):
		self.ts1 = ts1
		self.ts2 = ts2

DRIFT_ADJUSTMENT_FILE_HEADER = 'exiftimeadjust clock drift adjustment file V1'

META_DATE_FIELDS_DATETIME = [ 'Image DateTime',
															'EXIF DateTimeOriginal',
															'EXIF DateTimeDigitized' ]


def parseTs(ts_string):
	'''convert string representation of timestamp to Unix timestamp (float)'''
	try:
		struct_ts = time.strptime(ts_string, '%d.%m.%Y %H:%M:%S')
	except ValueError:
		struct_ts = time.strptime(ts_string, '%Y:%m:%d %H:%M:%S')
		## FIXME: Catch ValueError
	return time.mktime(struct_ts)



def getExifTimestamp(image_filename):
	'''reads the images EXIF header, extracts the timestamp and
	returns it as a Unix timestamp'''
	image_handle = open(image_filename, 'rb')
	try:
		exif_header = EXIF.process_file(image_handle)
		if not exif_header:
			print 'No readable EXIF header in %s.' % image_filename
			sys.exit(1)

		img_secs = None
		for fieldname in META_DATE_FIELDS_DATETIME:
			if fieldname in exif_header:
				ts_exif_field = exif_header[fieldname]
				ts_string = '%s' % ts_exif_field
				try:
					img_secs = parseTs(ts_string)
				## successfully parsed timestamp string
					break
				except ValueError:
					pass

		if img_secs == None:
			print 'No readable timestamp found in %s.' % image_filename
			sys.exit(1)

		return img_secs
			
	finally:
		image_handle.close()
	


def importDriftRecording(drift_record_file):
	'''import clock drift information'''
	
	ts_pair_list = []
	
	csvhandle = open(drift_record_file, 'r')
	reader = csv.reader(csvhandle)
	for name_ts_row in reader:
		(image_filename, real_timestamp_str) = name_ts_row

		real_secs = parseTs(real_timestamp_str)

		img_secs = getExifTimestamp(image_filename)

		ts_pair_list.append(TimestampPair(real_secs, img_secs))
		
	csvhandle.close()

	return ts_pair_list



def computeDriftAdjustmentParameters(ts_pair_list):
	'''computing drift adjustment function'''
	
	first_ts_pair = ts_pair_list[0]
	last_ts_pair = ts_pair_list[-1]
	
	diff1 = first_ts_pair.ts1 - first_ts_pair.ts2
	diff2 = last_ts_pair.ts1 - last_ts_pair.ts2

	clock_drift_factor = (diff2 - diff1)/(last_ts_pair.ts2 - first_ts_pair.ts2)
	correction_point = first_ts_pair.ts2
	clock_offset = diff1

	return (clock_drift_factor, correction_point, clock_offset)



def isCorrectionFunctionSane(ts_pair_list, corrector):
	'''sanity checking provided drift data'''
	
	for ts_pair in ts_pair_list:
		ts_fixed = corrector(ts_pair.ts2) + ts_pair.ts2
		print 'Fixed TS: %s, Error: %u/%s (rounded, real)' % (ts_fixed,
																													round(ts_pair.ts1 - ts_fixed),
																													ts_pair.ts1 - ts_fixed)
		if not ts_pair.ts1 - 1 <= ts_fixed <= ts_pair.ts1 + 1:
			print 'Correction mismatch: %s ; %s ; %s' % (ts_pair.ts1, ts_pair.ts2, ts_fixed)
			return False

	return True



def storeCorrectionParameters(drift_adjustment_filename, drift, correction_point, offset):
	'''Persists drift adjustment parameters in a file'''
	
	fd = open(drift_adjustment_filename, 'w')
	fd.write(DRIFT_ADJUSTMENT_FILE_HEADER +';%s;%s;%s' % (drift, correction_point, offset))
	fd.close()



def readCorrectionParameters(drift_adjustment_filename):
	'''Imports drift adjustment parameters from a file'''
	
	fd = open(drift_adjustment_filename, 'r')
	if fd.read(len(DRIFT_ADJUSTMENT_FILE_HEADER)) != DRIFT_ADJUSTMENT_FILE_HEADER:
		print 'Invalid drift adjustment file format (Unknown Header).'
		sys.exit(1)

	data_str = fd.read()
	fields = data_str.split(';')
	if len(fields) != 4:
		print 'Invalid drift adjustment file format. (Wrong Data Field Count)'
		sys.exit(1)
		
	fd.close()

	return [ float(field) for field in fields[1:] ]



def createTsCorrectionFunction(clock_drift_factor, correction_point,
															 clock_offset):
	'''Uses the provided parameters to construct and return a linear
	clock drift correction function'''
	
	return lambda ts: ((ts - correction_point) * clock_drift_factor
										 + clock_offset)



def createDriftFile(drift_adjustment_filename, calibration_csv_filename):
	'''Analyze the provided calibration file, compute a linear clock drift
	correction function, perform some sanity checks and store the computed
	function parameters into the provided file if everything seems to be ok'''
	
	ts_pair_list = importDriftRecording(calibration_csv_filename)

	if len(ts_pair_list) < 2:
		print 'Need at least two data points for linear adjustment.'
		sys.exit(1)

	for ts_pair in ts_pair_list:
		print '%s ; %s' % (ts_pair.ts1, ts_pair.ts2)

	(clock_drift_factor, correction_point,
	 clock_offset) = computeDriftAdjustmentParameters(ts_pair_list)
	
	print clock_drift_factor * 24*3600, 's/day', correction_point, clock_offset
	
	calcTsCorrection = createTsCorrectionFunction(clock_drift_factor,
																								correction_point,
																								clock_offset)
	
	if isCorrectionFunctionSane(ts_pair_list, calcTsCorrection):
		storeCorrectionParameters(drift_adjustment_filename, clock_drift_factor,
															correction_point, clock_offset)
	else:
		print "Your digital camera's clock does not seem to drift linearily!"
		sys.exit(1)



def sgn(val):
	if val == 0:
		return 0
	if val < 0:
		return -1
	else:
		return 1



def adjustExifTimestamp(image_name, adjust_by_secs):
	'''Adds the given adjustment to an images EXIF timestamps,
	the adjustment may be negative.'''
	adjust_by_secs = round(adjust_by_secs)
	s = abs(adjust_by_secs)
	h, s = divmod(s, 3600)
	m, s = divmod(s, 60)

	if sgn(adjust_by_secs) < 0:
		sign = '-'
	else:
		sign = ''

	adjustment_str = '%s%02u:%02u:%02u' % (sign, h, m, s)

	print 'Adjusting %s by %s (%u secs)' % (image_name, adjustment_str,
																					adjust_by_secs)

	## avoid spawning a shell for security and performance reasons
	err = os.spawnlp(os.P_WAIT,
	                 'exiv2', 'exiv2', 'ad', '-a', adjustment_str, image_name)
	if err != 0:
		 print 'exiv2 exited with return code: %s' % err
		 sys.exit(1)



def adjustTimestamps(drift_adjustment_filename, image_names):
	(clock_drift_factor, correction_point,
	 clock_offset) = readCorrectionParameters(drift_adjustment_filename)

	calcTsCorrection = createTsCorrectionFunction(clock_drift_factor,
																								correction_point,
																								clock_offset)

	for image_name in image_names:
		print 'Analyzing and adjusting %s...' % (image_name)
		img_secs = getExifTimestamp(image_name)
		ts_correction = calcTsCorrection(img_secs)
		adjustExifTimestamp(image_name, ts_correction)
		


def getCommandLineParser():
	'''Initializes and returns the command line parser.'''
	parser = optparse.OptionParser("usage: %prog [options] imagefiles")
	parser.add_option("-c", "--create-drift-adjustment-file",
										dest="create_drift_adjustment_filename",
										help="Creates a clock drift adjustment file using " \
										"the specified name. A CSV file containing the " \
										"names of calibration images must be the only " \
										"positional argument. See the README for details.",
										metavar="DRF_ADJ_FILE", default=None)
	parser.add_option("-a", "--adjust-timestamps",
										dest="drift_adjustment_filename",
										help="Use the specified clock drift adjustment file " \
										"to correct the image's time stamp information.",
										metavar="DRF_ADJ_FILE", default=None)

	return parser



if __name__ == '__main__':
	parser = getCommandLineParser()
	(options, args) = parser.parse_args()

	if options.create_drift_adjustment_filename != None:
		if len(args) != 1:
			print 'Wrong arguments: If creating a new drift adjustment file, ' \
						'you must specify exactly one single calibration CSV file.'
			sys.exit(1)

		createDriftFile(options.create_drift_adjustment_filename, args[0])

	elif options.drift_adjustment_filename != None:
		if len(args) == 0:
			print 'You did not specify any images to be corrected.'
			sys.exit(1)

		adjustTimestamps(options.drift_adjustment_filename, args)

	else:
		print 'You must specify an action. (Create adjustment file or ' \
					'perform adjustment.)'
		sys.exit(1)
