Generating a Google Map (KML) from GPS-tagged photos


Google world map overlaid with route generated from digital photo metadata

Joining the dots from digital photo metadata..

As I mentioned earlier, my current compact travel-zoom camera is the excellent Panasonic DMC-TZ40. As it saves the GPS coordinates with every photo I take I thought it'd be a fun little exercise to try and script the extraction of this metadata, with a view to plotting the points on a map or even better - drawing a line between them all so I could retrace my steps!

Looping through files is pretty trivial in all programming and scripting languages, and most if not all of them already have JPEG libraries that allow for the straightforward extraction of the photo metadata. The final piece was finding a suitable output format that would be understood for rendering points and lines on maps. Enter KML:

Keyhole Markup Language (KML) is an XML notation for expressing geographic annotation and visualization within Internet-based, two-dimensional maps and three-dimensional Earth browsers.
- [Wikipedia](

Google acquired the language in 2004, implemented it in Google Earth and provide excellent documentation of the format.

The result of pulling this all together is the following Ruby script which I've called photo-mapper and is an Open Source project on GitHub..

# This Ruby script generates two Keyhole Markup Language files:
#  1. points.kml  - a point for every photo with GPS coords
#  2. route.kml   - a single line that joins every photo with GPS coords
# from a folder (and sub-folders) of digital photos
# The intention is to create a chronological map of
#  photographed destinations from the digital photos themselves
# Usage: ruby photo-mapper.rb starting_directory
# e.g.:  ruby photo-mapper.rb Photos
# Author: Andrew Freemantle  

require 'exifr'
require 'date'

# The Panasonic DMC-TZ40 always saves GPS coords. Even indoors. Exclude photos with these coords..
INVALID_GPS_COORDS = [17056881.853375, 17056881.666666668]
# Exclude the following directories when traversing..
IGNORE_DIR = ['.', '..', '.git', '.DS_Store', '@eaDir']
# List of supported photo filename extensions..
ALLOWED_EXTENSIONS = ['.jpg', '.JPG', '.jpeg', '.JPEG']

# Directory traversing class
#  initialized with a starting path, it recursively descends through
#  any directories it finds that aren't in the IGNORE_DIR array above
class Traverse

    def initialize(path, pointsFile, routeFile)
        puts "in " + path
        @files = Dir.entries(path).sort
        @files.each do |f|
            if !IGNORE_DIR.include? f
                if, f))
                    @t =, f), pointsFile, routeFile)
                elsif File.file?(File.join(path, f))

                    # Is this an allowed file?
                    if ALLOWED_EXTENSIONS.include? File.extname(f)
                        # Does this file have Geo coords?
                        puts "Got allowed file #{File.join(path,f)}"

                            @file =, f))
                            if @file.exif?()
                                # We have EXIF, but do we have sensible Lat & Long?
                                if @file.gps != nil
                                    if !INVALID_GPS_COORDS.include? @file.gps.latitude
                                        #puts @file.gps
                                        routeFile.puts("#{@file.gps.longitude},#{@file.gps.latitude},0 ")
                                        #puts "No GPS in " + ARGV[0]
                                #puts "No EXIF in " + ARGV[0]
                        rescue EOFError
                            # End Of File can happen for partially copied or uploaded photos
                            #  and there's nothing we can do here but report out and skip
                            puts "Reached EOF for #{File.join(path,f)} - skipped."




# Start the two output files:
pointsFile ='points.kml', 'w')
routeFile ='route.kml', 'w')

date =

# write the file headers
pointsFile.puts("<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<kml xmlns=\"\">
   <description>Generated on #{date.strftime('%a %-d %b %Y')} by photo-mapper -</description>

routeFile.puts("<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<kml xmlns=\"\">
   <description>Generated on #{date.strftime('%a %-d %b %Y')} by photo-mapper -</description>
  <Style id=\"linestyle\">

go =[0]), pointsFile, routeFile)

# Close the files


# Done  :o)

