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

##
## bilderspur.py
##  - Bilderspur, a tool for converting Google Earth into a photo album
## 
## 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 ImageDraw
import math
import Numeric
import os
import sys
import tempfile
import zipfile


abort_on_error = False
silent_mode = False

class BilderspurException( Exception ):

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



def iif(cond, true_val, false_val):
	'''conditional operator replacement mainly for use in the
	lambda-expressions which you can specify to customize thumbnail
	URL and image detail generation'''
	if cond:
		return true_val
	else:
		return false_val



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 addElementWise(iterable1, iterable2):
	return [ x + y for (x, y) in zip(iterable1, iterable2) ]



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 quoteHtml(str):
	return str.replace('"', '&quot;').replace('<', '&lt;')



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):
		self.filename = filename
		self.name = name
		self.datetime = datetime
		self.lat = lat
		self.lon = lon
		self.comment = comment
		self.tags = tags
		self.thumbnail = thumbnail

	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:
		raise BilderspurException('file unreadable')

	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:
		comment = image.app['COM']
		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:
			image_infos.append(getInfoRecordForImage(filename))
		except BilderspurException, e:
			print 'Error processing "%s": %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 generateIconsForClusters(icon_path, icon_width_px, image_clusters):
	'''generate an icon thumbnail image for each cluster'''

	if icon_path == None:
		return
	
	tn_id_gen = thumbnailIdGenerator()
	
	for cluster in image_clusters:
		cluster.thumb_file_name = '%s/tn%s.png' \
																		% (icon_path, tn_id_gen.next(),)
		
		generateThumbnailForImagelist(cluster.thumb_file_name, icon_width_px,
																	cluster.images)
		


def thumbnailIdGenerator():
	'returns natural numbers in ascending order, starting by 0'
	tn_id = 0
	while True:
		yield tn_id
		tn_id += 1



def generateThumbnailForImagelist(icon_fname, thumbnailsize, image_list):
	'draw a representative thumbnail image for the given image list'

	if len(image_list) == 0 or thumbnailsize < 10:
		return None
	
	imgnum = min(len(image_list)-1, 4) ## 0-based
	offset = int(thumbnailsize / 10.0)

	## GE expects a completely transparent icon frame of
	## a least one pixel width. So make all icons somewhat
	## smaller and leave a pixel space around them.
	
	tn = Image.new('RGBA',
								 ## "+2" to leave space for a one pixel completely transparent border
								 2 * [thumbnailsize + imgnum*offset + 2],
								 (0, 0, 0, 0))

	## "+1" to leave space for a one pixel completely transparent border
	origin = 2 * [ offset * imgnum + 1 ]
	max_dim = [ 0, 0 ]

	for i in xrange(imgnum, -1, -1):
		img = Image.open(image_list[i].filename)
		sfactor = float(thumbnailsize) / max(img.size)
		new_size = [ int(x * sfactor) for x in img.size ]
		img = img.resize(new_size, Image.BICUBIC)
		img_max_coord = addElementWise(new_size, (-1, -1))
		draw = ImageDraw.Draw(img)
		draw.rectangle([ 0, 0 ] + img_max_coord, outline = 0)

		img_max_coord_in_tn = addElementWise(origin, new_size)
		tn.paste(img, origin + img_max_coord_in_tn)
		max_dim = map(max, max_dim, img_max_coord_in_tn)
		origin = map(lambda x: x-offset, origin)

	## GE icons must have dimensions which are a power of 2!
	## Leave some space for a one-pixel transparent border.
	max_dim = addElementWise(max_dim, (1, 1))
	tn = tn.crop([ 0, 0 ] + max_dim)
	tn.save(icon_fname)



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

	for image_cluster in image_clusters:
		kmldoc.appendChild(generateKmlForCluster(
			kmlcontainer, icon_scale,
			icon_url_expr, icon_url_param,
			image_info_expr, image_info_param,
			image_cluster,
			html_width, html_height))



