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