For the route output, the script assumes that sorting the directories and files alphabetically will result in the same order the photos were taken. This should be true and work fine in most cases. Personally, I organise my photos like so:

      day - with short description of the day or the location
        photo.jpg, photo2.jpeg, etc..


    01 - January
      01 - New Years day dip in the North Sea
        DSC01265.jpg, DSC01266.jpg, etc..
      02 - Discharged from hospital after recovering from hypothermia
    02 - February
      29 - Cycle-ride along the coast
        DSC01411.jpg, etc..

Viewing with Google Earth

Google earth overlaid with points generated from digital photo metadata

The resulting points.kml opened in Google Earth

Google Earth natively supports KML, so once you have it installed and open, just go to File > Open and select either points.kml, route.kml or both!

Viewing with Google Maps

Google world map overlaid with route generated from digital photo metadata

The resulting route.kml opened in Google Maps. A little more involved and not without some limitations..

Google Maps also understands KML files, but there are some limitations which I'll point out in a moment.

  1. First, head over Google Maps and sign in with your Google+ account, or create one
  2. Next, expand the Google Maps menu by clicking on the 3 horizontal bars inside the maps search box on the left, then choose 'My Maps' and 'Create'
  3. You'll get a new web-browser tab with a new map in it. Simply click the highlighted 'Import' link under the 'Untitled layer' and select the points.kml, route.kml or both!

Importing the KML file into Google Maps is pretty straightforward

Importing the KML file into Google Maps is pretty straightforward but requires a Google+ account


You'll likely run into the following message..
Google world map overlaid with route generated from digital photo metadata

Google Maps will only import the first 10 layers and 2000 features from this KML file.

For now at least, Google Maps is limited to the number of points and lines it can process. photo-mapper generates a single layer, but each photo will be a point so we're currently limited to 2,000 geotagged photos.

Google's retired MapsEngine could handle far more data so I think it'll just be a matter of time before this restriction is lifted.

Panasonic LUMIX MapTool.pkg – Open Source edition


The Panasonic LUMIX DMC-TZ40 Digital Camera

The Panasonic LUMIX DMC-TZ40 Digital Camera, in a word, excellent!

I recently upgraded my compact travel zoom camera from the tried and trusted Sony DMC-HX9V to the Panasonic DMC-TZ40. It's quite an upgrade and while I'm delighted by my new purchase, the review will have to wait now that I can use a major feature: MAPS!

Maps! On a Digital Camera! What ever will they think of next!

Maps! On a Digital Camera! What ever will they think of next!

Yes, as well as GPS Geotagging of photographs, the Panasonic LUMIX DMC-TZ30 (ZS20) and Panasonic LUMIX DMC-TZ40 (LZ30) come with Map Data that you can use to find your way to your next photo shooting location, or back to your hostel if you find yourself lost!

The Map Data comes on the CD-ROM / DVD with the camera, along with a little application called the LUMIX Map Tool. There's a version for Microsoft Windows and Apple Mac OSX, but not for Linux.

To save on space I'd copied the contents of the DVD onto a USB drive so I could update the map data while travelling, but the only thing that didn't copy was the Apple Mac OSX version of the LUMIX MapTool.pkg!

It took a bit of Googling, but I eventually found a great blog post by Roland Kluge where he'd written a simple script version of the tool for his LUMIX DMC-TZ31 - and after reading the comments I was able to modify his script to make it work for my new LUMIX DMC-TZ40.

I cannot stress how thankful I am for his work and the comments on his post - thank you Roland, and thank you commenter Falk

Introducing LUMIX Map Tool - Open Source edition :o)

My contribution to Roland Kluge's Simple Replacement for Lumix Map Tool

My contribution to Roland Kluge's Simple Replacement for Lumix Map Tool

I've forked Roland's code and added support for the Lumix DMC-TZ40, and I thought I'd make it a little more interactive as I'm not likely to change the Map Data too often.

Simply download the file from the GitHub repository and run it with..

$ python

.. and it will prompt you for the information it needs to get the Map Data from your DVD onto a formatted SD Card - maps away!

