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"
__version__="0.9"
 
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
      pass
    else:
      utcoffset = time.timezone
      pass
    pass
 
  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
    pass
  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
      pass
    pass
 
  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)
        pass
      pass
    pass
 
  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:
    try:
      file=open(image, 'rb')
    except:
      print '\'%s\' is unreadable' % image
      continue
 
    # Get the EXIF tag data
    tags = EXIF.process_file(file, stop_tag='DateTimeOriginal')
    file.close()
    if 'EXIF DateTimeOriginal' in tags:
      # Get the time
      value = str(tags['EXIF DateTimeOriginal'])
      data[exifTimeToGpxTime(value)] = image
      pass
    pass
 
  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'
    pass
  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
  else:
    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'])
    pass
  else:
    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'])))
    pass
 
  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()
  trkpttimes.sort()
  for imgtime in imgtimes.keys():
    pos = bisect.bisect_right(trkpttimes, imgtime)
    if pos == 0:
      data[imgtimes[imgtime]] = makeTrackDict(imgtime, tmtrkpts[trkpttimes[0]])
      pass
    elif pos >= len(trkpttimes):
      data[imgtimes[imgtime]] = makeTrackDict(imgtime, tmtrkpts[trkpttimes[-1]])
      pass
    else:
      if interpolate:
        data[imgtimes[imgtime]] = makeInterpolatedTrackDict(imgtime, tmtrkpts[trkpttimes[pos-1]], tmtrkpts[trkpttimes[pos]], utcoffset)
        pass
      else:
        data[imgtimes[imgtime]] = makeClosestTrackDict(imgtime, tmtrkpts[trkpttimes[pos-1]], tmtrkpts[trkpttimes[pos]], utcoffset)
        pass
      pass
    pass
 
  return data
  pass
 
 
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')
  file.write('\t<Document>\n')
  pass
 
 
def writeKMLDocumentFooter(file):
  file.write('\t</Document>\n')
  file.write('</kml>\n')
  pass
 
 
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<LineStyle>\n')
  file.write('\t\t\t\t<color>%s</color>\n' % linestyle['color'])
  file.write('\t\t\t\t<width>%s</width>\n' % linestyle['width'])
  file.write('\t\t\t</LineStyle>\n')
  file.write('\t\t</Style>\n')
  return styleName
 
def writeLineStringHeader(file, trk, stylename):
  file.write('\t\t<Placemark>\n')
  name = getElementValue(trk, 'name')
  if name:
    file.write('\t\t\t<name>%s</name>\n' % name)
    pass
  desc = getElementValue(trk, 'desc')
  if desc:
    file.write('\t\t\t<description>%s</description>\n' % desc)
    pass
  if stylename:
    file.write('\t\t\t<styleUrl>#%s</styleUrl>\n' % stylename)
    pass
  file.write('\t\t\t<LineString>\n')
  file.write('\t\t\t\t<extrude>0</extrude>\n')
  file.write('\t\t\t\t<tessellate>1</tessellate>\n')
  file.write('\t\t\t\t<altitudeMode>clampToGround</altitudeMode>\n')
  file.write('\t\t\t\t<coordinates>\n')
  pass
 
def writeLineStringFooter(file):
  file.write('\t\t\t\t</coordinates>\n')
  file.write('\t\t\t</LineString>\n')
  file.write('\t\t</Placemark>\n')
  pass
 
def writeTrkpts(file, trkpts):
  for trkpt in trkpts:
    ele = getElementValue(trkpt, 'ele')
    if not ele:
      ele = '0'
      pass
    file.write('\t\t\t\t\t%s,%s,%s\n' % (trkpt.getAttribute('lon'), trkpt.getAttribute('lat'), ele))
    pass
  pass
 
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)
    pass
 
  for trk in trks:
    if linestyle['style'] == STYLE_SPLIT:
      writeLineStringHeader(file, trk, stylename)
      pass
 
    # Get all of the segments
    trksegs = trk.getElementsByTagName('trkseg')
    for trkseg in trksegs:
      if linestyle['style'] == STYLE_SEGMENTED:
        writeLineStringHeader(file, trk, stylename)
        pass
 
      # Write all of the points
      writeTrkpts(file, trkseg.getElementsByTagName('trkpt'))
 
      if linestyle['style'] == STYLE_SEGMENTED:
        writeLineStringFooter(file)
        pass
      pass
 
    if linestyle['style'] == STYLE_SPLIT:
      writeLineStringFooter(file)
      pass
    pass
 
  if linestyle['style'] == STYLE_CONTINUOUS:
    writeLineStringFooter(file)
    pass
  pass
 
 
