Monday, February 01, 2010

seq_to_qt: Python Conversion of Still Image Sequences to QuickTime Movies on Windows

Update: I've created a project for seq_to_qt at code.google.com.  This makes it easier for me to manage updates to the code.

In several recent posts (1, 2, 3) I've discussed my progress toward automating the workflow of creating time-lapse movies from still image sequences on Windows.  I used JScript+WSH for the original scripts because that's how the samples I started with were written.  But I've now rewritten the system in Python and extended it to provide "seamless" automation from end to end of the QuickTime part of my process.

What It Does

seq_to_qt provides a set of functions that I am finding useful for automating my workflow in producing timelapse movies.  My camera software saves images in separate folders for each day and numbers the images sequentially.  That's where seq_to_qt enters the picture.  I can give it a simple list of folders that need to processed and it will take care of everything else involved in generating a QuickTime movie for each sequence.

Key Benefits

  • Saves me time, errors, and drudgery.  Fire it off once to handle multiple image sequences and leave for the day (or weekend).
  • Splits long image sequences into chunks, if necessary, so that QuickTime doesn't choke.
  • The sequence splitter itself is very general and could be used to divide any sequence of numbered files.
  • Flexible Python implementation is relatively easy to extend and modify.

Getting Started

Download and install Apple QuickTime.  You will need to pay for the "Pro" license.
Download and install compatible versions of Python and PyWin32.  I have tested seq_to_qt with Python 2.6.
Download my Python files seq_to_qt (links to download page at the project site) and put them in a convenient place.

Running The Scripts

Python is flexible and there are a lot of things you can do.  But here is a simple way to get started.

My working folders are organized like this:
Project
    scripts  (this is where I put seq_to_qt and any others)
    seq1 (each set of image sequence files is in its own folder)
       XXX_0001.jpg  (numbered sequentially with a common prefix)
       XXX_0002.jpg
       ...
    seq2
    seq3
    ...
You don't have to set yours up the same way, but I thought it might help to see how I'm using it.
  1. Create a "test" sequence with no more than about 200 frames. To do this, create a new folder in your Project folder and there copy the first 1-200 frames of your favorite sequence.
  2. Make a copy of seq_batch_convert.py and edit it to provide the file paths for your image sequences.  If you want to see how the chunk splitting works, set the chunk_size to something small, like 50.
  3. Now run your script!  From a command window, your command will look something like this:
    c:\Python26\python scripts\seq_batch_convert.py
  4. And if all goes well a QuickTime movie will be produced.
The output of the scripts is quite verbose, so you should get a good view into what is being done.

Future Work

I want to extend the automation tools to include image processing prior to movie production.  Also, I may want to automate more of the production work that takes place in my video editor after the QuickTime portion is done.

References

Source Code

You can download a zip file of the seq_to_qt package.  Or just copy and paste it from here.
  • seq_to_qt.py is the module that contains all the functionality, and is used by the other files.  The other files are intended to be used as commands.
  • seq_batch_convert.py -- A simple command file used to enter your data and initiate the complete conversion.  Copy this file and edit it with your own image sequence information, then run it.  
  • qt_movie_from_stills.py -- If you wish, you can use this for the conversion of a single sequence, without splitting.  I would normally use seq_batch_convert.py instead.
  • make_seq_chunks.py -- Does only the chunk splitting on a sequence, no conversion, and therefore no dependence on QuickTime. 
Files have been syntax-highlighted using Pygments.

seq_to_qt.py


#! /usr/bin/env python
"""
Convert one or more still image sequences into QuickTime movies.

Author: Chip Chapin 
For More Info: http://cchapin.blogspot.com

You must have the PyWin32 COM interface installed,
see http://sourceforge.net/projects/pywin32/

Acknowledgements:
  The QuickTime interface was originally written as a JScript+WSH
  script, which I based on a 2006 blog post by Luc-Eric Rousseau
  (XSIBlog http://www.xsi-blog.com/archives/103).  Rousseau's script
  was in turn based on sample code by John Cromie, author of the
  book "QuickTime for .NET and COM Developers" (Elsevier 2006,
  http://www.skylark.ie/qt4.net/samplecode.asp).

Latest Update: 2010-01-31

TODO: Some sort of progress indicator during the initial rendering stage.
TODO: Better solution to the QT_PLAYER_DELAY hack.
"""
import datetime
import math
import os
import re
import shutil
import sys
import tempfile
import time
import win32com.client

CODEC_INFO_FILENAME = "C:\\qtMovieFromStillsCodecInfo.xml"
DEFAULT_CHUNK_FILES=7000
DEFAULT_FRAME_RATE = "60"