Setting up Django on Windows Azure Websites


A couple of things caught me out when I was trying to deploy a Django application to Windows Azure, so I'm making a note of them here for future reference. Oh, and I hope they might be of use to you as well :smiley:

Configuring Azure Websites to run Python and Django

The Windows Azure Websites and Django getting started tutorial directs us to put the required settings into the Azure management portal, but that's not how Microsoft do it in the Gallery, oh no. If we create a Django application from the Windows Azure Website gallery, the resulting site puts all the configuration into a web.config.

Personally, I like the management portal approach, but I did play with the web.config and got it working for my custom site, so I present it here in case this works for you:

    <add key="PYTHONPATH" value="D:\home\site\wwwroot;D:\home\site\wwwroot\site-packages" />
    <add key="WSGI_HANDLER" value="django.core.handlers.wsgi.WSGIHandler()" />
    <add key="DJANGO_SETTINGS_MODULE" value="{django-app-name}.settings" />   
      <add name="Python_FastCGI"
           requireAccess="Script" />
        <rule name="Django Application" stopProcessing="true">
          <match url="(.*)" ignoreCase="false" />
            <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
          <action type="Rewrite" url="handler.fcgi/{R:1}" appendQueryString="false" />

There are three things you need to do to get this working:

  1. Check the PYTHONPATH - It needs pointing to the root of your Django Application (the directory that holds, and the location of the directory site-packages (obviously, copy that directory into Azure from your development environment if it's not there)
  2. Set DJANGO_SETTINGS_MODULE - Point this to your file. Replace directory slashes / with dots, and omit the .py from the end
  3. Create the file handler.fcgi - This is required, and it's a just a plain text file containing two double-quotes (i.e. "") on the first line followed by a return carriage

Seriously though, just put the settings into the Azure management portal. It's easier :smiley:

Bad Request (400)

Following the advice in the first thing I did after copying the files into Azure and seeing the Django "cool, now get cracking" default page was to trip DEBUG to False (DEBUG = False), then refresh the web browser..

"Django on Windows Azure Websites - Bad Request (400)"

Django on Windows Azure Websites - Bad Request (400)


Essentially, if DEBUG is False, we have to put our server's URL into the ALLOWED_HOSTS setting. Now, we could do that in but this is really Azure specific just like the configuration above, so it really belongs in Azure.

In the Azure management portal, under the "Website > Configure" tab, find the "app settings"; section near the bottom and add the following:

Setting Value
ALLOWED_HOSTS Your Azure URL: e.g.

If you have custom domains pointed at Azure, you can use a space-separated list like so:
SECRET_KEY While we're adding new application settings, create a new 50 character random string and copy it here. We'll use it in a second

Now, we could just modify our, but it's much better to have a configuration file specifically for production / Azure, so first of all, copy your existing to a new file in the same directory - I like to use the filename

Now, assuming you have a shiny new file, delete everything in it and replace it with this:

from settings import *

Now just overwrite the important settings

SECURITY WARNING: keep the secret key used in production secret!

SECRET_KEY = os.getenv('SECRET_KEY')  #  - defined in Windows Azure app settings

SECURITY WARNING: don't run with debug turned on in production!

DEBUG = False


ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS').split()  #  - defined in Windows Azure app settings

Handy file logging, in case we run into problems on production

### Uncomment to log Django errors to the root of your Azure Website
  'version': 1,
  'disable_existing_loggers': False,
  'filters': {
    'require_debug_false': {
      '()': 'django.utils.log.RequireDebugFalse'
  'handlers': {
    'logfile': {
      'class': 'logging.handlers.WatchedFileHandler',
      'filename': 'D:/home/site/wwwroot/error.log'
  'loggers': {
    'django': {
      'handlers': ['logfile'],
      'level': 'ERROR',
      'propagate': False,

If you created this new file, don't forget to adjust the DJANGO_SETTINGS_MODULE value to point to it in the Azure management portal, or your web.config if you went that route, you heathen!