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

##
## bilderspur.py
##  - Bilderspur, a tool for converting Google Earth into a photo album
## 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
##

import EXIF
import kml

import datetime
import Image
import math
import Numeric
import os
import sys
import tempfile
import zipfile


abort_on_error = False
silent_mode = False


ACTION_FUNCTION_REGISTRY = {}


class PublicTempFileNamePair( object ):
	def __init__(self, temp_file_name, target_name_in_kmz):
		self.temp_file_name = temp_file_name
		self.target_name_in_kmz = target_name_in_kmz


class GeneratorResult( object ):
	def __init__(self, result, list_of_filename_pairs):
		self.result = result
		self.list_of_filename_pairs = list_of_filename_pairs



class BilderspurApplication( object ):
	'''This class is only instantiated once and its singleton
	instance is then passed to each plugin as "application"
	object.

	ALL IDENTIFIERS ENDING WITH _BS are not part of the
	public interface and should not be altered or even accessed
	by plugins.'''

	## bilderspur provides the following hooks which can be used
	## by plugins to intercept certain actions performed by the
	## main script.

	## Currently only one hook is implemented, it will be
	## executed for each image, just after the corresponding
	## ImageInfoRecords has been implemented. The record is
	## passed as the only argument to the hook function.
	HOOK_IMAGE_ANALYSIS_POST = 'Post-Image-Analysis-Hook'

	ACTION_NOOP = 'NONE'
	ACTION_IMAGE_DETAIL_GENERATION = 'HTML'
	ACTION_THUMBNAIL_GENERATION = 'ICON'

	ALL_ACTIONS = [ ACTION_IMAGE_DETAIL_GENERATION,
									ACTION_THUMBNAIL_GENERATION ]

	PublicTempFileNamePair = PublicTempFileNamePair

	GeneratorResult = GeneratorResult

	def __init__(self):
		self.hooks_BS = {}
		## Set at a later time, but before plugin initialisation:
		self.options = None
		self.args = None

	def registerHookExpression(self, hook_id, executable):
		if not hook_id in self.hooks_BS:
			self.hooks_BS[hook_id] = []
		self.hooks_BS[hook_id].append(executable)
		
	def runHooks_BS(self, hook_id, *args):
		if hook_id in self.hooks_BS:
			for executable in self.hooks_BS[hook_id]:
				executable(*args)



BILDERSPUR_APP = BilderspurApplication()



def delegateToPluginAction(action_name, *args):
	global ACTION_FUNCTION_REGISTRY
	return ACTION_FUNCTION_REGISTRY[action_name](*args)
	


def generateDetailHtmlForImage(iir, width, height, url):
	global BILDERSPUR_APP
	return delegateToPluginAction(BILDERSPUR_APP.ACTION_IMAGE_DETAIL_GENERATION,
																iir, width, height, url)



def generateThumbnailLink(image_cluster, url):
	global BILDERSPUR_APP
	return delegateToPluginAction(BILDERSPUR_APP.ACTION_THUMBNAIL_GENERATION,
																image_cluster, url)
	


class BilderspurException( Exception ):

	def __init__(self, info):
		Exception.__init__(self, info)



def euklideanLength(a):
	'''calculate the euklidean length of a
	rank 1 Numeric.array() vector'''
	return math.sqrt(Numeric.add.reduce(a*a))



def euklideanDistance(a1, a2):
	'''calculate the euklidean distance between two
	rank 1 Numeric.array() vectors'''
	return euklideanLength(a1 - a2)



def subElementWise(iterable1, iterable2):
	return [ x - y for (x, y) in zip(iterable1, iterable2) ]



def getPureFilename(dirfilename):
	'returns a filename without file extension'
	
	fname = os.path.basename(dirfilename)
	return os.path.splitext(fname)[0]



