Bundling GPS Tracks and Geographically Located Photos in KML

Recently I described my effort to add GPS data recorded during a road trip and a canoe trip to Google Maps.  I had written some JavaScript to import my data into a map, with a few options for customization, but felt that there was more that could be done with the data from the canoe trip.  I had a number of photographs from the trip and a desire to map them, along with the GPS data, at the positions at which they were taken.  Although the photographs did not contain any meta-data providing the GPS coordinates for the location at which they were taken, they did contain meta-data indicating the time at which they were taken.  Since I had a GPX file full of time associated geographic positions, I decided to fuse the two together.  

This time, rather than import the GPS data and photographs directly into Google Maps, I decided to write a program to generate a KMZ file intended to be viewed with Google Earth.  I felt that Google Earth, with its 3D capability, would provide a richer user experience.  The program was written as a Python script which, when given a GPX file containing time-tagged GPS positions and the name of a directory containing photographs with EXIF time tags, determines the approximate location at which each image was taken.  A KML file containing a KML LineString to represent the GPS track and KML Placemarks to mark the photograph locations, complete with an info bubble to display the image when clicked, is generated and packaged with the photographs in a KMZ file ready to be dragged and dropped into Google Earth for vieweing:

KMZ Generation Program
__author__="Dustin Graves"
__date__ ="$Sep 29, 2009 11:31:56 PM$"
__license__="Python License"
__copyright__="Copyright (c) 2009, Dustin Graves"
import os
import sys
import bisect
import time
import copy
import getopt
import zipfile
import xml.dom.minidom as minidom
import EXIF
# Path syles
STYLE_CONTINUOUS = 0   # All track points are connected
STYLE_SEGMENTED = 1    # Track segments are drawn separately
STYLE_SPLIT = 2        # Tracks are drawn separately
# LinePath style - color is AABBGGRR
default_linestyle = {'style': STYLE_CONTINUOUS, 'color': 'ac00ffff', 'width': 5}
# Convert the GPX ISO 8061 time element from UTC time to local time
# utctime: time to convert
# utcoffset: UTC offset from local timezone in seconds for adjusting time
#            If no offset is specified, the current timezone's offset
#            will be used
def getLocalTime(utctimestr, utcoffset = None):
  # Get time object from ISO time string
  utctime = time.strptime(utctimestr, '%Y-%m-%dT%H:%M:%SZ')
  # Convert to seconds since epoch
  secs = time.mktime(utctime)
  if not utcoffset:
    # Get local timezone offset
    if time.localtime(secs).tm_isdst:
      utcoffset = time.altzone
      utcoffset = time.timezone
  return time.localtime(secs - utcoffset)
# Extract string value from DOM element
def getElementValue(element, tag):
  values = element.getElementsByTagName(tag)
  if values:
    return values[0].firstChild.data
  return None
# Build a dictionary of GPX trkpt elements keyed by associated time.
# Time is converted from UTC to a local time using utcoffset, or current
# timezone if no offset is specified
# trkpts: list of GPX trkpt elements
# utcoffset: UTC offset from local timezone in seconds for adjusting trkpt time
def buildTimeTrkptDictionary(trkpts, utcoffset = None):
  # Create the dictionary
  data = {}
  # Build a dictionary of trackpoints keyed by time
  for trkpt in trkpts:
    # Get time from trkpt
    timestr = getElementValue(trkpt, 'time')
    if timestr:
      st = getLocalTime(timestr, utcoffset)
      data[time.strftime('%Y-%m-%dT%H:%M:%S', st)] = trkpt
  return data
# Retrieve image filenames with '.jpg' and '.tiff' extensions from directory
# directory: Directory containing images to process
def getImageFiles(directory):
  images = []
  # Get the list of image names from the directory
  for root, dirs, files in os.walk(directory):
    for file in files:
      # Add files with the '.jpg' and '.tiff' extensions to the list
      if os.path.splitext(file)[-1] in ['.jpg', '.jpeg', '.tiff', '.JPG', '.JPEG', '.TIFF']:
        images.append(root + os.path.sep + file)
  return images