# HACK: Delay (sec) while the QuickTime player initializes.
QT_PLAYER_DELAY = 7.0

def abort(message=None):
    """Print error message and exit."""
    if message:
        print "ERROR: ", message
    sys.exit(1)

def usage(message=None):
    """Print usage message and exit."""
    if message:
        print message
    sys.exit(2)

def check_file_folder(fpath):
    """Returns true if the directory of fpath exists and is writable."""
    fdir = os.path.dirname(fpath)
    try:
        f = tempfile.TemporaryFile("w", dir=fdir)
    except OSError:
        return False
    f.close()
    return True

def file_exists(fpath):
    """Returns true if file fpath exists and is readable."""
    try:
        f = open(fpath, "r")
    except IOError:
        return False
    f.close()
    return True

def uniqueify(fpath):
    """Check to see if the file exists.  If so, uniqueify the filename."""
    upath = fpath
    count = 0
    while file_exists(upath):
        count = count + 1
        (root, ext) = os.path.splitext(fpath)
        upath = "%s-%02d%s" % (root, count, ext)
    if count:
        print "WARN: You already have a file '%s'" % fpath
        print "   Saving as '%s' instead." % upath
    return upath

def create_new_movie_from_images(sourcePath, frameRate, qtControl):
    """Create the movie from the still image sequence."""
    print "Creating new movie from still sequence '" + sourcePath + "'..."
    try:
        qtControl.CreateNewMovieFromImages(sourcePath,
                                           frameRate,
                                           True) # rate is in frames per second
    except:
        print "ERROR creating movie "
        #+ e.number +
        #           " (" + (e.number>>16 & 0x1FFF) +
        #           "-" + (e.number & 0xffff) + ")");
        #WScript.Echo(e.description);
        # TODO: Find more reliable error reporting.
        # The following doesn't work if the QTControl object is gone.
        print "QuickTime error " + qtControl.ErrorCode
        qte = qtControl.QuickTime.Error
        print "  " + qte.ErrorCode + ", " + qte.Description
        print "  " + qte.SourceReference
        raise

    qtMovie = qtControl.Movie;
    if not qtMovie:
        abort("No movie created (" + qtControl.ErrorCode + ")")
    duration = qtMovie.Duration;
    if (duration == 0):
        # This test isn't as helpful as I thought it would be.  I thought it
        # would catch the case where QT does not have a valid input file, but in
        # that case it seems to create a two-second empty movie 
        # (ie. duration = 20*framerate).
        abort("Movie has duration 0.")

    # Duration is the number of frames * 10.
    print "Created new movie, duration %d" % duration

def get_quicktime_exporter(qtControl):
    """Set up the QuickTime movie exporter."""
    qt = qtControl.QuickTime
    if (qt.Exporters.Count == 0):
        # Only add an exporter if needed.
        qt.Exporters.Add()
        print "Adding new Exporter."
    else:
        print "Using existing Exporter."
    qtExporter = qt.Exporters(1)
    if not qtExporter:
        abort("Unable to get Exporter.")
    qtExporter.TypeName = "QuickTime Movie"
    qtExporter.SetDataSource(qtControl.Movie)

    if file_exists(CODEC_INFO_FILENAME):
        print "Reading codec config from '" + CODEC_INFO_FILENAME + "'"
        CodecFileInfo = open(CODEC_INFO_FILENAME, "r")
        xmlCodecInfoText = CodecFileInfo.read()
        # Cause the exporter to be reconfigured.
        # http://developer.apple.com/technotes/tn2006/tn2120.html
        tempSettings = qtExporter.Settings
        tempSettings.XML = xmlCodecInfoText
        qtExporter.Settings = tempSettings
    else:
        # Use the Settings dialog box, then save the results.
        qtExporter.ShowSettingsDialog()
        xmlCodecInfoText = qtExporter.Settings.XML
        try:
            CodecFileInfo = open(CODEC_INFO_FILENAME, "w")
            CodecFileInfo.write(xmlCodecInfoText)
            CodecFileInfo.close()
        except IOError:
            print ("Warning: failed to save codec info to '" +
                   CODEC_INFO_FILENAME + "'")
            print "continuing ..."
    return qtExporter

