소개
이 프로젝트에서는 OpenCV 를 사용하여 이미지와 비디오를 처리하여 ASCII 아트 애니메이션을 생성합니다.
- OpenCV 컴파일
- OpenCV 를 사용한 이미지 및 비디오 처리
- 이미지에서 ASCII 아트로의 변환 원리
- 데몬 스레드 (Daemon threads)
- 커서 위치 지정 및 이스케이프 인코딩 (Escape encoding)
👀 미리보기

🎯 과제
이 프로젝트에서는 다음을 배우게 됩니다.
- OpenCV 를 사용하여 이미지와 비디오를 ASCII 아트 애니메이션으로 변환하는 방법.
- 커서 위치 지정 및 이스케이프 인코딩을 사용하여 터미널에서 ASCII 아트 애니메이션을 재생하는 방법.
- ASCII 아트 애니메이션 데이터를 내보내고 로드하는 방법.
🏆 성과
이 프로젝트를 완료하면 다음을 수행할 수 있습니다.
- OpenCV 를 사용하여 이미지와 비디오를 처리할 수 있습니다.
- 이미지를 ASCII 아트로 변환할 수 있습니다.
- ASCII 아트 애니메이션을 재생할 수 있습니다.
- ASCII 아트 애니메이션 데이터를 내보내고 로드할 수 있습니다.
파일 생성
모두가 비디오가 실제로 일련의 이미지라는 것을 이해해야 합니다. 따라서 비디오를 ASCII 애니메이션으로 변환하는 기본 원리는 이미지를 ASCII 아트로 변환하는 것입니다.
다음은 이미지를 ASCII 아트로 변환하는 원리에 대한 간단한 설명입니다. 먼저, 이미지를 흑백 이미지로 변환합니다. 여기서 각 픽셀은 밝기 정보만 포함합니다 (0-255 값으로 표현됨). 그런 다음, 각 문자가 밝기 값의 범위에 해당하는 제한된 문자 집합을 만듭니다. 그런 다음, 이 대응 관계와 픽셀의 밝기 정보를 기반으로 각 픽셀을 해당 문자로 표현하여 ASCII 아트를 생성할 수 있습니다.
ASCII 애니메이션이 의미 있으려면 재생 가능해야 합니다. 이를 수행하는 가장 조잡하고 단순한 방법은 텍스트 편집기에서 ASCII 애니메이션 텍스트 파일을 열고 PageDown 키를 반복해서 누르는 것입니다. 그러나 이 접근 방식은 정말 너무 단순하고 조잡하며 전혀 우아하지 않습니다.
대신, 한 번에 한 프레임씩 출력하여 터미널에서 ASCII 애니메이션을 재생할 수 있습니다. 그러나 이 접근 방식에는 큰 단점이 있습니다. 재생하는 동안 터미널 오른쪽에 있는 스크롤 막대 (있는 경우) 가 점점 작아지는 것을 알 수 있습니다. 재생 후 터미널에서 위로 스크롤하면 이전에 출력된 ASCII 아트만 보이며 재생 전의 모든 명령 기록이 밀려납니다. 이 문제에 대한 해결책은 이 프로젝트의 뒷부분에서 제공될 것입니다.
~/project 디렉토리에 CLIPlayVideo.py 파일을 생성합니다.
cd ~/project
touch CLIPlayVideo.py
그런 다음, opencv-python 모듈을 설치합니다.
sudo pip install opencv-python
CharFrame 클래스 생성
잊지 않도록 필요한 패키지를 가져옵니다.
import sys
import os
import time
import threading
import termios
import tty
import cv2
import pyprind
마지막 모듈인 pyprind는 진행률 표시줄을 제공합니다. 비디오를 문자 애니메이션으로 변환하는 것은 시간이 많이 걸리는 프로세스이므로 진행 상황과 예상 남은 시간을 보기 위해 진행률 표시줄이 필요하며, 이를 통해 프로그램의 상태를 보다 직관적으로 이해할 수 있습니다. 다음과 같이 설치합니다.
sudo pip3 install pyprind
이 프로젝트에서는 비디오 파일을 문자 애니메이션으로 변환하고 재생하는 것 외에도 이미지 파일을 문자 아트로 변환하는 기능도 추가했습니다. 따라서 프로그램 설계에서 CharFrame, I2Char, V2Char의 세 가지 클래스가 있으며, 후자 두 클래스는 첫 번째 클래스를 상속합니다.
CharFrame 클래스:
class CharFrame:
ascii_char = "$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\|()1{}[]?-_+~<>i!lI;:,\"^`'. "
## 픽셀을 문자에 매핑
def pixelToChar(self, luminance):
return self.ascii_char[int(luminance/256*len(self.ascii_char))]
## 일반 프레임을 ASCII 문자 프레임으로 변환
def convert(self, img, limitSize=-1, fill=False, wrap=False):
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 = ''
if fill:
blank += ' '*(limitSize[0]-img.shape[1])
if wrap:
blank += '\n'
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
ascii_char 속성은 변환하려는 비디오에 따라 조정할 수 있습니다.
pixelToChar() 메서드는 픽셀의 밝기 정보를 받는 단일 매개변수 luminance 를 사용합니다. 메서드의 return 문에 있는 표현식은 값 256을 사용한다는 점에 유의해야 합니다. 픽셀의 밝기 범위는 0~255이지만, 이 표현식에서 256을 255로 변경하면 IndexError 예외가 발생할 수 있습니다.
convert() 메서드는 하나의 위치 매개변수와 세 개의 선택적 매개변수를 갖습니다. img 매개변수는 이미지를 열 때 OpenCV 에서 반환하는 객체인 numpy.ndarray 유형의 객체를 받습니다. 마찬가지로, 나중에 OpenCV 를 사용하여 얻은 비디오의 프레임도 이 유형이 됩니다. limitSize 매개변수는 이미지의 최대 너비와 높이를 나타내는 튜플을 받습니다. fill 매개변수는 이미지의 너비를 공백으로 최대 너비까지 채울지 여부를 나타내고, wrap 매개변수는 각 행의 끝에 줄 바꿈을 추가할지 여부를 나타냅니다.
img.shape는 이미지의 행 수 (높이), 열 수 (너비) 및 색상 채널을 포함하는 튜플을 반환합니다. 이미지가 흑백인 경우 색상 채널 수는 포함되지 않습니다.
cv2.resize() 함수는 이미지 크기를 조정하는 데 사용됩니다. 첫 번째 매개변수는 numpy.ndarray 객체이고, 두 번째는 크기가 조정된 이미지의 원하는 너비와 높이입니다. interpolation 매개변수는 보간 방법을 지정합니다. OpenCV 공식 웹사이트에서 설명한 대로 여러 보간 방법을 사용할 수 있습니다.
선호하는 보간 방법은 축소를 위한 **
cv2.INTER_AREA**와 확대/축소를 위한cv2.INTER_CUBIC(느림) 및 **cv2.INTER_LINEAR**입니다. 기본적으로 사용되는 보간 방법은 모든 크기 조정 목적에 대해 **cv2.INTER_LINEAR**입니다.
img[i,j]는 이미지의 i 번째 행과 j 번째 열에 있는 픽셀의 BGR 값 목록 또는 흑백 이미지의 해당 픽셀의 밝기 값을 반환합니다.
I2Char 클래스 생성
다음은 CharFrame을 상속하는 I2Char 클래스입니다.
class I2Char(CharFrame):
result = None
def __init__(self, path, limitSize=-1, fill=False, wrap=False):
self.genCharImage(path, limitSize, fill, wrap)
def genCharImage(self, path, limitSize=-1, fill=False, wrap=False):
img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
if img is None:
return
self.result = self.convert(img, limitSize, fill, wrap)
def show(self, stream = 2):
if self.result is None:
return
if stream == 1 and os.isatty(sys.stdout.fileno()):
self.streamOut = sys.stdout.write
self.streamFlush = sys.stdout.flush
elif stream == 2 and os.isatty(sys.stderr.fileno()):
self.streamOut = sys.stderr.write
self.streamFlush = sys.stderr.flush
elif hasattr(stream, 'write'):
self.streamOut = stream.write
self.streamFlush = stream.flush
self.streamOut(self.result)
self.streamFlush()
self.streamOut('\n')
cv2.imread()는 이미지를 읽고 numpy.ndarray 객체를 반환합니다. 첫 번째 매개변수는 열려는 이미지의 경로이고, 두 번째 매개변수는 이미지를 여는 방식을 지정하며 다음 세 가지 값을 가질 수 있습니다.
cv2.IMREAD_COLOR: 알파 채널을 무시하고 컬러 이미지를 로드합니다.cv2.IMREAD_GRAYSCALE: 이미지를 흑백 모드로 로드합니다.cv2.IMREAD_UNCHANGED: 알파 채널을 포함하여 이미지를 로드합니다.
show() 메서드는 사용할 출력 스트림을 나타내는 매개변수를 받습니다. 1은 표준 출력 sys.stdout을 나타내고, 2는 표준 오류 출력 sys.stderr을 나타냅니다. sys.stdout.fileno() 및 sys.stderr.fileno()는 각각 표준 출력 및 표준 오류 출력에 대한 파일 디스크립터를 반환합니다. os.isatty(fd)는 파일 디스크립터 fd 가 열려 있고 tty 장치에 연결되어 있는지 여부를 나타내는 부울 값을 반환합니다.
V2Char 클래스 생성
다음은 CharFrame 클래스를 상속하는 V2Char 클래스입니다.
먼저, 클래스에 대해 생각해 봅시다. 속성 중 하나는 문자 애니메이션에 대한 모든 데이터를 저장하는 데 사용되는 목록인 charVideo입니다.
그런 다음 두 가지 주요 메서드가 있습니다. 하나는 비디오 파일을 문자 애니메이션으로 변환하는 genCharVideo() 메서드이고, 다른 하나는 문자 애니메이션을 재생하는 play() 메서드입니다.
또한 비디오에서 문자 애니메이션으로 변환하는 데 시간이 오래 걸리므로, 향후 재생을 용이하게 하기 위해 charVideo에서 문자 애니메이션 데이터를 내보낼 수 있습니다. 즉, 내보내기 및 로드 메서드, 즉 export() 메서드와 load() 메서드가 필요합니다.
클래스에는 읽을 파일 경로를 매개변수로 사용하는 초기화 메서드도 필요합니다. 파일이 내보낸 txt 파일인 경우 load() 메서드를 호출하여 데이터를 charVideo 속성으로 로드합니다. 그렇지 않으면 비디오 파일로 처리되어 genCharVideo() 메서드를 호출하여 비디오를 문자 애니메이션으로 변환하고 charVideo 속성에 저장합니다.
class V2Char(CharFrame):
def __init__(self, path):
if path.endswith('txt'):
self.load(path)
else:
self.genCharVideo(path)
다음은 genCharVideo() 메서드입니다.
def genCharVideo(self, filepath):
self.charVideo = []
cap = cv2.VideoCapture(filepath) ## `cv2.VideoCapture()` 메서드를 사용하여 비디오 파일을 읽습니다.
self.timeInterval = round(1/cap.get(5), 3) ## 비디오의 프레임 속도를 가져옵니다.
nf = int(cap.get(7)) ## 비디오의 총 프레임 수를 가져옵니다.
print('Generate char video, please wait...')
for i in pyprind.prog_bar(range(nf)):
rawFrame = cv2.cvtColor(cap.read()[1], cv2.COLOR_BGR2GRAY)
frame = self.convert(rawFrame, os.get_terminal_size(), fill=True) ## 원시 프레임을 문자 프레임으로 변환합니다.
self.charVideo.append(frame)
cap.release() ## 리소스를 해제합니다.
cv2.VideoCapture() 메서드는 비디오 파일을 읽는 데 사용되며, 반환된 객체는 cap에 할당됩니다.
cap.get() 메서드를 사용하여 비디오의 속성을 얻을 수 있습니다. 예를 들어, cap.get(3) 및 cap.get(4)는 비디오의 너비와 높이를 반환하고, cap.get(5)는 비디오의 프레임 속도를 반환합니다. cap.get(7)은 비디오의 총 프레임 수를 반환합니다.
timeInterval은 재생 시간 간격을 저장하며, 문자 애니메이션의 프레임 속도를 원본 비디오와 동일하게 만드는 데 사용됩니다.
pyprind.prog_bar()는 반복할 때 터미널에 진행률 표시줄을 출력하는 생성기입니다.
cap.read()는 비디오의 다음 프레임을 읽습니다. 두 개의 요소가 있는 튜플을 반환합니다. 첫 번째 요소는 프레임이 올바르게 읽혔는지 여부를 나타내는 부울 값이고, 두 번째 요소는 프레임의 데이터를 포함하는 numpy.ndarray입니다.
cv2.cvtColor()는 이미지의 색상 공간을 변환하는 데 사용됩니다. 첫 번째 매개변수는 이미지 객체이고, 두 번째 매개변수는 변환 유형을 나타냅니다. OpenCV 에는 150 개 이상의 색상 공간 변환이 있습니다. 여기서는 색상을 회색으로 변환하는 cv2.COLOR_BGR2GRAY를 사용합니다.
os.get_terminal_size()는 현재 터미널의 열 수 (너비) 와 행 수 (높이) 를 반환합니다. fill 매개변수를 True로 설정하고 wrap 매개변수를 설정하지 않았으므로 기본값은 False입니다. 터미널에서 인쇄된 문자가 한 줄의 너비를 초과하면 터미널은 자동으로 표시를 줄 바꿈합니다.
마지막으로, cap.release()로 리소스를 해제하는 것을 잊지 마십시오.
다음은 play() 메서드입니다. 앞서 언급했듯이, 문자 애니메이션을 재생한 후 터미널이 쓸모없는 문자로 채워지는 것을 방지하기 위해 커서 위치 지정 이스케이프 코드를 사용할 수 있습니다.
다음과 같이 할 수 있습니다. 각 프레임을 출력한 후 커서를 재생 시작 부분으로 이동하면 다음 프레임이 이 위치에서 출력되고 이전 내용이 자동으로 덮어쓰입니다. 이 프로세스를 재생이 완료될 때까지 반복한 다음 출력된 마지막 프레임을 지우면 터미널이 문자 아트로 채워지지 않습니다.
다음은 The Linux Command Line에서 가져온 일련의 커서 위치 지정 이스케이프 코드입니다 (일부 터미널은 일부 이스케이프 코드를 지원하지 않을 수 있습니다).
| 이스케이프 코드 | 작업 |
|---|---|
| \033[l;cH | 커서를 l 행, c 열로 이동합니다. |
| \033[nA | 커서를 n 행 위로 이동합니다. |
| \033[nB | 커서를 n 행 아래로 이동합니다. |
| \033[nC | 커서를 n 문자 앞으로 이동합니다. |
| \033[nD | 커서를 n 문자 뒤로 이동합니다. |
| \033[2J | 화면을 지우고 커서를 (0, 0) 으로 이동합니다. |
| \033[K | 커서 위치에서 현재 줄의 끝까지 지웁니다. |
| \033[s | 현재 커서 위치를 저장합니다. |
| \033[u | 이전에 저장된 커서 위치를 복원합니다. |
또 다른 문제는 재생을 중간에 중지하는 방법입니다. 물론 Ctrl + C를 누를 수 있지만, 이 방법으로는 프로그램이 정리 작업을 수행할 수 없고 터미널이 쓸모없는 문자로 채워집니다.
다음과 같이 설계했습니다. 문자 애니메이션이 재생을 시작하면 사용자 입력을 기다리는 데몬 스레드를 시작합니다. 입력을 받으면 문자 애니메이션 재생을 중지합니다.
여기서 두 가지 사항에 유의해야 합니다.
input()메서드를 사용하여 문자 입력을 받지 마십시오.- 일반 스레드를 사용할 수 없습니다.
첫 번째 요점에 대해, 어떤 문자를 눌러도 재생을 중지하려면 input() 을 사용하면 안 됩니다. 그렇지 않으면 Enter 키를 눌러 재생을 중지하거나 다른 문자를 누른 다음 Enter 키를 눌러 재생을 중지해야 합니다. 즉, input() 을 사용하는 것은 편하지 않습니다. 가장 좋은 해결책은 C 언어의 getchar() 메서드와 유사한 것을 사용하는 것입니다. 그러나 Python 은 유사한 메서드를 제공하지 않으며, 나중에 코드에서 대체 솔루션을 제공합니다.
두 번째 요점에 대해, 파생된 스레드가 계속 실행 중인 경우, 파생된 스레드가 데몬 스레드로 설정되지 않는 한 메인 스레드는 종료되지 않는다는 것을 이해해야 합니다. 따라서 일반 스레드를 사용하고 사용자가 중간에 중지하지 않으면 애니메이션이 완료될 때까지 계속 실행된 다음 사용자가 어떤 문자를 입력할 때까지 영원히 실행됩니다. 재생이 완료되면 이 파생된 스레드를 수동으로 종료할 수 있다면 문제가 없습니다. 그러나 Python 은 스레드를 종료하는 메서드를 제공하지 않습니다. 따라서 이 파생된 스레드를 데몬 스레드로 설정할 수밖에 없습니다. 메인 스레드가 종료되면 프로그램은 데몬 스레드만 실행하게 되며, 프로그램이 종료될 때 데몬 스레드는 자동으로 종료됩니다.
V2Char 클래스의 전체 코드는 다음과 같습니다.
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)
self.timeInterval = round(1/cap.get(5), 3)
nf = int(cap.get(7))
print('Generate char video, please wait...')
for i in pyprind.prog_bar(range(nf)):
rawFrame = cv2.cvtColor(cap.read()[1], cv2.COLOR_BGR2GRAY)
frame = self.convert(rawFrame, os.get_terminal_size(), fill=True)
self.charVideo.append(frame)
cap.release()
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 = []
for i in open(filepath): ## 각 줄은 프레임입니다.
self.charVideo.append(i[:-1])
def play(self, stream = 1):
## 버그:
## 커서 위치 지정 이스케이프 코드는 Windows와 호환되지 않습니다.
if not self.charVideo:
return
if stream == 1 and os.isatty(sys.stdout.fileno()): ## 표준 출력이고 파일 디스크립터가 터미널을 참조하는 경우
self.streamOut = sys.stdout.write
self.streamFlush = sys.stdout.flush
elif stream == 2 and os.isatty(sys.stderr.fileno()): ## 표준 오류 출력이고 파일 디스크립터가 터미널을 참조하는 경우
self.streamOut = sys.stderr.write
self.streamFlush = sys.stderr.flush
elif hasattr(stream, 'write'): ## write 속성이 있는 경우
self.streamOut = stream.write
self.streamFlush = stream.flush
old_settings = None
breakflag = None
fd = sys.stdin.fileno() ## 표준 입력의 파일 디스크립터를 가져옵니다.
def getChar():
nonlocal breakflag
nonlocal old_settings
old_settings = termios.tcgetattr(fd) ## 표준 입력의 속성을 저장합니다.
tty.setraw(sys.stdin.fileno()) ## 표준 입력을 원시 모드로 설정합니다.
ch = sys.stdin.read(1) ## 문자를 읽습니다.
breakflag = True if ch else False
## 스레드 생성
getchar = threading.Thread(target=getChar)
getchar.daemon = True ## 데몬 스레드로 설정합니다.
getchar.start() ## 데몬 스레드를 시작합니다.
rows = len(self.charVideo[0])//os.get_terminal_size()[0] ## 출력 문자 아트의 행 수
for frame in self.charVideo:
if breakflag is True: ## 입력을 받으면 루프를 종료합니다.
break
self.streamOut(frame)
self.streamFlush()
time.sleep(self.timeInterval)
self.streamOut('\033[{}A\r'.format(rows-1)) ## 시작 부분으로 `rows-1`행 위로 이동합니다.
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) ## 표준 입력을 원래 속성으로 복원합니다.
self.streamOut('\033[{}B\033[K'.format(rows-1)) ## 마지막 줄로 `rows-1`행 아래로 이동하고 지웁니다.
for i in range(rows-1): ## 마지막 프레임의 모든 행을 두 번째 마지막 행부터 지웁니다.
self.streamOut('\033[1A')
self.streamOut('\r\033[K')
info = 'User interrupt!\n' if breakflag else 'Finished!\n'
self.streamOut(info)
테스트 및 실행
이전 코드 아래에 다음 코드를 입력하면 이 코드를 스크립트 파일로 사용하여 비디오를 ASCII 아트로 변환할 수 있습니다.
if __name__ == '__main__':
import argparse
## Set command line arguments
parser = argparse.ArgumentParser()
parser.add_argument('file',
help='Video file or charvideo file')
parser.add_argument('-e', '--export', nargs='?', const='charvideo.txt',
help='Export charvideo file')
## Get arguments
args = parser.parse_args()
v2char = V2Char(args.file)
if args.export:
v2char.export(args.export)
v2char.play()
다음 명령을 입력하여 파일 ~/project/BadApple.mp4를 ASCII 애니메이션으로 변환하고, 파일로 내보낸 다음 변환된 ASCII 애니메이션을 재생합니다 (인코딩 프로세스는 몇 분 정도 걸릴 수 있으므로 잠시 기다려 주십시오).
cd ~/project
python3 CLIPlayVideo.py BadApple.mp4 -e

이후, 다시 변환하지 않고 다시 재생하려면 내보낸 ASCII 아트 파일을 직접 읽을 수 있습니다. 파일이 charvideo.txt라고 가정합니다.
python3 CLIPlayVideo.py charvideo.txt
요약
이 프로젝트는 OpenCV 를 사용하여 이미지와 비디오를 처리합니다. 여기서 언급된 OpenCV 의 작업은 모두에게 시작일 뿐입니다. 더 깊이 파고들고 싶다면 공식 문서를 더 자세히 살펴보아야 합니다. 데몬 스레드를 사용함으로써 모든 사람이 데몬 스레드가 무엇인지, 그리고 일반 스레드와 어떻게 다른지 이해할 수 있다고 생각합니다. 마지막으로, 커서 위치 지정 및 이스케이프 코드에 대해서도 배웠습니다. 이것이 그다지 유용하지 않을 수 있지만, 여전히 많은 작업을 수행하는 데 사용할 수 있는 흥미로운 것입니다. 더 많이 가지고 놀 수 있습니다.