# Convert a EXIF time to the ISO 8061 time format used by GPX (without the 'Z')
def exifTimeToGpxTime(timestr):
  st = time.strptime(timestr, '%Y:%m:%d %H:%M:%S')
  return time.strftime('%Y-%m-%dT%H:%M:%S', st)
# Build a dictionary of images elements keyed by associated EXIF time
# image: list of image filenames
def getImageTimes(images):
  # Create the dictionary
  data = {}
  for image in images:
      file=open(image, 'rb')
      print '\'%s\' is unreadable' % image
    # Get the EXIF tag data
    tags = EXIF.process_file(file, stop_tag='DateTimeOriginal')
    if 'EXIF DateTimeOriginal' in tags:
      # Get the time
      value = str(tags['EXIF DateTimeOriginal'])
      data[exifTimeToGpxTime(value)] = image
  return data
# Create a dictionary containing position and time associated with an image
# with keys 'time', 'lat', 'lon', and 'ele'
def makeTrackDict(timestr, trkpt):
  ele = getElementValue(trkpt, 'ele')
  if not ele:
    ele = '0'
  return {'time': timestr, 'lat': trkpt.getAttribute('lat'), 'lon': trkpt.getAttribute('lon'), 'ele': ele}
# Get the position from the track with the closest time to the specified image time
def makeClosestTrackDict(timestr, trkpt1, trkpt2, utcoffset):
  tgtts = time.strptime(timestr, '%Y-%m-%dT%H:%M:%S')
  tgttime = time.mktime(tgtts)
  # Get track data for calculation
  pt1ts = getLocalTime(getElementValue(trkpt1, 'time'), utcoffset)
  pt2ts = getLocalTime(getElementValue(trkpt2, 'time'), utcoffset)
  pt1time = time.mktime(pt1ts)
  pt2time = time.mktime(pt2ts)
  pt1 = makeTrackDict(timestr, trkpt1)
  pt2 = makeTrackDict(timestr, trkpt2)
  # Time is between the two point times; find closest time
  if (tgttime - pt1time) < (pt2time - tgttime):
    return pt1
    return pt2
# Calculate position for a picture between the two bounding positions.
# Using basic linear interpolation.
def makeInterpolatedTrackDict(timestr, trkpt1, trkpt2, utcoffset):
  tgtts = time.strptime(timestr, '%Y-%m-%dT%H:%M:%S')
  tgttime = time.mktime(tgtts)
  # Get track data for calculation
  pt1ts = getLocalTime(getElementValue(trkpt1, 'time'), utcoffset)
  pt2ts = getLocalTime(getElementValue(trkpt2, 'time'), utcoffset)
  pt1time = time.mktime(pt1ts)
  pt2time = time.mktime(pt2ts)
  pt1 = makeTrackDict('', trkpt1)
  pt2 = makeTrackDict('', trkpt2)
  # Calculate positions; if the times are the same, calculation can not be made (divide by zero)
  if pt1time == pt2time:
    lat = float(pt1['lat'])
    lon = float(pt1['lon'])
    ele = float(pt1['ele'])
    lat = float(pt1['lat']) + ((tgttime - pt1time) / (pt2time - pt1time)) * ((float(pt2['lat']) - float(pt1['lat'])))
    lon = float(pt1['lon']) + ((tgttime - pt1time) / (pt2time - pt1time)) * ((float(pt2['lon']) - float(pt1['lon'])))
    ele = float(pt1['ele']) + ((tgttime - pt1time) / (pt2time - pt1time)) * ((float(pt2['ele']) - float(pt1['ele'])))
  return {'time': timestr, 'lat': str(lat), 'lon': str(lon), 'ele': str(ele)}