def export_movie(qtExporter, destPath):
    """Export the movie."""
    print "Exporting ..."
    try:
        qtExporter.DestinationFileName = destPath
        qtExporter.ShowProgressDialog = True
        # Uncomment this line if you want the export dialog box to appear.
        # qtExporter.ShowExportDialog();
        qtExporter.BeginExport()  # This can take a l-o-n-g time.
        print "Exported to '%s'" % destPath
    except:
        print "ERROR exporting '%s'" % destPath
        #print "ERROR " + (e.number>>16 & 0x1FFF) +
        #      "-" + (e.number & 0xffff) + 
        #      " exporting '" + destPath + "'"
        #print e.description
        #WScript.Echo(JSON.stringify(e, null, 2));
        #qte = qt.Error;
        #print "QuickTime Error %d, %s" % (qte.ErrorCode, qte.Description)
        #WScript.Echo(JSON.stringify(qte, null, 2));
        raise

def qt_movie_from_stills(sourcePath, destPath, frameRate):
    """Create and export a QuickTime movie from a sequence of images."""
    # Launch QuickTime Player Application
    qtPlayerApp = win32com.client.Dispatch(
                    "QuickTimePlayerLib.QuickTimePlayerApp")
    time.sleep(QT_PLAYER_DELAY);  # Give it time to launch.
    if not qtPlayerApp:
        abort("Failed to launch QuickTime Player App.")

    # Get the QuickTime player and its associated controller.
    # NOTE: The script will abort here if the player hasn't had time
    # to initialize.  It should work if you run it again, or you can increase
    # QT_PLAYER_DELAY.
    qtPlayer = qtPlayerApp.Players(1);
    if not qtPlayer:
        abort("Failed to get QuickTime Player.");
    print "Got player '" + qtPlayer.Caption + "'"
    qtControl = qtPlayer.QTControl
    
    create_new_movie_from_images(sourcePath, frameRate, qtControl)
    qtExporter = get_quicktime_exporter(qtControl)
    export_movie(qtExporter, destPath)
    # Closing the player causes failures for subsequent invocations.
    # qtPlayer.Close();

def seq_file_list(a_seq_file):
    """Return the list of file names in the sequence."""
    # Extract the leading invariant part of the sequence filename.
    # For example, if the name is Fooo-0001.jpg, the invariant is "Fooo-".
    # Use non-greedy match so the numbers are kept out.
    (work_folder, sequence_proto) = os.path.split(a_seq_file)
    sm = re.match("(.*?)[0-9]+\.", sequence_proto)
    if not sm:
        usage(("'%s' doesn't look like the start of a sequence.\n"
              % sequence_proto) +
"Sequences consist of sequentially numbered file names like\n" +
"   Grog-001.jpg, Grog-002.jpg, Grog-...\n" +
"\n" +
"  sequenceExample -- Path to any file in the sequence.\n" +
"  maxSize -- Maximum number of sequence files to put in a folder.");
        
    # Construct a regexp for matching sequence file names.
    sq_name = sm.group(1)
    sq_re = re.compile("^" + sq_name + "[0-9]+\.");

    # Make a list of files that match the sq_re pattern.  "fnmatch" patterns
    # are not powerful enough to be reliable so we don't use glob.
    sq_files = []
    dir = os.listdir(work_folder)
    dir.sort()
    for f in dir:
      if sq_re.match(f):
        sq_files.append(f)
    return (sq_files, sq_name)

def split_file_list(a_seq_file, sq_files, sq_name, max_size):
    """Split the sequence files into successive split folders.
    Returns the list of split folder names.
    """
    work_folder = os.path.dirname(a_seq_file)

    sq_count = len(sq_files)
    num_splits = math.ceil(sq_count / max_size)
    print ("Splitting file sequence '%s' into %d parts."
           % (sq_name, num_splits))
    parent_folder = os.path.dirname(work_folder)
    file_count = 0
    next_file_in_split = 1
    current_split = 0
    splits = []
    for fname in sq_files:
        if (file_count == 0 or next_file_in_split > max_size):
            current_split = current_split + 1
            next_file_in_split = 1
            split_folder = os.path.join(parent_folder,
                                        ("%s-%d" % (work_folder, current_split)))
            print "New split folder '%s' (%d)" % (split_folder, file_count)
            try:
                os.mkdir(split_folder, 0755)
            except EnvironmentError as (errno, strerror):
                print "ERROR: Failed to create split directory '%s'" % split_folder
                print "[Error %d] %s" % (errno, strerror)
                abort()
            splits.append((split_folder, fname))
            
        # Move the file from work_folder to split_folder
        shutil.move(os.path.join(work_folder, fname),
                    os.path.join(split_folder, fname))
        file_count = file_count + 1
        next_file_in_split = next_file_in_split + 1

    print ("Split %d files in '%s' into %d folders."
           %(file_count, work_folder, current_split))
    return splits