def convertPosListToFloat(pos_triple):
	'''Takes a latitude or longitude coordinate as a value triple
	as returned by the exif library and collapses it into a sinlge
	floating point value'''
	(deg, mnt, sec) = pos_triple
	
	val = deg.getValue()             \
				+ mnt.getValue() / 60      \
				+ sec.getValue() / 3600
	
	if math.floor(val) != deg.num:
		raise BilderspurException('Implausible GPS coordinate values encountered.')
	
	return val



def convertGpsTimestampToDateTime(datelist, timelist):
	'''returns a number which can be used two dates given in UTC'''
	(year, month, day) = datelist
	(hour, minute, second) = timelist

	return datetime.datetime(int(year.getValue()), int(month.getValue()),
													 int(day.getValue()),
													 int(hour.getValue()), int(minute.getValue()),
													 int(second.getValue()))



def a(iterable):
	'shortcut for creating a Numeric.array'
	return Numeric.array(iterable)



class ImageInfoRecord( object ):

	def __init__(self, filename,
							 name, datetime,
							 lat, lon, comment = None, tags = None,
							 thumbnail = None):
		## technical details
		self.filename = filename
		self.name = name
		self.datetime = datetime
		self.lat = lat
		self.lon = lon
		self.comment = comment
		self.tags = tags
		self.thumbnail = thumbnail
		## exta meta data required for KML generation
		## must be set explicitely and separately
		self.thumbnail_url = None
		self.detail_html = None

	pos = property(lambda self: a((self.lat, self.lon)), None, None,
								 'Position as a rank 1 Numeric.array vector')



def getInfoRecordForImage(filename):
	'''analyzes a single image and extracts all required information
	into an ImageInfoRecord'''
	try:
		fhandle = file(filename, 'rb')
	except Exception, e:
		raise BilderspurException('file "%s" inaccessible: %s'
															% (filename, e))

	tags = EXIF.process_file(fhandle)

	fhandle.seek(0)

	image = Image.open(fhandle)

	#fhandle.close()
	
	if not tags:
		raise BilderspurException('No EXIF information found')

	if 'GPS GPSLatitude' not in tags or 'GPS GPSLongitude' not in tags:
		raise BilderspurException('No GPS coordinates found')

	gpslat = tags['GPS GPSLatitude'].values
	gpslon = tags['GPS GPSLongitude'].values

	if len(gpslat) != 3 or len(gpslon) != 3:
		raise BilderspurException('Malformed GPS coordinate data')

	latval = convertPosListToFloat(gpslat)
	lonval = convertPosListToFloat(gpslon)

	if 'GPS GPSTimeStamp' not in tags or 'GPS GPSDate' not in tags:
		raise BilderspurException('No GPS timestamp information found')
	
	gpsdate = tags['GPS GPSDate'].values
	gpstime = tags['GPS GPSTimeStamp'].values

	dt = convertGpsTimestampToDateTime(gpsdate, gpstime)

	comment = None
	if 'COM' in image.app:
		## Assume comment to be UTF-8 coded.
		comment = unicode(image.app['COM'], 'utf-8')
		if comment[-1] == '\x00': # strip off trailing NULL char
			comment = comment[0:-1]

	return ImageInfoRecord(filename,
												 filename.split('/')[-1],
												 dt, latval, lonval,
												 comment, tags)



def compareIirsByTime(iir1, iir2):
	if iir1.datetime < iir2.datetime:
		return -1
	if iir1.datetime > iir2.datetime:
		return 1
	return 0



def analyzeImages(filenames):
	'''analyzes all provided images and returns a list of ImageInfo records'''

	global abort_on_error
	
	image_infos = []
	
	for filename in filenames:
		try:
			iir = getInfoRecordForImage(filename)

			## run plugin hooks
			BILDERSPUR_APP.runHooks_BS(BILDERSPUR_APP.HOOK_IMAGE_ANALYSIS_POST,
																 iir)
			
			image_infos.append(iir)
		except BilderspurException, e:
			print 'Error processing "%s", skipping image: %s' % (filename, e)
			if abort_on_error:
				sys.exit(2)

	return image_infos



class ImageCluster( object ):
	
	def __init__(self, image_list):
		self.images = image_list
		(latsum, lonsum) = reduce(lambda (latsum, lonsum), iir:
															(latsum + iir.lat, lonsum + iir.lon),
															image_list, (0, 0))
		(self.clat, self.clon) = (latsum / len(image_list), lonsum / len(image_list))
		(self.lat, self.lon) = (self.clat, self.clon)
		self.thumb_file_name = None


	def getCenter(self):
		"""returns the cluster's center coordinates,
		which also is its initial position"""
		return a((self.clat, self.clon))

	center = property(getCenter, None, None,
										'Center coordinates as a rank 1 Numeric.array vector')

	def getPosition(self):
		"""returns the cluster's position,
		which is initialized with its center coordinates"""
		return a((self.lat, self.lon))
	
	def setPosition(self, a):
		"""sets the cluster's position"""
		self.lat = a[0]
		self.lon = a[1]

	pos = property(getPosition, setPosition, None,
								 'Position as a rank 1 Numeric.array vector')



def clusterImages(max_cluster_diameter, max_cluster_size, image_infos):
	'''uses a pretty simple yet sufficient and fast clustering algorithm'''

	## order images by date
	image_infos.sort(compareIirsByTime)

	## cluster images by location (pretty dumb algorithm)
	image_clusters = []
	cur_imglist = []
	cur_pos = a((-1, -1))

	for iir in image_infos:
		dist = euklideanDistance(iir.pos, cur_pos)
		if (dist >= max_cluster_diameter
				or len(cur_imglist) >= max_cluster_size):
			if len(cur_imglist) > 0:
				image_clusters.append(ImageCluster(cur_imglist))
				
			cur_imglist = []
			cur_pos = iir.pos + 0 ## assign copy

		cur_imglist.append(iir)

	if len(cur_imglist) > 0:
		image_clusters.append(ImageCluster(cur_imglist))

	return image_clusters



def positionClusters(image_clusters, cluster_distance):
	'''shuffles the clusters around to respect the minimum
	cluster distance setting'''
	global silent_mode

	one_diff = Numeric.ones((2,))

	rerun = True
	i = 0
	while rerun:
		moved = False
		i += 1
		if not silent_mode: print 'Iteration %d.' % i
		for cur_clust in image_clusters:
			## find close neighbouring clusters
			close_clusters = [ cluster for cluster in image_clusters
												 if cur_clust != cluster \
												 and euklideanDistance(cur_clust.pos,
																							 cluster.pos) < cluster_distance ]

			#if len(close_clusters) > 0:
			#	print '%d close clusters have been encountered.' % len(close_clusters)
			
			if len(close_clusters) == 0:
				continue ## minimum distance is respected
		
			else:
				## show 'em respect
				moved = True
				for cc in close_clusters:
					diff_v = cc.pos - cur_clust.pos
					diff_len = euklideanLength(diff_v)
					if diff_len == 0: ## check sanity
						diff_v = one_diff
						diff_len = euklideanLength(one_diff)
					new_diff_v = diff_v / diff_len * cluster_distance * 1.00001
					cc.pos = cur_clust.pos + new_diff_v

		rerun = moved and i < 10
		## end of positioning loop



def buildKmlForClusters(kmlcontainer, kmldoc,
												icon_scale,
												icon_url_param, image_info_param,
												image_clusters,
												html_width, html_height):
	'''actually generate the KML for all clusters'''

	extra_files = []
	for image_cluster in image_clusters:
		cluster_result = generateKmlForCluster(
			kmlcontainer, icon_scale,
			icon_url_param, image_info_param,
			image_cluster,
			html_width, html_height)
		kmldoc.appendChild(cluster_result.result)
		extra_files += cluster_result.list_of_filename_pairs

	return extra_files