# Process track, comparing EXIF time from an image to time of each point.  The
# position with the closest time is associated with the image.  An interpolated
# position for a point between two points with times which the image's
# time falls between can optionally be calculated.
# file: GPX file
# directory: Photo directory
# interpolate: interpolate between points or choose closest point
# utcoffset: UTC offset for timezone used by photograph times in seconds
#            GPX uses UTC time, but the camera uses the local timezone time
#            The utcoffset allows the GPX times to be converted to the same
#            timezone used by the photographs
def findPositionsByTime(trkpts, directory, interpolate, utcoffset = None):
  # Get the dictionary of points keyed by time
  tmtrkpts = buildTimeTrkptDictionary(trkpts, utcoffset)
  # Get all the '.jpg' and '.tiff' images from the specified directory and place
  # into dictionary keyed by time
  imgtimes = getImageTimes(getImageFiles(directory))
  data = {}
  # Find the closest track time to each image time
  trkpttimes = tmtrkpts.keys()
  for imgtime in imgtimes.keys():
    pos = bisect.bisect_right(trkpttimes, imgtime)
    if pos == 0:
      data[imgtimes[imgtime]] = makeTrackDict(imgtime, tmtrkpts[trkpttimes[0]])
    elif pos >= len(trkpttimes):
      data[imgtimes[imgtime]] = makeTrackDict(imgtime, tmtrkpts[trkpttimes[-1]])
      if interpolate:
        data[imgtimes[imgtime]] = makeInterpolatedTrackDict(imgtime, tmtrkpts[trkpttimes[pos-1]], tmtrkpts[trkpttimes[pos]], utcoffset)
        data[imgtimes[imgtime]] = makeClosestTrackDict(imgtime, tmtrkpts[trkpttimes[pos-1]], tmtrkpts[trkpttimes[pos]], utcoffset)
  return data
def writeKMLDocumentHeader(file):
  file.write('<?xml version="1.0" encoding="UTF-8"?>\n')
  file.write('<kml xmlns="http://www.opengis.net/kml/2.2">\n')
def writeKMLDocumentFooter(file):
def writeLineStringStyle(file, linestyle):
  styleName = 'linestyleC%sW%s' % (linestyle['color'], linestyle['width'])
  file.write('\t\t<Style id="%s">\n' % styleName)
  file.write('\t\t\t\t<color>%s</color>\n' % linestyle['color'])
  file.write('\t\t\t\t<width>%s</width>\n' % linestyle['width'])
  return styleName
def writeLineStringHeader(file, trk, stylename):
  name = getElementValue(trk, 'name')
  if name:
    file.write('\t\t\t<name>%s</name>\n' % name)
  desc = getElementValue(trk, 'desc')
  if desc:
    file.write('\t\t\t<description>%s</description>\n' % desc)
  if stylename:
    file.write('\t\t\t<styleUrl>#%s</styleUrl>\n' % stylename)
def writeLineStringFooter(file):
def writeTrkpts(file, trkpts):
  for trkpt in trkpts:
    ele = getElementValue(trkpt, 'ele')
    if not ele:
      ele = '0'
    file.write('\t\t\t\t\t%s,%s,%s\n' % (trkpt.getAttribute('lon'), trkpt.getAttribute('lat'), ele))
def writeTrkLineString(file, trks, linestyle):
  # Start the placemark
  stylename = writeLineStringStyle(file, linestyle)
  # Draw the path using specified style
  if linestyle['style'] == STYLE_CONTINUOUS:
    writeLineStringHeader(file, trks[0], stylename)
  for trk in trks:
    if linestyle['style'] == STYLE_SPLIT:
      writeLineStringHeader(file, trk, stylename)
    # Get all of the segments
    trksegs = trk.getElementsByTagName('trkseg')
    for trkseg in trksegs:
      if linestyle['style'] == STYLE_SEGMENTED:
        writeLineStringHeader(file, trk, stylename)
      # Write all of the points
      writeTrkpts(file, trkseg.getElementsByTagName('trkpt'))
      if linestyle['style'] == STYLE_SEGMENTED:
    if linestyle['style'] == STYLE_SPLIT:
  if linestyle['style'] == STYLE_CONTINUOUS:
# Create a dictionary keyed by date with lists of images whose date match the key
def groupByDate(imagepos):
  groups = {}
  for image in imagepos:
    timestr = imagepos[image]['time'].split('T')[0]
    if not timestr in groups:
      groups[timestr] = {}
    groups[timestr][image] = imagepos[image]
  return groups
# Write a single placemark point
def writeImagePoint(file, imagename, position, icon = None):
  basename = os.path.basename(imagename)
  description = '<![CDATA[<img src="%s/%s">]]>' % ('images', basename)
  name = os.path.splitext(basename)[0];
  file.write('\t\t\t<name>%s</name>\n' % name)
  file.write('\t\t\t<description>%s</description>\n' % description)
  if icon:
    file.write('\t\t\t\t\t\t<href>%s</href>\n' % icon)
  file.write('\t\t\t\t<coordinates>%s, %s, %s</coordinates>\n' % (position['lon'], position['lat'], position['ele']))
# Writes placemark points for each image at the associated position.
# The image is placed in the placemark description so that it is displayed
# when the place mark is clicked.  Images can be grouped by date for better
# organization within the Google Earth places list.  An icon for the placemark
# can optionally be specified.
def writeImagePoints(file, imagepos, group = True, icon = None):
  if group:
    groups = groupByDate(imagepos)
    dates = groups.keys()
    for timestr in dates:
      file.write('\t\t<name>%s Pictures</name>\n' % timestr)
      images = groups[timestr]
      imagenames = images.keys()
      for imagename in imagenames:
        writeImagePoint(file, imagename, images[imagename], icon)
    imagenames = imagepos.keys()
    for imagename in imagenames:
      writeImagePoint(file, imagename, imagepos[imagename], icon)
# Write a KMZ file containing the specified tracks and images.  Images can
# optionally be grouped by date and a custom icon for the image placemrk can
# be optionally specified.
def writeKMZTrkImagefile(outfilename, trks, linestyle, imagepos, group = True, icon = None):
  # Write the KML file
  kmlfilename = os.path.splitext(outfilename)[0] + '.kml'
    kmlfile = open(kmlfilename, 'w')
    print '\'%s\' can not be opened for writing' % kmlfilename
  if trks:
    writeTrkLineString(kmlfile, trks, linestyle)
  writeImagePoints(kmlfile, imagepos, group, icon)
  # Create the KMZ file
    archive = zipfile.ZipFile(outfilename, 'w')
    print '\'%s\' can not be opened for writing' % outfilename
  archive.write(kmlfilename, os.path.basename(kmlfilename))
  for imagename in imagepos.keys():
    archive.write(imagename, os.path.join('images', os.path.basename(imagename)))
  # Final cleanup
def convert(infilename, imagedir, outfilename, interpolate, utcoffset = None, linestyle = default_linestyle, group = True, icon = None):
  # Parse the GPX file
    doc = minidom.parse(infilename)
    print '\'%s\' can not be opened for processing' % infilename
  root = doc.documentElement
  # Get the track points
  trkpts = root.getElementsByTagName('trkpt')
  # Get the image positions
  imagepos = findPositionsByTime(trkpts, imagedir, interpolate, utcoffset)
  # Get the tracks
  trks = root.getElementsByTagName('trk')
  # Write the KMZ file
  writeKMZTrkImagefile(outfilename, trks, linestyle, imagepos, group, icon)
def printUsage(name):
  print 'Usage: python %s [-c|--line-color <aabbggrr>] [-w|--line-width <width>]' \
        '[-s|--line-style <CONTINUOUS|SPLIT|SEGMENTED>]' \
        '[-i|--image-icon <image-filename>] [-n|--interpolate]' \
        '[-g|--group] [-u|--utcoffset <seconds>]' \
        '[-o|--output-file <filename>] <gpx-filename> <image-directory>' % name
def usageError(name, message):
  print message