def make_seq_chunks(a_seq_file, max_size):
    """Split a file sequence into folders of no more than max_size files each.
    Returns a list of (folder, starting_file) duples.

    Sequences have file names that end with a sequence number,
    like (Foo-001.jpg, Foo-002.jpg, ...).
    """
    (sq_files, sq_name) = seq_file_list(a_seq_file)
    sq_count = len(sq_files)
    print "Sequence '%s' contains %d members." % (a_seq_file, sq_count)    
    if sq_count <= max_size:
        print "No need to split."
        if (sq_count == 0):
            return []
        else:
            return [os.path.split(a_seq_file)]
    else:
        return split_file_list(a_seq_file, sq_files, sq_name, max_size)

def seq_name_from_filename(fname):
    """Extract the 'sequence name' from a representative filename."""
    sm = re.match("(.*?)[-_]?[0-9]+\.", os.path.basename(fname))
    if not sm:
        print ("'%s' doesn't look like the start of a sequence."
                  % fname)
        return None
    return sm.group(1)

def convert_sequences(base_dir, seqs,
                      frame_rate=DEFAULT_FRAME_RATE,
                      chunk_size=DEFAULT_CHUNK_FILES):
    """Generates QuickTime movies for a set of image sequences.

    base_dir: Full path to the common parent directory.
    seqs: List of duples describing image sequences.
        Each duple contains the FOLDER NAME of the sequence
        and the STARTING IMAGE file name: (folder, file)
        Names are unqualified, i.e. relative to their parent directories.
    frameRate: Numeric frame rate in frames per second.
    chunk_size: Maximum number of frames to be processed in
        a single movie.
    """
    for (sq_dir, sq_file) in seqs:
        src = "%s\\%s\\%s" % (base_dir, sq_dir, sq_file)
        if not file_exists(src):
            print "WARN: '%s' is not readable.  Skipping." % src
            continue
        # Possibly split the image sequence into chunks.
        splits = make_seq_chunks(src, chunk_size)
        # Iterate over the chunks.
        for (split_folder, split_file) in splits:
            sq_name = seq_name_from_filename(sq_file)
            if (sq_name):
                dst = ("%s\\%s_%s.mov" %
                       (base_dir, sq_name, os.path.basename(split_folder)))
                dst = uniqueify(dst)
                print time.strftime("%Y-%m-%d %X  Converting:") 
                print ("    Folder %s\n    Start %s\n    Dest %s"
                       % (split_folder, split_file, dst))
                qt_movie_from_stills(src, dst, frame_rate)
                print time.strftime("Complete at %Y-%m-%d %X")

def main(argv):
    """Main Program"""
    usage("This is the seq_to_qt module.\n" +
          "For batch conversion, edit 'seq_batch_convert.py'.  It calls the functions here.")

if __name__ == "__main__":
    main(sys.argv)


seq_batch_convert.py


#! /usr/bin/env python
"""
Convert a batch of image file sequences to QuickTime movies.

Edit this file and list your sequences below.

Author: Chip Chapin 
For More Info: http://cchapin.blogspot.com
"""
import seq_to_qt

DEFAULT_BASE_PATH = "L:\\Graphics\\Images\\SX110\\MTV-TL01"
DEFAULT_SEQUENCE_FILE = "MTV-TL01_0001.JPG"
DEFAULT_CHUNK_SIZE = 7000

def usage():
    print "Usage: seq_batch_convert.py "
    exit(2)
    
def main(argv):
    """Main Program"""
    # Step 1: Base directory.  This is the parent of your
    # sequence directories.
    base_path = DEFAULT_BASE_PATH
    
    # Step 2: List your image sequences.
    # Each sequence is a duple containing the
    # FOLDER NAME and the STARTING IMAGE of the sequence
    # Example:
    #sequences =  [("Test01-1", "MTV-TL01_0001.JPG"),
    #              ("Test01-2", "MTV-TL01_0076.JPG"),
    #              ("Test01-3", "MTV-TL01_0151.JPG")]
    sequences =  [("2009-12-15b", "MTV-TL01_7001.JPG"),
                  ("2009-12-19b", "MTV-TL01_7001.JPG"),
                  ("2009-12-22b", "MTV-TL01_7001.JPG"),
                  ("2009-12-23b", "MTV-TL01_7001.JPG"),
                  ("2009-12-24b", "MTV-TL01_7001.JPG")]

    # Step 3: How many frames per second should your movie run at?
    frame_rate = 60

    # Step 4: Movie chunk size-- how large a movie (how many frames)
    # can QuickTime process on your computer?  Try the default.
    # If QuickTime aborts during the export phase, make it smaller.
    #chunk_size = 75  # Low value for testing. Normally use DEFAULT_CHUNK_SIZE
    chunk_size = DEFAULT_CHUNK_SIZE
    
    # Now do the conversion.
    seq_to_qt.convert_sequences(base_path,
                                sequences,
                                frame_rate,
                                chunk_size)
    
