import os import cv2 import numpy as np import matplotlib.pyplot as plt from matplotlib.patches import * from matplotlib.path import Path from matplotlib.figure import Figure from matplotlib.text import TextPath class Visual(Figure): def __init__(self, fps=30, *args, **kwargs): super().__init__(*args, **kwargs) self.__figure__() self.fps = fps self.frame_index = 0 @classmethod def square(cls, *args, **kwargs): return cls(figsize=(1, 1), *args, **kwargs) def __figure__(self): self.subplots_adjust(left=0, right=1, bottom=0, top=1) self.ax = self.add_subplot() self.ax.set_axis_off() def new_frame(self): if not os.path.exists('frames'): os.mkdir('frames') self.savefig(f'frames/{self.frame_index:04d}.png') self.frame_index += 1 def make_video(self, filename='video'): frames = sorted([ os.path.join('frames', file) for file in os.listdir('frames') ]) height, width, _ = cv2.imread(frames[0]).shape video = cv2.VideoWriter( filename=filename + '.mp4', fourcc=cv2.VideoWriter_fourcc(*'mp4v'), fps=self.fps, frameSize=(width, height), ) for frame in frames: video.write(cv2.imread(frame)) video.release() cv2.destroyAllWindows() def duration_to_number(self, duration): return int(duration*self.fps) def wait(self, duration): for _ in range(self.duration_to_number(duration)): self.new_frame() def set_boundary(self, boundary=1): self.ax.set_xlim(-boundary, boundary) self.ax.set_ylim(-boundary, boundary) def add_image(self, filename, shift=0, *args, **kwargs): X = plt.imread(filename) self.image = self.ax.imshow( X=X, extent=( shift - 1 - 2*X.shape[0]/X.shape[1], shift + 1, -1, 1, ), *args, **kwargs, ) def image_appear(self, duration): n_steps = self.duration_to_number(duration) for step in range(n_steps): self.image.set_alpha((1 + step)/n_steps) self.new_frame() def add_circle(self, xy=(0, 0), radius=1, *args, **kwargs): return self.ax.add_patch(Circle( xy=xy, radius=radius, *args, **kwargs, )) def new_sphere(self, color='forestgreen', dark='darkgreen', alpha=0.2, **kwargs, ): sphere = { key : vis.add_circle(color=color, lw=0) for key in ['main', 'light', 'shade'] } sphere.update(**kwargs) sphere['main'].set_color(dark) sphere['shade'].set(alpha=alpha, zorder=0) return sphere def update_sphere(self, main, light, shade, xy=(0, 0), radius=1, height=0, shift=(0.4, -0.8), side=0.15, shadow=0.5, ): xy = np.array(xy) shift = np.array(shift) for circle in [main, light, shade]: circle.set_radius(radius) main.set_center(xy + np.array([0, height])) light.set_center(xy - radius*side*shift + np.array([0, height])) shade_shift = height*shadow + radius/np.sum(shift**2)**0.5 shade.set_center(xy + shade_shift*shift) light.set_clip_path(main) def make_spheres(self, number, ratio=0.5, *args, **kwargs): self.spheres = [] for i in range(number): for j in range(number): xy = (2*i + 1)/number - 1, 1 - (2*j + 1)/number sphere = self.new_sphere( radius=ratio/number, xy=xy, *args, **kwargs, ) self.update_sphere(**sphere) self.spheres.append(sphere) def get_height(self, index=0, height_spread=1, height_period=0.5, height_drop=0.25, max_height=4.5, ): if not hasattr(self, 'height_shifts'): self.height_shifts = np.random.rand(len(self.spheres)) shifts = index - self.fps*height_spread*self.height_shifts shifts = shifts*(shifts >= 0) flucts = np.abs(np.cos(np.pi*shifts/self.fps/height_period)) heights = np.exp(-shifts/self.fps/height_drop) return max_height*flucts*heights def drop_spheres(self, duration, *args, **kwargs): for index in range(self.duration_to_number(duration)): heights = self.get_height(index, *args, **kwargs) for sphere, height in zip(self.spheres, heights): self.update_sphere(height=height, **sphere) self.new_frame() def schrink_spheres(self, duration): n_steps = self.duration_to_number(duration) radius = self.spheres[0]['main'].get_radius() for index in range(n_steps): ratio = 1 - (1 + index)/n_steps for sphere in self.spheres: sphere['radius'] = ratio*radius self.update_sphere(**sphere) self.new_frame() def add_heart(self, size=1, *args, **kwargs): path = TextPath(xy=(0, 0), s='\u2665', size=1) bbox = path.get_extents() vertices = size*(path.vertices - (bbox.p0 + bbox.p1)/2)/bbox.size path = Path(vertices=vertices, codes=path.codes) self.heart = vis.ax.add_patch(PathPatch(path=path, *args, **kwargs)) def draw_heart(self, duration): n_steps = self.duration_to_number(duration) wedge = self.ax.add_patch( Wedge((0, 0), 1, 270, 270, visible=False) ) self.heart.set_clip_path(wedge) for index in range(n_steps): wedge.set_theta1(270 - 360*(1 + index)/n_steps) self.new_frame() def zoom_in(self, duration, zoom=0.5): n_steps = self.duration_to_number(duration) for index in range(n_steps): ratio = (1 + index)/n_steps self.heart.set_fc((1, 1, 1, ratio)) self.set_boundary(1 - ratio*(1 - zoom)) self.new_frame() if __name__ == '__main__': vis = Visual.square(dpi=1000) vis.set_boundary() vis.wait(duration=0.5) vis.add_image(filename='singapore.jpg', shift=0.4) vis.image_appear(duration=1) vis.wait(duration=0.5) vis.make_spheres(number=5, color='gold', dark='darkgoldenrod') vis.drop_spheres(duration=3) vis.schrink_spheres(duration=1) vis.wait(duration=0.5) vis.add_heart(size=1.5) vis.heart.set(ec='crimson', fc=4*[0], lw=3, joinstyle='round') vis.draw_heart(duration=1) vis.wait(duration=0.5) vis.zoom_in(duration=1, zoom=0.2) vis.make_video()