def generateKmlForCluster(kmlcontainer, icon_scale,
													icon_url_param, image_info_param,
													image_cluster,
													width, height):
	'''converts a non-empty image cluster into a <placemark>'''

	## comparator to order images in cluster:
	## images closest to the clusters center are listed first,
	## within all images with the same distance from the center the
	## images are sorted ascending by age.
	def cmpIirsByDistanceFromCenter(iir1, iir2):
		dist_iir1 = euklideanDistance(iir1.pos, clst_pos)
		dist_iir2 = euklideanDistance(iir2.pos, clst_pos)
		if dist_iir1 < dist_iir2:
			return -1
		if dist_iir1 > dist_iir2:
			return 1
		return compareIirsByTime(iir1, iir2)

	(image_list, clst_pos) = (image_cluster.images, image_cluster.pos)

	image_list.sort(cmpIirsByDistanceFromCenter)

	extra_files = []

	cluster_preview = generateThumbnailLink(image_cluster,
																					icon_url_param)

	extra_files += cluster_preview.list_of_filename_pairs

	detail_result_list = [ generateDetailHtmlForImage(iir, width, height,
																										image_info_param)
												 for iir in image_list ]
	
	for detail_result in detail_result_list:
		extra_files += detail_result.list_of_filename_pairs
		
	htmlstr = '\n'.join([ details.result
												for details in detail_result_list ])

	#if len(image_list) == 1:
	#	name = image_list[0].name
	#else:
	#	name = '%d images' % (len(image_list),)

	kml_placemark_code = getKmlPlacemarkCode(kmlcontainer,
																					 None, clst_pos[0], clst_pos[1],
																					 htmlstr,
																					 icon_scale,
																					 cluster_preview.result)

	return GeneratorResult(kml_placemark_code, extra_files)



def getKmlPlacemarkCode(kmlcontainer,
												name, lat, lon, desc,
												icon_scale, icon_fname):
	'creates a complete KML Placemark Tag for the provided parameters'

	el_style = None
	if icon_fname != None:
		el_style = kmlcontainer.createStyle(
			children = kmlcontainer.createIconStyle(
			icon = kmlcontainer.createIcon(icon_fname),
			scale = icon_scale
			))

	return kmlcontainer.createPlacemark(
		name = name,
		lat  = lat, lon = lon,
		desc = desc, style = el_style)



def generateKmz(file, kmlcontainer, extra_files):
	ziparc = zipfile.ZipFile(file, 'w', zipfile.ZIP_DEFLATED)

	kmltmp = tempfile.NamedTemporaryFile()
	kmlcontainer.writepretty(kmltmp)
	kmltmp.flush()

	ziparc.write(kmltmp.name, 'bilderspur.kml')
	kmltmp.close()

	for file_pair in extra_files:
		if file_pair.target_name_in_kmz != None:
			ziparc.write(file_pair.temp_file_name,
									 file_pair.target_name_in_kmz)

	ziparc.close()