def main():
  linestyle = copy.copy(default_linestyle)
  icon = None
  outfilename = None
  interpolate = False
  group = False
  utcoffset = None
  # Get tha parameters from command line
    opts, args = getopt.gnu_getopt(sys.argv[1:], 'o:c:w:s:i:ngu:', ['output-file=', 'line-color=', 'line-width=', 'line-style=', 'image-icon=', 'interpolate', 'group', 'utcoffset='])
    for opt, val in opts:
      if opt in ['-o', '--output-file']:
        outfilename = val;
      elif opt in ['-c', '--line-color']:
        linestyle['color'] = val
      elif opt in ['-w', '--line-width']:
        linestyle['width'] = val
      elif opt in ['-s', '--line-style']:
        key = val.upper()
        if key in styles.keys():
          linestyle['style'] = styles[key]
          usageError(os.path.basename(sys.argv[0]), 'Unrecognized line style \'%s\'' % key)
      elif opt in ['-i', '--image-icon']:
        icon = val
      elif opt in ['-n', '--interpolate']:
        interpolate = True
      elif opt in ['-g', '--group']:
        group = True
      elif opt in ['-u', '--utcoffset']:
        utcoffset = int(val)
  except getopt.GetoptError, err:
    usageError(os.path.basename(sys.argv[0]), str(err))
  if len(args) != 2:
    usageError(os.file.basename(sys.argv[0]), 'Program requires at least two arguments')
  infilename = args[0]
  imagedir = args[1]
  # Create output filename if none was specified
  if not outfilename:
    outfilename = '%s.kmz' % os.path.splitext(infilename)[0]
#  imagedir = "C:\\Documents and Settings\\graves\\Desktop\\oldforge"
#  infilename = "C:\\Documents and Settings\\graves\\Desktop\\oldforge.gpx"
#  outfilename = "C:\\Documents and Settings\\graves\\Desktop\\oldforge.kmz"
#  interpolate = True
#  group = True
#  linestyle['color'] = 'acdb56b5'
  convert(infilename, imagedir, outfilename, interpolate, utcoffset, linestyle, group, icon)
if __name__ == "__main__":

The program, in its current form, is intended to be run from the command prompt.  It requires that the user specify the name of a GPX file and a directory containing images which are to be fused together into a KMZ file.  Some optional parameters to control the generation of the KML data are also available.   The full usage documentation for the program is as follows:

 python tripconvert.py [-c|--line-color <aabbggrr>] [-w|--line-width <width>]
                       [-s|--line-style <CONTINUOUS|SPLIT|SEGMENTED>]
                       [-i|--image-icon <image-filename>] [-n|--interpolate]
                       [-g|--group] [-u|--utcoffset <seconds>]
                       [-o|--output-file <filename>] <gpx-filename> <image-directory>
-c|--line-color <aabbggrr>
specify the color for the KML LineString object, default is 'ac00ffff'
-w|--line-width <width>
specify the thickness for the KML LineString object, default is 5
specify the stile for the KML LineString object as one of the following:
CONTINUOUS - All GPS track points are connected
SPLIT - all track points within a GPX track object are connected, but the tracks are not connected
SEGMENTED - all track points within a GPX track segment are connected, but the segments are not connected
default is CONTINUOUS
-i|--image-icon <image-filename>
specify the image icon for the KML Placemark
when enabled, the position for a photograph whose time does not exactly match the time of a GPX track point will be calculated as a position between the two track points with times immediately before and after the photograph's time using simple linear interpolation; when not enabled, the position from the GPX track point with the time closest to the photograph's time will be used
photographs are grouped by date within the Google Earth Places list
-u|--utcoffset <seconds>
specify a time offset, in seconds, to be applied to the UTC image timestamp such that the times are adjusted to fall within a specific time zone (e.g. -14400 seconds in the Eastern time zone during daylight savings time)
-o|--output-file <filename>
specify the name of the KMZ file to be generated, defaults to the GPX filename with the .gpx extension changed to .kmz
name of the GPX file to process (required)
name of the directory containing the photograps to be processed (required)

Relevant Files

Here are links to the file containing the Python code and a sample KMZ file containing the fused canoe trip GPS data and photographs discussed by this post:

The TripConverter script has one external dependency, which provides functionality for extracting EXIF information from image files, that can be found here.