Copy Pictures from a Digital Camera and Automatically Rename to Date and Time Taken#

Most digital cameras use some sort of naming scheme that leaves a lot to be desired. The names usually consist of something like:

  • picture001.jpg

  • picture002.jpg

  • picture 134.jpg

As you can see that naming scheme tells you nothing about the picture. Personally I like to rename the picture based on the date and time it was taken. For example: 2010-04-04T07h35m39.jpg. With a name like that you can clearly see that the picture was taken on April 4, 2010 at 7:35 am. The neat thing about this is that all modern digital cameras write this information to what is called an EXIF[1] tag contained within the picture itself.

I wrote a python script that copies all of the pictures from a digital camera (well from the directory that is mounted in the file system) to a temporary location and renames them based on the date and time the pictures were taken. In addition it can also add some additional information to the IPTC[2] tags of the photograph.

Features:

  • Reads a configuration file that contains:

    • Photographer name

    • Copyright notice

    • Output path - the directory to copy the pictures to. Typically it is a temporary location. I would then copy the pictures manually to the final spot to ensure that nothing is accidentally over written

  • Can deal with multiple configuration files and allows the user to choose which one to apply

  • Searches the camera for all picture files (jpg, jpeg, png)

  • Pictures are copied to the output path and renamed based on the EXIF date and time and the IPTC tags are updated as well

  • Pictures are also sorted into directories based on year and month the picture was taken

  • If for some reason two pictures have the exact EXIF date and time a number is appended to the file name

  • After the pictures are copied and renamed, the pictures can be deleted from the camera

  • Any non-picture files are displayed at the end. Useful if you have movies stored on the camera

Here is an example of the configuration file - photographer.cfg:

:number-lines:

[camera.profile]
photographer=Troy Williams
copyright=Copyright 2010 Troy Williams
outputpath=/home/troy/repositories/code/Python/camera copy/output/Troy Williams