def getCommandLineParser():
	'''Initializes and returns the command line parser.'''
	from optparse import OptionParser
	
	parser = OptionParser("usage: %prog [options] imagefiles")
	parser.add_option("-o", "--output", dest="outfilename",
										help="name of the KML/KMZ file to be generated" \
										"(stdout by default)",
										metavar="KMLFILE", default=None)
	parser.add_option("-t", "--title", dest="title",
										help="the TITLE your image track should use inside " \
										"Google Earth (\"Bilderspur\" by default)",
										metavar="TITLE", default='Bilderspur')
	parser.add_option("-W", "--image-width", dest="width",
										help="width for your fotos in HTML units " \
										"(750px by default)",
										metavar="WIDTH", default="750")
	parser.add_option("-H", "--image-height", dest="height",
										help="height for your fotos in HTML units " \
										"(undefined by default)",
										metavar="WIDTH", default=None)
	parser.add_option("-w", "--thumbnail-width", dest="thumbnail_width",
										help="width of the generated thumbnail images, in " \
										"pixels. Does not directly influence display size, " \
										"see also '-s'. Can be used to enhance detail or " \
										"reduce storage space. (64 pixels by default)",
										metavar="THUMB_WIDTH", default=64)
	parser.add_option("-s", "--thumbnail-scale", dest="thumbnail_scale",
										help="Scaling factor (0.1 to 3.0) to be applied to " \
										"the thumbnail images used as placemark icons. GE " \
										"normally uses a standard size for all icons, " \
										"independant of their pyhsical resolution, and this " \
										"factor can be used to alter the display size. See " \
										"'-w' to change the physical icon resolution to " \
										"enhance detail or reduce storage space. " \
										"(2.5 by default)",
										metavar="THUMB_SCALING_FACTOR", default=2.5)
	parser.add_option("-a", "--abort-on-error",
										action="store_true", dest="quitonerror",
										help="Quit if an error occurs. By default, the " \
										"image will be skipped and Bilderspur will continue " \
										"processing",
										default=False)
	parser.add_option("-i", "--icon-dest-path", dest="icon_dest_path",
										help="If set, thumbnails of all images are stored in PATH and used as placemark icons (unset by default)",
										metavar="PATH", default = None)
	parser.add_option("-u", "--icon-url", dest="icon_url_param",
										help="Parameter for thumbnail URL generation. You " \
										"can eg. set it to an URL or path which will be " \
										"prepended to the generated tumbnail file name. " \
										"(--icon-dest-path is used by default when " \
										"generating a KML, and the empty string is used " \
										"when generating a KMZ)",
										metavar="URL", default = None)
	parser.add_option("-U", "--image-info", dest="image_info_param",
										help="Parameter for image detail generation. By " \
										"default, you can set it to an URL or path which " \
										"will be used to display the image within the GE " \
										"placemark's details text",
										metavar="URL", default = None)
	parser.add_option("-P", "--plugin-defs", dest="plugin_defs",
										help="Plugin specification string. This allows you " \
										"to control which functionalitiy is performed by " \
										"which plugin. Please refer to the documentation " \
										"for details.",
										metavar="PLUGIN_DEFS", default = None)
	parser.add_option("-c", "--cluster-diameter", dest="clusterdiameter",
										help="Cluster images which are closer than DEGREE " \
										"degree (This does only an approximate distance " \
										"calculation, as it merely calculates the euklidean " \
										"distance between the image coordinates and does " \
										"not take the round earth surface into account. " \
										"This approximation should not be relevant with " \
										"typical clustering distances, if you really need " \
										"Bilderspur to do correct mathematics in this place " \
										"just tell me, it#ss easily fixable.) (0.001 by " \
										"default)",
										metavar="DEGREE", default = 0.001)
	parser.add_option("-m", "--cluster-max", dest="clustermax",
										help="Maxmimum number of images in a single cluster " \
										"(15 by default)",
										metavar="NUMIMG", default = 15)
	parser.add_option("-d", "--cluster-dist", dest="clusterdistance",
										help="Minimum distance between clusters in DEGREE " \
										"(This does only an approximate distance " \
										"calculation, as it merely calculates the euklidean " \
										"distance between the cluster coordinates and does " \
										"not take the round earth surface into account. " \
										"This approximation should not be relevant with " \
										"typical cluster distances, if you really need " \
										"Bilderspur to do correct mathematics in this " \
										"place just tell me, it's easily fixable.) (0.0025 " \
										"by default)",
										metavar="DISTDEGREE", default = 0.0025)
	parser.add_option("-z", "--kmz", dest="gen_kmz",
										action="store_true",
										help="Generate a compressed KMZ file which also " \
										"contains all generated thumbnail images, if any, " \
										"instead of a plain KML.")
	parser.add_option("-q", "--quiet",
										action="store_true", dest="silent",
										help="Be quiet, issue error messages only.")

	return parser



