Image Animation in Python with OpenCV

This Python script demonstrates an approach to creating video content for the Pausch Bridge using a single image as a source. The image is sequenced out row by row from top to bottom at a rate determined by the ‘tempo’ setting. Each row is treated as a keyframe and the video smoothly cross-fades between each row. The default setting of 60 BPM emits one row per second.

The source image must be 57 pixels wide, corresponding to the pier sections of the bridge. This is expanded to the standard 228 x 8 video mapping size which covers all the lighting elements. The default (preferred) output format is “PNG within AVI” which stores each frame losslessly without any compression artifacts. Other formats may introduce some slight color bleed due to compression artifacts at the crisp pixel block boundaries.

The script uses the Python bindings for the OpenCV library. These include the ffmpeg video codecs used to generate the output.

Installation Requirements

The code requires a working installation of Python 3 with OpenCV and its dependency NumPy. For suggestions on setting up your system please see Python 3 Installation.

Usage

This script is intended to be run from a command line. It requires an input image exactly 57 pixels wide. This is typically a lossless format like PNG or TIFF.

Run without options, it will read input from timeline.png and write output to animation.avi with a key frame tempo of 60:

python3 pb_animate_image

To see all available command-line options:

python3 pb_animate_image.py --help

A more full use of options might look as follows:

python3 pb_animate_image.py --input myshow.png --tempo 90 --verbose myshow.avi

Full Code

  1#!/usr/bin/env python3
  2# pb_animate_image.py
  3
  4# This script converts an image into a Pausch Bridge video file by sequencing
  5# out each row from top to bottom.  The source image must be 57 pixels wide.
  6# Each row is treated as a key frame spaced in time at the 'tempo' rate, with
  7# the video smoothly cross-fading between each row.
  8
  9# This script assumes the availability of the OpenCV and numpy libraries. A
 10# recommended method for installing these in Python 3 follows:
 11#
 12#   pip3 install opencv-contrib-python
 13#
 14# General OpenCV information:   https://opencv.org/
 15# General NumPy information:    https://numpy.org/
 16
 17#================================================================
 18# Import standard Python modules.
 19import argparse
 20
 21# Import the numpy and OpenCV modules.
 22import numpy as np
 23import cv2 as cv
 24
 25#================================================================
 26# Define the video properties using the canonical video format for the Pausch
 27# Bridge lighting system.
 28frame_rate   = 30
 29frame_width  = 228
 30frame_height = 8
 31
 32# Specify a format code and file format.  The exact combinations of codec and
 33# file formats available are different for each platform.
 34codec_code = cv.VideoWriter.fourcc(*'png ') # PNG images, lossless, clean block edges
 35file_extension = 'avi'
 36
 37#================================================================
 38# Generate successive frames of the video sequence.
 39
 40def frame_generator(verbose, source, tempo):
 41    count = 0             # count of generated frames
 42    frame_time = 0.0      # time stamp for generated frame in seconds
 43    keyframe_phase = 0.0  # unit phase for the cross-fade, cycles over 0 to 1
 44
 45    frame_interval = 1.0 / frame_rate                       # seconds between video frames
 46    keyframe_interval = 60.0 / tempo                        # seconds between key frames
 47    keyframe_rate = 1.0 / (frame_rate * keyframe_interval)  # phase / frame
 48
 49    source_rows = source.shape[0]
 50    source_cols = source.shape[1]
 51
 52    # Use the first two rows as the first keyframes.
 53    row0 = source[0:1, :, :]
 54    row1 = source[1:2, :, :]
 55    frame0 = cv.resize(row0, None, fx=4, fy=8, interpolation=cv.INTER_NEAREST)
 56    frame1 = cv.resize(row1, None, fx=4, fy=8, interpolation=cv.INTER_NEAREST)
 57    next_scanline = 2
 58
 59    while True:
 60        # Cross-fade between successive key frames at the given tempo.  This will
 61        # return a new frame of integer pixels.
 62        frame = cv.addWeighted(frame0, (1.0 - keyframe_phase), frame1, keyframe_phase, 0.0)
 63
 64        # Return the frame and advance the generator state.
 65        yield frame
 66        count += 1
 67        frame_time += frame_interval
 68        
 69        # Advance the cross-fade phase.
 70        keyframe_phase += keyframe_rate
 71
 72        # Once the second keyframe is reached, generate the successor and reset the fade.
 73        if keyframe_phase > 1.0:
 74            keyframe_phase -= 1.0
 75
 76            # Once all rows have been consumed, start returning a null result.
 77            if next_scanline >= source_rows:
 78                if verbose:
 79                    print(f"Generated {count} frames.")
 80                while True:
 81                    yield None
 82            else:
 83                frame0 = frame1
 84                row1 = source[next_scanline:next_scanline+1, :, :] 
 85                frame1 = cv.resize(row1, None, fx=4, fy=8, interpolation=cv.INTER_NEAREST)               
 86                next_scanline += 1
 87        
 88
 89#================================================================
 90# Write a video file in the default format.
 91
 92def write_video_file(filename, verbose, *args):
 93
 94    # Open the writer with a path, format, frame rate, and size.
 95    out = cv.VideoWriter(filename, codec_code, frame_rate, (frame_width, frame_height))
 96
 97    if verbose:
 98        print(f"Open file {filename} for output.")
 99
100    # Set up the frame generator.
101    frame_sequence = frame_generator(verbose, *args)
102
103    # Synthesize some frames and write them to the stream.
104    while True:
105        next_frame = next(frame_sequence)
106        if next_frame is not None:
107            out.write(next_frame)
108        else:
109            break
110
111    # Release everything when done.
112    out.release()
113
114#================================================================
115# Main script follows.
116if __name__ == "__main__":
117    parser = argparse.ArgumentParser(description = """Convert an image row by row to video frames, smoothly interpolating between rows. \
118The source image must be exactly 57 pixels wide.""")
119    parser.add_argument( '-v', '--verbose', action='store_true', help='Enable more detailed output.' )
120    parser.add_argument( '--tempo', type=float, default=60.0, help='Tempo of key frames (image rows) in beats per minute (default: %(default)s)')
121    parser.add_argument( '--input', type=str, default='timeline.png', help='Path of input image (default: %(default)s.')
122    parser.add_argument( 'videopath', default='animation.avi', nargs='?', help='Path of output video file (default: %(default)s).')
123
124    args = parser.parse_args()
125    source = cv.imread(args.input)
126    if args.verbose:
127        print(f"Source image size: {source.shape}")
128        
129    write_video_file(args.videopath, args.verbose, source, args.tempo)