Here is the script - camera_copy.py:


    #!/usr/bin/env python
    #-*- coding:utf-8 -*-

    """
    This script copies pictures from one folder to another. It attempts to rename
    the pictures based on the exif date taken tag. The script also reads from a
    configuration file that contains, amoung other things, the name of the
    photographer (which is assigned to the photographer IPTC tag) as well as the
    folder to copy the images to.

    Documentation:
        -Contains urls to sites containing relevant documentation for the code in
        in question. Normally this should be inlined closed to the code where it
        is used.

    References:
        -Contains links to reference materials used. If specific functions are used
        directly, then credit is placed there

    Dependencies:
        pyexiv2 - http://tilloy.net/dev/pyexiv2/index.htm
                  http://tilloy.net/dev/pyexiv2/tutorial.htm

    License:
    The MIT License
    Copyright (c) 2010 Troy Williams

    Permission is hereby granted, free of charge, to any person obtaining a copy
    of this software and associated documentation files (the "Software"), to deal
    in the Software without restriction, including without limitation the rights
    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    copies of the Software, and to permit persons to whom the Software is
    furnished to do so, subject to the following conditions:

    The above copyright notice and this permission notice shall be included in
    all copies or substantial portions of the Software.

    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    THE SOFTWARE.
    """

    import sys
    import os
    import shutil
    from datetime import datetime
    import pyexiv2

    #Constants
    __uuid__ = 'f706d95a-6c94-4a1e-ab4c-a8ee26b0c563'
    __version__ = '0.2'
    __author__ = 'Troy Williams'
    __email__ = 'troy.williams@bluebill.net'
    __copyright__ = 'Copyright (c) 2010, Troy Williams'
    __date__ = '2010-04-10'
    __license__ = 'MIT'
    __maintainer__ = 'Troy Williams'
    __status__ = 'Development'

    def confirm(prompt=None, resp=False):
        """
        Source: http://code.activestate.com/recipes/541096/

        prompts for yes or no response from the user. Returns True for yes and
        False for no.

        'resp' should be set to the default value assumed by the caller when
        user simply types ENTER.

        >>> confirm(prompt='Create Directory?', resp=True)
        Create Directory? [y]|n:
        True
        >>> confirm(prompt='Create Directory?', resp=False)
        Create Directory? [n]|y:
        False
        >>> confirm(prompt='Create Directory?', resp=False)
        Create Directory? [n]|y: y
        True

        TBW: 2009-11-13 - change the prompt if test
        """

        if not prompt:
            prompt = 'Confirm'

        if resp:
            prompt = '%s [%s]|%s: ' % (prompt, 'y', 'n')
        else:
            prompt = '%s [%s]|%s: ' % (prompt, 'n', 'y')

        while True:
            ans = raw_input(prompt)
            if not ans:
                return resp
            if ans not in ['y', 'Y', 'n', 'N']:
                print 'please enter y or n.'
                continue
            if ans == 'y' or ans == 'Y':
                return True
            if ans == 'n' or ans == 'N':
                return False


    def process_command_line():
        """
        Sets up the command line options and arguments
        """
        from optparse import OptionParser

        usage = """
                usage: %prog [options] path1 path2 path3

                The program takes a path (or number of paths) to the directory where
                the pictures are stored. The paths can be relative to the current
                script location. It takes the pictures and copies them to a location
                based on the configuration settings and renames them based on the
                exif date stored within the image. In addition the images will be
                sorted into directories based on the exif date. They are sorted by
                year and month.

                In the same folder as the script, configuration files are detected
                and the user is prompted to select one. A configuration file can
                contain the following:

                [Camera.Profile]
                photographer=Troy Williams
                copyright=Copyright 2010 Troy Williams
                outputpath=/home/troy/Pictures/Troy Williams

                The configuration file must contain the [Camera.Profile] header

                photographer - The name of the person that took the pictures
                copyright - a string that will be added to the IPTC copyright tag
                of the photo
                outputpath - The path to copy the pictures too. They will be sorted
                by year/month based on the exif information stored in the picture.
                If no information is available it will be placed into a misc
                directory.
                """
        parser = OptionParser(usage=usage, version='%prog v' + __version__)

        options, args = parser.parse_args(args=None, values=None)

        if not args:
            parser.error('At least one image path must be specified!')
            parser.print_help()

        return options, args


    def find(path, pattern=None):
        """
        Takes a path and recursively finds the files.

        Optionally pattern can be specified where pattern = '*.txt' or something
        that fnmatch would find useful

        NOTE: this is a generator and should be used accordingly
        """

        if not os.path.exists(path):
            raise Exception, '%s does not exist!' % path

        if pattern:
            #search for the files that match the specific pattern
            import fnmatch
            for root, dirnames, filenames in os.walk(path):
                for filename in fnmatch.filter(filenames, pattern):
                    yield os.path.join(root, filename)
        else:
            #search for all files
            for root, dirnames, filenames in os.walk(path):
                for filename in filenames:
                    yield os.path.join(root, filename)


    def make_directory(dir_path):
        """
        Takes the passed directory path and attempts to create it including all
        directories or sub-directories that do not exist on the path.
        """

        try:
            os.makedirs(dir_path)
        except OSError:
            #Check to see if the directory already exists
            if os.path.exists(dir_path):
                #It exists so ignore the exception
                pass
            else:
                #There was some other error
                raise


    def path_from_date(path, date):
        """
        Takes a path and a date. It extracts the year and month from the date and
        returns a new path

        path = /home/troy/picture

        date = 2010-04-03 12:22:12 PM

        returns a path like /home/troy/picture/2010/03
        """

        return os.path.join(path, str(date.year), date.strftime("%m"))


    def loadConfigParameters(path):
        """
        Takes a path to a configuration file and reads in the values stored there.

        Returns: dictionary
        """

        if not os.path.exists(path):
            raise Exception, '%s does not exist!' % path

        import ConfigParser

        #Set the defaults
        configParams = {}
        configParams['photographer'] = None
        configParams['copyright'] = None
        configParams['output_path'] = None
        configParams['extensions'] = ['.jpg', '.jpeg', '.JPEG', '.JPG', '.png']

        config = ConfigParser.RawConfigParser()
        config.read(path)

        #loop through all the items in the section and assign the values to the the
        #configParams dictionary... We don't assign it as the default dictionary
        #because, the options we are interested in are defined above... This
        #appears to be case sensitive therefore we make the keys lower case
        for name, value in config.items('camera.profile'):
            configParams[name.lower()] = value

        return configParams


    def suggest_file_name(path):
        """
        Takes a file path and checks to see if the file exists at that location. If
        it doesn't then it simply returns the path unchanged. If the path exists, it
        will attempt generate a new file name and check to see if it exists.

        If a new name is found, it is returned.
        If the original name is not duplicated, it is returned
        If the looping limit is reached, None is returned
        """

        if os.path.lexists(path):
            filename, extension = os.path.splitext(path)
            for i in xrange(1, 1000):
                #Suggest a new file name of the form "file_name (1).jpg"
                newFile = '%s (%d)%s' % (filename, i, extension)
                if not os.path.lexists(newFile):
                    return newFile
            return None
        else:
            return path


    def update_image_iptc(path, **iptc):
        """
        This takes an image and updates the iptc information based on the passed
        parameters
        """

        if not os.path.exists(path):
            raise Exception, '%s does not exist!' % path

        image = pyexiv2.ImageMetadata(path)
        image.read()

        if 'exifDateTime' in iptc:
            image['Iptc.Application2.DateCreated'] = [iptc['exifDateTime']]

        if 'photographer' in iptc:
            image['Iptc.Application2.Byline'] = [iptc['photographer']]
            image['Iptc.Application2.Writer'] = [iptc['photographer']]

        if 'copyright' in iptc:
            image['Iptc.Application2.Copyright'] = [iptc['copyright']]

        image.write()


    def main():
        """
        The heart of the script. Takes all of the bits and organizes them into a
        proper program
        """
        #grab the command line arguments
        options, args = process_command_line()

        #grab the path to the script.
        scriptPath = sys.path[0]

        #Search the scriptPath for configuration files
        configurationFiles = []
        for filename in find(scriptPath, pattern='*.cfg'):
            configurationFiles.append(filename)

        #make sure that there is at least one configuration file
        if not configurationFiles:
            raise Exception, 'No configurations files found!'

        print 'Please choose the number of the configuration file to use:'

        for i, item in enumerate(configurationFiles):
            print '%i : %s' % (i, os.path.basename(item))

        #prompt the user to pick the index of the configuration file to execute
        index = int(raw_input("Choose the configuration: "))
        selectedConfiguration = configurationFiles[index]

        print "Configuration file: ", selectedConfiguration

        #load the configuration file parameters
        configParams = loadConfigParameters(selectedConfiguration)

        #make the root output directory
        make_directory(configParams['outputpath'])

        #Store a list of files that were successfully copied for later deletion
        matches = []

        #Store a list of files that were not in configParams['extensions'] but in
        #the search path
        mismatches = []

        #potential files to delete
        to_delete = []

        #copy all of the pictures from the specified paths
        for picture_path in args:
            normpath = os.path.join(scriptPath, picture_path)
            print "Searching ", normpath
            for filename in find(normpath):
                filebasename, fileextension = os.path.splitext(filename)
                if fileextension in configParams['extensions']:
                    #record the matched file for later statistics
                    matches.append(filename)
                else:
                    #record the mismatch and continue the loop
                    mismatches.append(filename)
                    continue

                print 'Attempting to copy: ' + os.path.basename(filename)

                image = pyexiv2.ImageMetadata(filename)
                image.read()

                if 'Exif.Image.DateTime' in image.exif_keys:
                    #rename the file based on the exif date and time and copy the
                    #picture to a folder based on year/month

                    exifDateTime = image['Exif.Image.DateTime'].value
                    newpath = path_from_date(configParams['outputpath'],
                                             exifDateTime)
                    make_directory(newpath)

                    newFile = exifDateTime.strftime("%Y-%m-%dT%Hh%Mm%S") + fileextension
                    newpath = os.path.join(newpath, newFile)
                else:
                    #no exif date time tag, simply copy to the unsorted directory
                    #exifDateTime = datetime.strftime("%Y-%m-%dT%Hh%Mm%S")
                    exifDateTime = datetime.today()
                    newpath = os.path.join(configParams['outputpath'], "unsorted")
                    make_directory(newpath)

                    newpath = os.path.join(newpath, os.path.basename(filename))

                #check to see if there are any duplicate file names
                newpath = suggest_file_name(newpath)
                if not newpath:
                    print 'Too many duplicates for: ' + filename
                    continue

                shutil.copy2(filename, newpath)

                update_image_iptc(newpath, exifDateTime=exifDateTime,
                                           photographer=configParams['photographer'],
                                           copyright=configParams['copyright'])

                #The file has been successfully copied, add it to the list of files
                #delete
                to_delete.append(filename)


    #check to see if there are any files to delete
        if len(to_delete) > 0:
            #prompt the user if they want to delete the files
            if confirm(prompt='Delete %s files?' % len(to_delete), resp=False):
                deletedCount = 0
                for item in to_delete:
                    os.remove(item)

        #print out the list of invalid files - if any
        if len(mismatches) > 0:
            print "Files not in valid extension list:"
            for item in mismatches:
                print item

        return 0 # success


    if __name__ == '__main__':
        status = main()
        sys.exit(status)

Here is an example of a shell script configured for a particular camera - camera.sh:

:number-lines:

#!/bin/bash
./camera_copy.py /media/FC30-3DA9