def initializePlugin(plugin_name, plugin_args):
	## import plugin
	try:
		plugin = __import__(plugin_name,
												globals(), locals(), [])
	except Exception, desc:
		print "Plugin-Import von %s fehlgeschlagen: %s" \
					% (plugin_name, desc)
		sys.exit(1)

	if plugin.BILDERSPUR_PLUGIN_API_VERSION != 1:
		print "Unsupported bilderspur plugin API version encountered, " \
					"%s announces API version %s, but only version 1 is " \
					"supported." \
					% (plugin_name, plugin.BILDERSPUR_PLUGIN_API_VERSION)
		sys.exit(1)

	## request executable expression
	try:
		return plugin.init(BILDERSPUR_APP, plugin_args)
	except Exception, desc:
		print "Plugin %s: Initialisierung fehlgeschlagen. [%s]" \
					% (plugin_name, desc)
		sys.exit(1)



def loadPlugins(plugindefs):
	if plugindefs != None:
		plugindefs = 'bilderspur_std_plugin##' + plugindefs
	else:
		plugindefs = 'bilderspur_std_plugin'

	plugin_registry = {}

	for plugin_name in plugindefs.split('##'):
		action_list = []
		action_str = ''
		args = None
		if '@@' in plugin_name:
			(plugin_name, action_str) = plugin_name.split('@@')
		if '::' in plugin_name:
			(plugin_name, args) = plugin_name.split('::')

		if plugin_name not in plugin_registry:
			plugin = initializePlugin(plugin_name, args)
			plugin_registry[plugin_name] = plugin
		else:
			plugin = plugin_registry[plugin_name]
			if args != None:
				print 'WARNING: Plugin listed twice, ignoring arguments ' \
							'of all but its first\ninvokation.'

		if len(action_str) == 0:
			action_list = plugin.getSupportedActions()
		else:
			action_list = action_str.split(',')
		print 'Plugin:', plugin_name, ' Actions:', action_list

		for action in action_list:
			if action != BILDERSPUR_APP.ACTION_NOOP:
				action_function = plugin.getActionFunction(action)
				ACTION_FUNCTION_REGISTRY[action] = action_function



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

	BILDERSPUR_APP.options = options
	BILDERSPUR_APP.args = args

	abort_on_error = options.quitonerror
	silent_mode = options.silent
	gen_kmz = options.gen_kmz
	
	cluster_diameter = float(options.clusterdiameter)
	cluster_maxsize = int(options.clustermax)
	cluster_distance = float(options.clusterdistance)

	icon_width_px = options.thumbnail_width
	icon_scale = options.thumbnail_scale
	## working path for thumbnail creation
	icon_dest_path = options.icon_dest_path

	icon_url_param = options.icon_url_param
	if icon_url_param == None:
		if gen_kmz:
			icon_url_param = ''
		else:
			icon_url_param = icon_dest_path
	image_info_param = options.icon_url_param

	if len(args) == 0:
		print 'You must specify at least one input image file.\n'
		sys.exit(1)

	loadPlugins(options.plugin_defs)

	if options.outfilename == None:
		fkml = sys.stdout
		silent_mode = True
	else:
		try:
			fkml = file(options.outfilename, 'w')
		except StandardError, e:
			print 'Could not create output file "%s": %s' % (options.outfilename, e)
			sys.exit(3)

	kmlcontainer = kml.KML(options.title)
	kmldoc = kmlcontainer.createDocument(options.title)
	kmlcontainer.appendChild(kmldoc)

	if not silent_mode: print 'Reading image information...'
	image_infos = analyzeImages(args)

	if not silent_mode: print 'Clustering images...'
	image_clusters = clusterImages(cluster_diameter, cluster_maxsize, image_infos)

	if not silent_mode: print 'Positioning clusters...'
	positionClusters(image_clusters, cluster_distance)

	if not silent_mode: print 'Creating KML structure...'
	extra_files = buildKmlForClusters(kmlcontainer, kmldoc,
																		icon_scale,
																		icon_url_param, image_info_param,
																		image_clusters,
																		options.width, options.height)
	
	if not silent_mode: print 'Writing output file "%s"...' \
		 % options.outfilename
	if gen_kmz:
		generateKmz(fkml, kmlcontainer, extra_files)
	else:
		kmlcontainer.writepretty(fkml)
