Bad Apple!! on Terminal
장창 6시간 동안 어제 뭔가 잘못된 것을 알아차린 서울대학교 수치모델연구실의 데이터를 보다가, 설 연휴 앞임에도 내가 뭘 하고 있는 것인가 하고 지쳐버리게 되었다.
지쳤을 때는 약간의 전환이 필요한 법이라, Youtube를 좀 뒤적거리다가 외국 사람들은 온갖 장치들과 소프트웨어들을 이용해 동방 프로젝트의 사운드트랙을 nomico가 리믹스한 Bad Apple!!을 가지각색에서 돌려본다는 것을 알게 되었다. 패미컴에서 돌린 사람도 있고, 테슬라 코일을 이용해 연주하면서 동시에 레이저로 윤곽선 형태로 비디오를 재현한 사람도 있고, 심지어는 Minecraft의 철 다락문이나 양을 이용하여 재현한 경우도 있고…….
사실 2009년에 일본 니코동에서 투고되어 높은 인기를 끈 해당 뮤직 비디오가 인기를 끌 수 있었고 별의별 Machine들에서 돌릴 수 있었던 연유는 비디오가 단순한 B&W(흑백) 비디오 형태의, 그것도 모션 그래픽 형태였기 때문이 아닐까 싶었다. 이미지 상으로도 윤곽선 뽑기도 편리하고, 각 프레임을 저장하거나 디스플레이에 출력하는데 있어서 그다지 많은 용량이 필요하지도 않고.
그래서 약간 노닥거리는 김에, 대충 OpenCV와 Python을 이용하면 Terminal이나 Shell에서 이 비디오를 재현할 수 있도록 하지 않을까 싶어서 대충 만들었다. 그 결과물이 아래에 있는데, 뭐…… 굳이 오래 걸리지도 않았고 재미 삼아서 만들었으니 소스 코드는 아래에 공개해둔다.
결과물
소스 코드
# -----------------------------------------
# videoplayer.py
# -----------------------------------------
# * Created on 8 Feb 2024
# * Created by Stephen Oh (stevenoh0908@gmail.com)
# -----------------------------------------
# this videoplayer plays video files, using opencv-python, in terminal.
# only black-and-white video can be played.
import cv2, pygame
import os, sys, time
import numpy as np
class Video:
# Variables
path = None
object = None
length = None
width = None
height = None
framecount = None
fps = None
frames = None
def __init__(self, path=None):
self.path = None
self.object = None
self.length = None
self.width = None
self.height = None
self.framecount = None
self.fps = None
self.frames = None
if path != None:
self.load(path)
pass
return
def getPath(self):
if self.path == None:
raise Exception("The video is not opened yet.")
return
return self.path
def getObject(self):
if self.object == None:
raise Exception("The video is not opened yet.")
return
return self.object
def getLength(self):
if self.length == None:
raise Exception("The video is not opened yet.")
return
return self.length
def getWidth(self):
if self.width == None:
raise Exception("The video is not opened yet.")
return
return self.width
def getHeight(self):
if self.height == None:
raise Exception("The video is not opened yet.")
return
return self.height
def getResolution(self):
return (self.getWidth(), self.getHeight())
def getFPS(self):
if self.fps == None:
raise Exception("The video is not opened yet.")
return
return self.fps
def getFramecount(self):
if self.framecount == None:
raise Exception("The video is not opened yet.")
return
return self.framecount
def setPath(self, path):
if not os.path.exists(path):
raise FileNotFoundError("The specified path was not found.")
return
self.path = path
return
def updateMetainfo(self):
if self.object == None:
raise Exception("The Video is not loaded.")
return
self.framecount = int(self.object.get(cv2.CAP_PROP_FRAME_COUNT))
self.width = int(self.object.get(cv2.CAP_PROP_FRAME_WIDTH))
self.height = int(self.object.get(cv2.CAP_PROP_FRAME_HEIGHT))
self.fps = self.object.get(cv2.CAP_PROP_FPS)
self.length = int(self.framecount / self.fps)
return
def load(self, path=None):
if path != None:
self.setPath(path)
pass
self.object = cv2.VideoCapture(self.path)
if not self.object.isOpened():
raise Exception("Video was not opened successfully.")
self.object = None
return
self.updateMetainfo()
return
def loadAllFrames(self):
if self.object == None or not self.object.isOpened():
raise Exception("The Video is not loaded.")
return
# init frames
self.frames = np.ndarray((self.getFramecount(), self.getHeight(), self.getWidth(), 3), dtype=np.uint8)
for i in range(self.getFramecount()):
ret, frame = self.object.read()
if ret:
self.frames[i,:,:,:] = frame
pass
pass
return
def release(self):
self.object.release()
return
def getFrame(self, frameno):
if type(self.frames) == type(None):
raise Exception("The Video is not loaded.")
return
if not (0 <= frameno < self.getFramecount()):
raise Exception(f"Invalid Frame No. Maximum Frame: {self.framecount-1}")
return
return self.frames[frameno,:,:,:]
def getGrayFrame(self, frameno):
frame = self.getFrame(frameno)
grayframe = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
return grayframe
def getBlackAndWhiteFrame(self, frameno):
grayframe = self.getGrayFrame(frameno)
thresh, blacknwhiteframe = cv2.threshold(grayframe, 127, 255, cv2.THRESH_BINARY)
return blacknwhiteframe
pass
def printFrame(video, frameno, width=100, whiteChar='oo', blackChar=' '):
orig_width, orig_height = video.getResolution()
r = width / orig_width
height = int(orig_height * r)
frame = video.getBlackAndWhiteFrame(frameno)
resizedFrame = cv2.resize(frame, dsize=(width,height))
print_str = ''
for row in range(height):
for col in range(width):
print_str += blackChar if resizedFrame[row,col] == 0 else whiteChar
pass
print_str += '\n'
pass
print(print_str)
return
def printVideo(path, width=100, whiteChar='o', blackChar=' ', timeOffset=0, audio_path='0'):
video = Video(path=path)
video.loadAllFrames()
framecounts = video.getFramecount()
frameoffset = 0
if audio_path != '0':
pygame.mixer.init()
pygame.mixer.music.load(audio_path)
pygame.mixer.music.play()
pass
stime = time.time()
for frameno in range(framecounts):
os.system('cls')
printFrame(video, frameno, width=width, whiteChar=whiteChar, blackChar=blackChar)
sleeptime = 1.0/video.getFPS() - (time.time() - stime) + frameoffset + timeOffset
if sleeptime > 0:
frameoffset = 0
time.sleep(sleeptime)
pass
else:
frameoffset = sleeptime
pass
stime = time.time()
pass
video.release()
return
if __name__ == '__main__':
width = 100
whiteChar = 'o'
blackChar = ' '
repeatCount = 1
timeOffset = 0
if len(sys.argv) not in [2, 3, 4, 5, 6, 7, 8]:
print("Invalid Usage.")
exit()
if len(sys.argv) >= 2: path = sys.argv[1]
if len(sys.argv) >= 3: audio_path = sys.argv[2]
if len(sys.argv) >= 4: width = int(sys.argv[3])
if len(sys.argv) >= 5: timeOffset = float(sys.argv[4])
if len(sys.argv) >= 6: repeatCount = int(sys.argv[5])
if len(sys.argv) >= 7: whiteChar = sys.argv[6]
if len(sys.argv) >= 8: blackChar = sys.argv[7]
printVideo(path, width=width, whiteChar=whiteChar*repeatCount, blackChar=blackChar*repeatCount, timeOffset=timeOffset, audio_path=audio_path)
exit()