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
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
#!/usr/bin/env python3
# pb_animate_image.py

# This script converts an image into a Pausch Bridge video file by sequencing
# out each row from top to bottom.  The source image must be 57 pixels wide.
# Each row is treated as a key frame spaced in time at the 'tempo' rate, with
# the video smoothly cross-fading between each row.

# This script assumes the availability of the OpenCV and numpy libraries. A
# recommended method for installing these in Python 3 follows:
#
#   pip3 install opencv-contrib-python
#
# General OpenCV information:   https://opencv.org/
# General NumPy information:    https://numpy.org/

#================================================================
# Import standard Python modules.
import argparse

# Import the numpy and OpenCV modules.
import numpy as np
import cv2 as cv

#================================================================
# Define the video properties using the canonical video format for the Pausch
# Bridge lighting system.
frame_rate   = 30
frame_width  = 228
frame_height = 8

# Specify a format code and file format.  The exact combinations of codec and
# file formats available are different for each platform.
codec_code = cv.VideoWriter.fourcc(*'png ') # PNG images, lossless, clean block edges
file_extension = 'avi'

#================================================================
# Generate successive frames of the video sequence.

def frame_generator(verbose, source, tempo):
    count = 0             # count of generated frames
    frame_time = 0.0      # time stamp for generated frame in seconds
    keyframe_phase = 0.0  # unit phase for the cross-fade, cycles over 0 to 1

    frame_interval = 1.0 / frame_rate                       # seconds between video frames
    keyframe_interval = 60.0 / tempo                        # seconds between key frames
    keyframe_rate = 1.0 / (frame_rate * keyframe_interval)  # phase / frame

    source_rows = source.shape[0]
    source_cols = source.shape[1]

    # Use the first two rows as the first keyframes.
    row0 = source[0:1, :, :]
    row1 = source[1:2, :, :]
    frame0 = cv.resize(row0, None, fx=4, fy=8, interpolation=cv.INTER_NEAREST)
    frame1 = cv.resize(row1, None, fx=4, fy=8, interpolation=cv.INTER_NEAREST)
    next_scanline = 2

    while True:
        # Cross-fade between successive key frames at the given tempo.  This will
        # return a new frame of integer pixels.
        frame = cv.addWeighted(frame0, (1.0 - keyframe_phase), frame1, keyframe_phase, 0.0)

        # Return the frame and advance the generator state.
        yield frame
        count += 1
        frame_time += frame_interval
        
        # Advance the cross-fade phase.
        keyframe_phase += keyframe_rate

        # Once the second keyframe is reached, generate the successor and reset the fade.
        if keyframe_phase > 1.0:
            keyframe_phase -= 1.0

            # Once all rows have been consumed, start returning a null result.
            if next_scanline >= source_rows:
                if verbose:
                    print(f"Generated {count} frames.")
                while True:
                    yield None
            else:
                frame0 = frame1
                row1 = source[next_scanline:next_scanline+1, :, :] 
                frame1 = cv.resize(row1, None, fx=4, fy=8, interpolation=cv.INTER_NEAREST)               
                next_scanline += 1
        

#================================================================
# Write a video file in the default format.

def write_video_file(filename, verbose, *args):

    # Open the writer with a path, format, frame rate, and size.
    out = cv.VideoWriter(filename, codec_code, frame_rate, (frame_width, frame_height))

    if verbose:
        print(f"Open file {filename} for output.")

    # Set up the frame generator.
    frame_sequence = frame_generator(verbose, *args)

    # Synthesize some frames and write them to the stream.
    while True:
        next_frame = next(frame_sequence)
        if next_frame is not None:
            out.write(next_frame)
        else:
            break

    # Release everything when done.
    out.release()

#================================================================
# Main script follows.
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description = """Convert an image row by row to video frames, smoothly interpolating between rows. \
The source image must be exactly 57 pixels wide.""")
    parser.add_argument( '-v', '--verbose', action='store_true', help='Enable more detailed output.' )
    parser.add_argument( '--tempo', type=float, default=60.0, help='Tempo of key frames (image rows) in beats per minute (default: %(default)s)')
    parser.add_argument( '--input', type=str, default='timeline.png', help='Path of input image (default: %(default)s.')
    parser.add_argument( 'videopath', default='animation.avi', nargs='?', help='Path of output video file (default: %(default)s).')

    args = parser.parse_args()
    source = cv.imread(args.input)
    if args.verbose:
        print(f"Source image size: {source.shape}")
        
    write_video_file(args.videopath, args.verbose, source, args.tempo)