# 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] = {}
      pass
    groups[timestr][image] = imagepos[image]
    pass
  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<Placemark>\n')
  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<Style>\n')
    file.write('\t\t\t\t<IconStyle>\n')
    file.write('\t\t\t\t\t<Icon>\n')
    file.write('\t\t\t\t\t\t<href>%s</href>\n' % icon)
    file.write('\t\t\t\t\t</Icon>\n')
    file.write('\t\t\t\t</IconStyle>\n')
    file.write('\t\t\t</Style>\n')
    pass
 
  file.write('\t\t\t<Point>\n')
  file.write('\t\t\t\t<coordinates>%s, %s, %s</coordinates>\n' % (position['lon'], position['lat'], position['ele']))
  file.write('\t\t\t</Point>\n')
  file.write('\t\t</Placemark>\n')
  pass
 
# 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()
    dates.sort()
    for timestr in dates:
      file.write('\t\t<Folder>\n')
      file.write('\t\t<name>%s Pictures</name>\n' % timestr)
      images = groups[timestr]
      imagenames = images.keys()
      imagenames.sort()
      for imagename in imagenames:
        writeImagePoint(file, imagename, images[imagename], icon)
        pass
      file.write('\t\t</Folder>\n')
      pass
    pass
  else:
    imagenames = imagepos.keys()
    imagenames.sort()
    for imagename in imagenames:
      writeImagePoint(file, imagename, imagepos[imagename], icon)
      pass
    pass
  pass
 
 
# 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'
  try:
    kmlfile = open(kmlfilename, 'w')
    pass
  except:
    print '\'%s\' can not be opened for writing' % kmlfilename
    sys.exit(2)
 
  writeKMLDocumentHeader(kmlfile)
 
  if trks:
    writeTrkLineString(kmlfile, trks, linestyle)
    pass
 
  writeImagePoints(kmlfile, imagepos, group, icon)
 
  writeKMLDocumentFooter(kmlfile)
 
  kmlfile.close()
 
  # Create the KMZ file
  try:
    archive = zipfile.ZipFile(outfilename, 'w')
    pass
  except:
    print '\'%s\' can not be opened for writing' % outfilename
    sys.exit(2)
 
  archive.write(kmlfilename, os.path.basename(kmlfilename))
  for imagename in imagepos.keys():
    archive.write(imagename, os.path.join('images', os.path.basename(imagename)))
    pass
  archive.close()
 
  # Final cleanup
  os.remove(kmlfilename)
  pass
 
 
def convert(infilename, imagedir, outfilename, interpolate, utcoffset = None, linestyle = default_linestyle, group = True, icon = None):
  # Parse the GPX file
  try:
    doc = minidom.parse(infilename)
    pass
  except:
    print '\'%s\' can not be opened for processing' % infilename
    sys.exit(2)
 
  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)
  pass
 
 
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
  pass
 
 
def usageError(name, message):
  print message
  printUsage(name)
  sys.exit(2)
 
 
def main():
  linestyle = copy.copy(default_linestyle)
  icon = None
  outfilename = None
  interpolate = False
  group = False
  utcoffset = None
 
  # Get tha parameters from command line
  try:
    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;
        pass
      elif opt in ['-c', '--line-color']:
        linestyle['color'] = val
        pass
      elif opt in ['-w', '--line-width']:
        linestyle['width'] = val
        pass
      elif opt in ['-s', '--line-style']:
        styles = {'CONTINUOUS': STYLE_CONTINUOUS, 'SPLIT': STYLE_SPLIT, 'SEGMENTED': STYLE_SEGMENTED}
        key = val.upper()
        if key in styles.keys():
          linestyle['style'] = styles[key]
          pass
        else:
          usageError(os.path.basename(sys.argv[0]), 'Unrecognized line style \'%s\'' % key)
          pass
        pass
      elif opt in ['-i', '--image-icon']:
        icon = val
        pass
      elif opt in ['-n', '--interpolate']:
        interpolate = True
        pass
      elif opt in ['-g', '--group']:
        group = True
        pass
      elif opt in ['-u', '--utcoffset']:
        utcoffset = int(val)
        pass
      pass
    pass
  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')
    pass
 
  infilename = args[0]
  imagedir = args[1]
 
  # Create output filename if none was specified
  if not outfilename:
    outfilename = '%s.kmz' % os.path.splitext(infilename)[0]
    pass
 
#  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)
  pass
 
 
if __name__ == "__main__":
  main()
  pass

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
 
-s|--line-style <CONTINUOUS|SPLIT|SEGMENTED>
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
 
-n|--interpolate
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
 
-g|--group
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
 
<gpx-filename>
name of the GPX file to process (required)
 
<image-directory>
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.  

 

Project: