Bad Apple!! on Terminal

2024-02-09 0 By 커피사유

장창 6시간 동안 어제 뭔가 잘못된 것을 알아차린 서울대학교 수치모델연구실의 데이터를 보다가, 설 연휴 앞임에도 내가 뭘 하고 있는 것인가 하고 지쳐버리게 되었다.

지쳤을 때는 약간의 전환이 필요한 법이라, Youtube를 좀 뒤적거리다가 외국 사람들은 온갖 장치들과 소프트웨어들을 이용해 동방 프로젝트의 사운드트랙을 nomico가 리믹스한 Bad Apple!!을 가지각색에서 돌려본다는 것을 알게 되었다. 패미컴에서 돌린 사람도 있고, 테슬라 코일을 이용해 연주하면서 동시에 레이저로 윤곽선 형태로 비디오를 재현한 사람도 있고, 심지어는 Minecraft의 철 다락문이나 양을 이용하여 재현한 경우도 있고…….

사실 2009년에 일본 니코동에서 투고되어 높은 인기를 끈 해당 뮤직 비디오가 인기를 끌 수 있었고 별의별 Machine들에서 돌릴 수 있었던 연유는 비디오가 단순한 B&W(흑백) 비디오 형태의, 그것도 모션 그래픽 형태였기 때문이 아닐까 싶었다. 이미지 상으로도 윤곽선 뽑기도 편리하고, 각 프레임을 저장하거나 디스플레이에 출력하는데 있어서 그다지 많은 용량이 필요하지도 않고.

그래서 약간 노닥거리는 김에, 대충 OpenCV와 Python을 이용하면 Terminal이나 Shell에서 이 비디오를 재현할 수 있도록 하지 않을까 싶어서 대충 만들었다. 그 결과물이 아래에 있는데, 뭐…… 굳이 오래 걸리지도 않았고 재미 삼아서 만들었으니 소스 코드는 아래에 공개해둔다.


결과물

노닥거린 결과물. Standard Output을 터미널에 출력하는데 걸리는 시간 지연 때문에 약간 깜빡거린다.

소스 코드

# -----------------------------------------
# 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()