if __name__ == "__main__":
    import sys
    main(sys.argv)


qt_movie_from_stills.py


#! /usr/bin/env python
"""
Create a QuickTime movie from a sequence of still images on Windows.

Usage: python qt_movie_from_stills.py sourcepath destpath [framerate]

Use FULLY-QUALIFIED Windows file paths. Relative paths don't work well
inside QuickTime -- it is likely to fail with no error report from QT.

You must have the PyWin32 COM interface installed,
see http://sourceforge.net/projects/pywin32/

Author: Chip Chapin 
For More Info: http://cchapin.blogspot.com
Acknowledgements:
  I originally wrote this as a JScript+WSH script based on a post by
  Luc-Eric Rousseau (XSIBlog 2006, http://www.xsi-blog.com/archives/103).
  His was in turn based on sample code by John Cromie that accompanied
  his book "QuickTime for .NET and COM Developers"
  (Elsevier 2006, http://www.skylark.ie/qt4.net/samplecode.asp).

Last Update: 2010-01-31

TODO: Some sort of progress indicator during the initial rendering stage.
"""
import seq_to_qt
import os

def usage(message=None):
    """Print usage message and exit."""
    if message:
        print message
    print "Usage: qt_movie_from_stills.py sourcepath destpath [framerate]"
    print "Hint: Avoid relative paths, use fully-qualified Windows paths"
    print "(e.g. C:\\a\\b\\c.jpg)."
    sys.exit(2)

def main(argv):
    """Main Program"""
    if (len(argv) < 3):
        usage()
    sourcePath = argv[1]
    destPath = argv[2]
    if (len(argv) >= 4):
        frameRate = argv[3]
    else:
        frameRate = seq_to_qt.DEFAULT_FRAME_RATE
    print "FrameRate: " + frameRate

    if not seq_to_qt.file_exists(sourcePath):
        usage("Missing source file '" + sourcePath +"'")
    if seq_to_qt.file_exists(destPath):
        # .mov files take a long time to create.  Avoid
        # overwriting one by mistake.
        usage("Will not overwrite destination file '" + destPath + "'")
    if not seq_to_qt.check_file_folder(destPath):
        usage("Unusable destination file path '" + destPath +"'")
    seq_to_qt.qt_movie_from_stills(sourcePath, destPath, frameRate)

if __name__ == "__main__":
    import sys
    main(sys.argv)


make_seq_chunks.py


#! /usr/bin/env python
"""
Author: Chip Chapin 
Last Updated: 2010-01-31

Split a sequence of files into smaller chunks.
I use this for dividing still image sequences into pieces
that QuickTime can handle.

See http://cchapin.blogspot.com/
"""
import seq_to_qt
import os

def usage(message=None):
    """Print usage message and exit."""
    if message:
        print message
    print "Usage: make_seq_chunks.py sequenceFile maxSize"
    print (
"\nSequences have sequentially numbered file names like\n" +
"    Foo-001.jpg, Foo-002.jpg...\n" +
"sequenceFile -- Path to any file in the sequence.\n" +
"maxSize -- Maximum number of sequence files to put in a folder.");
    sys.exit(2)

def main(argv):
    """Main Program"""
    if (len(argv) != 3):
        usage("Expected two arguments")  
    a_seq_file = os.path.abspath(argv[1])
    max_size = float(argv[2])
    
    if not os.path.isfile(a_seq_file):
        usage("'%s' is not a file." % a_seq_file)
    seq_to_qt.make_seq_chunks(a_seq_file, max_size)

if __name__ == "__main__":
    import sys
    main(sys.argv)

3 comments:

Chip Chapin (G) said...

Before you use this code, be sure to visit the Google Code Project for seq-to-qt to get the most up to date version.

CFM said...

Works like a charm, just what i needed. The xml export is pretty nifty. Do you have a simple solution to stop quicktime from popping on screen? Thank you very much for seq_to_qt!!!

Chip Chapin (G) said...

@CFM, I'm glad to hear it was useful to you. Sorry, I have no solution to Quicktime appearing on the screen.