def generateKmlForCluster(kmlcontainer, icon_scale,
													icon_url_expr, icon_url_param,
													image_info_expr, 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,
	## of images having the same distance from the center older
	## images come first.
	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)

	icon_fname = None
	if image_cluster.thumb_file_name != None:
		icon_fname = icon_url_expr(image_cluster.thumb_file_name,
															 image_list,
															 icon_url_param)
		
	htmlstr = '\n'.join(
		[ image_info_expr(iir, width, height, image_info_param)
			for iir in image_list])

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

	return getKmlPlacemarkCode(kmlcontainer,
														 None, clst_pos[0], clst_pos[1],
														 htmlstr,
														 icon_scale, icon_fname)



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 = None,
		lat  = lat, lon = lon,
		desc = desc, style = el_style)



def generateKmz(file, kmlcontainer, image_clusters):
	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 cluster in image_clusters:
		if cluster.thumb_file_name != None:
			ziparc.write(cluster.thumb_file_name,
									 os.path.basename(cluster.thumb_file_name))

	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("-e", "--icon-url-expression", dest="icon_url_expr",
										help="a Python (lambda-)expression for calculating thumbnail icon URLs. See README for details and examples.",
										metavar="PYTHONEXPRESSION", 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("-E", "--image-url-expression", dest="image_info_expr",
										help="a Python (lambda-)expression for calculating an image's details display. See README for details and examples.",
										metavar="PYTHONEXPRESSION", 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#ss 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 generateDetailHtmlForImage(iir, width, height, url):
	"""Default HTML generator for the details document of the
	generated placemarks.
	This default method generates an <img />-tag with the image's
	path as its src-attribute and a paragraph containing the
	image comment, if one exists.
	If 'url' is not None, the src-attribute is generated by
	prepending the given URL to the image's file name.
	"""

	img_basename = os.path.basename(iir.filename)
	if url == None:
		src_url = iir.filename
	else:
		src_url = '%s%s' % (url, img_basename)

	html_width = html_height = ''
	if width != None:
		html_width = ' width="%s"' % width
	if height != None:
		html_height = ' height="%s"' % height

	img_body_str = '<a href="%s"><img%s%s src="%s" /></a>' \
								 % (quoteHtml(src_url),
										html_width, html_height,
										quoteHtml(src_url))

	if 'EXIF DateTimeOriginal' in iir.tags:
		datetimestr = iir.tags['EXIF DateTimeOriginal'].values
	else:
		datetimestr = ''
		
	if iir.comment != None:
		commentstr = iir.comment
	else:
		commentstr = ''

	return '''<table width="100%%">
	<tr><td colspan="2" align="center"><strong>%s</strong></td></tr>
	<tr><td colspan="2" align="center">%s</td></tr>
	<tr><td>%s</td><td align="right">%s</td></tr>
	</table>''' % (commentstr, img_body_str, quoteHtml(img_basename),
								 datetimestr)



def generateThumbnailLink(thumb_file_name, image_list, url):
	"""Default generator for the thumbnail image 'href' text.
	This default method generates an <href />-tag with the image's
	path as its text contents.
	If 'url' is not None, the src-attribute is generated by
	prepending the given URL to the image's file name.
	"""
	len(image_list) ## shut up pychecker's "image_list is unused" warning...
	if url == None:
		src_url = thumb_file_name
	else:
		src_url = '%s%s' % (url, os.path.basename(thumb_file_name))

	return src_url



if __name__ == '__main__':
	parser = getCommandLineParser()
	(options, args) = parser.parse_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

	icon_url_expr = options.icon_url_expr
	image_info_expr = options.image_info_expr

	if image_info_expr != None:
		image_info_expr = eval(image_info_expr)
	else:
		image_info_expr = generateDetailHtmlForImage

	if icon_url_expr != None:
		icon_url_expr = eval(icon_url_expr)
	else:
		icon_url_expr = generateThumbnailLink

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

	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 icon_dest_path != None:
		if not silent_mode: print 'Generating thumbnail icons...'
		generateIconsForClusters(icon_dest_path, icon_width_px, image_clusters)

	if not silent_mode: print 'Creating KML structure...'
	buildKmlForClusters(kmlcontainer, kmldoc,
											icon_scale,
											icon_url_expr, icon_url_param,
											image_info_expr, image_info_param,
											image_clusters,
											options.width, options.height)
	
	if not silent_mode: print 'Writing output file...'
	if gen_kmz:
		generateKmz(fkml, kmlcontainer, image_clusters)
	else:
		kmlcontainer.writepretty(fkml)
