Skip to main content

How to Create ASCII Art Animations in Python Using OpenCV

Converting videos into ASCII art animations is a fascinating way to explore image processing and terminal manipulation in Python. The core concept involves converting each video frame into grayscale, mapping pixel brightness to specific characters, and then efficiently rendering these frames in the terminal to create the illusion of movement.

This guide walks you through building a CLI tool that converts videos to ASCII animations using OpenCV and plays them directly in your terminal.

Prerequisites and Setup

We need to install opencv-python for video processing and pyprind for displaying progress bars during the conversion process.

sudo pip3 install opencv-python pyprind

Create a file named CLIPlayVideo.py and import the necessary libraries.

import sys
import os
import time
import threading
import termios
import tty
import cv2
import pyprind

The CharFrame Class: Basic Conversion Logic

The foundation of our project is converting a single image (or frame) into ASCII characters. We map pixel brightness (0-255) to a string of characters ranging from dense (like $) to sparse (like ).

class CharFrame:
# Character set from darkest to lightest
ascii_char = "$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\|()1{}[]?-_+~<>i!lI;:,\"^`'. "

def pixelToChar(self, luminance):
"""Map a grayscale value to a character."""
return self.ascii_char[int(luminance / 256 * len(self.ascii_char))]

def convert(self, img, limitSize=-1, fill=False, wrap=False):
"""Convert a standard image frame to an ASCII string."""
# 1. Resize image if necessary to fit terminal
if limitSize != -1 and (img.shape[0] > limitSize[1] or img.shape[1] > limitSize[0]):
img = cv2.resize(img, limitSize, interpolation=cv2.INTER_AREA)

ascii_frame = ''
blank = ''

# Calculate padding if fill is enabled
if fill:
blank += ' ' * (limitSize[0] - img.shape[1])
if wrap:
blank += '\n'

# 2. Iterate over pixels and convert to char
for i in range(img.shape[0]):
for j in range(img.shape[1]):
ascii_frame += self.pixelToChar(img[i, j])
ascii_frame += blank

return ascii_frame
note

We use luminance / 256 instead of 255 to prevent an IndexError when the pixel value is exactly 255.

The V2Char Class: Video Processing and Playback

This class handles reading video files frame-by-frame, converting them, and managing playback in the terminal.

Initializing and Generating Frames

We use cv2.VideoCapture to read the video.

class V2Char(CharFrame):
charVideo = []
timeInterval = 0.033

def __init__(self, path):
if path.endswith('txt'):
self.load(path)
else:
self.genCharVideo(path)

def genCharVideo(self, filepath):
self.charVideo = []
cap = cv2.VideoCapture(filepath)

# specific property IDs for OpenCV
self.timeInterval = round(1/cap.get(5), 3) # FPS
nf = int(cap.get(7)) # Frame count

print('Generating ASCII video, please wait...')
# Iterate through frames
for i in pyprind.prog_bar(range(nf)):
ret, rawFrame = cap.read()
if not ret: break

# Convert to grayscale
grayFrame = cv2.cvtColor(rawFrame, cv2.COLOR_BGR2GRAY)

# Convert to ASCII
frame = self.convert(grayFrame, os.get_terminal_size(), fill=True)
self.charVideo.append(frame)
cap.release()

Playback with Cursor Control

To play the animation smoothly without flooding the terminal history, we use ANSI escape codes to reset the cursor position after printing each frame.

    def play(self, stream=1):
if not self.charVideo:
return

# Setup output stream
if stream == 1 and os.isatty(sys.stdout.fileno()):
self.streamOut = sys.stdout.write
self.streamFlush = sys.stdout.flush
# ... (other stream handling omitted for brevity)

# Handling User Interrupt (Daemon Thread)
breakflag = False
fd = sys.stdin.fileno()
old_settings = None

def getChar():
nonlocal breakflag, old_settings
old_settings = termios.tcgetattr(fd)
tty.setraw(sys.stdin.fileno())
ch = sys.stdin.read(1)
if ch: breakflag = True

# Start input listener in background
t = threading.Thread(target=getChar)
t.daemon = True
t.start()

# Determine terminal rows
rows = len(self.charVideo[0]) // os.get_terminal_size()[0]

for frame in self.charVideo:
if breakflag: break

self.streamOut(frame)
self.streamFlush()
time.sleep(self.timeInterval)

# Move cursor up to overwrite previous frame
self.streamOut('\033[{}A\r'.format(rows-1))

# Cleanup: Restore terminal settings and clear screen
if old_settings:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)

# Clear the residual frame
self.streamOut('\033[{}B\033[K'.format(rows-1))
for i in range(rows-1):
self.streamOut('\033[1A')
self.streamOut('\r\033[K')

print("Finished!")

Exporting and Loading

To save processing time on subsequent runs, we can save the ASCII frames to a text file.

    def export(self, filepath):
if not self.charVideo: return
with open(filepath, 'w') as f:
for frame in self.charVideo:
f.write(frame + '\n')

def load(self, filepath):
self.charVideo = []
# Python reads files line-by-line; perfect for our format
for line in open(filepath):
self.charVideo.append(line[:-1])

Running the Animation

Wrap the logic in a main block that uses argparse to handle command-line arguments.

if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('file', help='Video file or exported charvideo file')
parser.add_argument('-e', '--export', nargs='?', const='charvideo.txt', help='Export path')

args = parser.parse_args()
v2char = V2Char(args.file)

if args.export:
v2char.export(args.export)

v2char.play()

Usage

  1. Run conversion and play:
    python3 CLIPlayVideo.py BadApple.mp4
  2. Convert, save, and play:
    python3 CLIPlayVideo.py BadApple.mp4 -e
  3. Play saved file:
    python3 CLIPlayVideo.py charvideo.txt

Conclusion

This project demonstrates the versatility of Python for multimedia projects.

  1. OpenCV efficiently handles frame extraction and resizing.
  2. ANSI Escape Codes allow for dynamic terminal UI updates, enabling animation playback.
  3. Daemon Threads provide a non-blocking way to listen for user input during execution.