From 3ae36a3ac9be2d6ff7956244d017a75ecfbce607 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 26 Mar 2015 22:49:22 -0600 Subject: [PATCH] tex mobjects implemented --- Tex/template.tex | 20 +++++++++ animate.py | 16 +++---- constants.py | 36 +++++++++------- displayer.py | 89 ++++++++++++++++++++++++++++---------- mobject.py | 47 ++++++++++---------- scene.py | 66 ++++++++++++++++++---------- tex_utils.py | 109 +++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 291 insertions(+), 92 deletions(-) create mode 100644 Tex/template.tex create mode 100644 tex_utils.py diff --git a/Tex/template.tex b/Tex/template.tex new file mode 100644 index 00000000..a6d20295 --- /dev/null +++ b/Tex/template.tex @@ -0,0 +1,20 @@ +\documentclass[12pt]{beamer} + +\usepackage[english]{babel} +\usepackage{mathtools} + +\mode +{ + \usetheme{default} % or try Darmstadt, Madrid, Warsaw, ... + \usecolortheme{default} % or try albatross, beaver, crane, ... + \usefonttheme[onlymath]{serif} % or try serif, structurebold, ... + \setbeamertemplate{navigation symbols}{} + \setbeamertemplate{caption}[numbered] +} +\begin{document} +\begin{frame} + \centering + SizeHere + $YourTextHere$ +\end{frame} +\end{document} \ No newline at end of file diff --git a/animate.py b/animate.py index e49bf24f..6be7f5ce 100644 --- a/animate.py +++ b/animate.py @@ -16,19 +16,19 @@ import displayer as disp class Animation(object): def __init__(self, - mobject, + mobject_or_animation, run_time = DEFAULT_ANIMATION_RUN_TIME, alpha_func = high_inflection_0_to_1, name = None): - if isinstance(mobject, type) and issubclass(mobject, Mobject): - self.mobject = mobject() - self.starting_mobject = mobject() - elif isinstance(mobject, Mobject): - self.mobject = mobject - self.starting_mobject = copy.deepcopy(mobject) + self.embedded_animation = None + if isinstance(mobject_or_animation, type) and issubclass(mobject_or_animation, Mobject): + self.mobject = mobject_or_animation() + elif isinstance(mobject_or_animation, Mobject): + self.mobject = mobject_or_animation else: - raise Exception("Invalid mobject parameter, must be \ + raise Exception("Invalid mobject_or_animation parameter, must be \ subclass or instance of Mobject") + self.starting_mobject = copy.deepcopy(self.mobject) self.reference_mobjects = [self.starting_mobject] self.alpha_func = alpha_func or (lambda x : x) self.run_time = run_time diff --git a/constants.py b/constants.py index d1f5ee56..00b1e0ae 100644 --- a/constants.py +++ b/constants.py @@ -1,12 +1,12 @@ import os -PRODUCTION_QUALITY = True +PRODUCTION_QUALITY = False -DEFAULT_POINT_DENSITY_2D = 25 if PRODUCTION_QUALITY else 20 -DEFAULT_POINT_DENSITY_1D = 200 if PRODUCTION_QUALITY else 50 +DEFAULT_POINT_DENSITY_2D = 25 #if PRODUCTION_QUALITY else 20 +DEFAULT_POINT_DENSITY_1D = 200 #if PRODUCTION_QUALITY else 50 -HEIGHT = 1024#1440 if PRODUCTION_QUALITY else 480 -WIDTH = 1024#2560 if PRODUCTION_QUALITY else 640 +DEFAULT_HEIGHT = 1440 #if PRODUCTION_QUALITY else 480 +DEFAULT_WIDTH = 2560 #if PRODUCTION_QUALITY else 640 #All in seconds DEFAULT_FRAME_DURATION = 0.04 if PRODUCTION_QUALITY else 0.1 DEFAULT_ANIMATION_RUN_TIME = 3.0 @@ -15,22 +15,28 @@ DEFAULT_DITHER_TIME = 1.0 GENERALLY_BUFF_POINTS = PRODUCTION_QUALITY -BACKGROUND_COLOR = "black" #TODO, this is never actually enforced anywhere. - DEFAULT_NUM_STARS = 1000 SPACE_HEIGHT = 4.0 -SPACE_WIDTH = WIDTH * SPACE_HEIGHT / HEIGHT +SPACE_WIDTH = DEFAULT_WIDTH * SPACE_HEIGHT / DEFAULT_HEIGHT -PDF_DENSITY = 400 -IMAGE_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "images") -GIF_DIR = os.path.join(os.getenv("HOME"), "Desktop", "math_gifs") -MOVIE_DIR = os.path.join(os.getenv("HOME"), "Desktop", "math_movies") -PDF_DIR = os.path.join(os.getenv("HOME"), "Documents", "Tex", "Animations") + +THIS_DIR = os.path.dirname(os.path.realpath(__file__)) +IMAGE_DIR = os.path.join(THIS_DIR, "images") +GIF_DIR = os.path.join(THIS_DIR, "gifs") +MOVIE_DIR = os.path.join(THIS_DIR, "movies") +TEX_DIR = os.path.join(THIS_DIR, "Tex") +TEX_IMAGE_DIR = os.path.join(IMAGE_DIR, "Tex") TMP_IMAGE_DIR = "/tmp/animation_images/" -for folder in [IMAGE_DIR, GIF_DIR, MOVIE_DIR, TMP_IMAGE_DIR]: +for folder in [IMAGE_DIR, GIF_DIR, MOVIE_DIR, TEX_DIR, TMP_IMAGE_DIR, TEX_IMAGE_DIR]: if not os.path.exists(folder): os.mkdir(folder) -LOGO_PATH = os.path.join(IMAGE_DIR, "logo.png") \ No newline at end of file +PDF_DENSITY = 400 +SIZE_TO_REPLACE = "SizeHere" +TEX_TEXT_TO_REPLACE = "YourTextHere" +TEMPLATE_TEX_FILE = os.path.join(TEX_DIR, "template.tex") + +LOGO_PATH = os.path.join(IMAGE_DIR, "logo.png") + diff --git a/displayer.py b/displayer.py index 42f6fb69..9957bb64 100644 --- a/displayer.py +++ b/displayer.py @@ -2,39 +2,47 @@ import numpy as np import itertools as it import os from PIL import Image +import subprocess import cv2 from animate import * +def paint_mobject(mobject, image_array = None): + if image_array is None: + pixels = np.zeros((DEFAULT_HEIGHT, DEFAULT_WIDTH, 3)) + else: + pixels = np.array(image_array) + assert len(pixels.shape) == 3 and pixels.shape[2] == 3 + height = pixels.shape[0] + width = pixels.shape[1] + space_height = SPACE_HEIGHT + space_width = SPACE_HEIGHT * width / height -def get_image(points, rgbs): - return Image.fromarray(get_pixels(points, rgbs)) - -def get_pixels(points, rgbs): #TODO, Let z add a depth componenet? - points = points[:, :2] + points = np.array(mobject.points[:, :2]) #Flips y-axis points[:,1] *= -1 #Map points to pixel space, then create pixel array first in terms #of its flattened version points += np.array( - [SPACE_WIDTH, SPACE_HEIGHT]*points.shape[0] + [space_width, space_height]*points.shape[0] ).reshape(points.shape) points *= np.array( - [HEIGHT / (2.0 * SPACE_HEIGHT), WIDTH / (2.0 * SPACE_WIDTH)]*\ + [width / (2.0 * space_width), height / (2.0 * space_height)]*\ points.shape[0] ).reshape(points.shape) points = points.astype('int') - flattener = np.array([1, WIDTH], dtype = 'int').reshape((2, 1)) + flattener = np.array([1, width], dtype = 'int').reshape((2, 1)) indices = np.dot(points, flattener) indices = indices.reshape(indices.size) - admissibles = (indices < HEIGHT * WIDTH) * (indices > 0) + admissibles = (indices < height * width) * (indices > 0) indices = indices[admissibles] - rgbs = rgbs[admissibles] + rgbs = mobject.rgbs[admissibles] rgbs = (rgbs * 255).astype(int) - pixels = np.zeros((HEIGHT * WIDTH, 3)) + pixels = pixels.reshape((height * width, 3)) pixels[indices] = rgbs - return pixels.reshape((HEIGHT, WIDTH, 3)).astype('uint8') + pixels = pixels.reshape((height, width, 3)).astype('uint8') + return pixels def write_to_gif(scene, name): #TODO, find better means of compression @@ -43,29 +51,31 @@ def write_to_gif(scene, name): filepath = os.path.join(GIF_DIR, name) temppath = os.path.join(GIF_DIR, "Temp.gif") print "Writing " + name + "..." - writeGif(temppath, scene.frames, scene.frame_duration) + images = [Image.fromarray(frame) for frame in scene.frames] + writeGif(temppath, images, scene.frame_duration) print "Compressing..." os.system("gifsicle -O " + temppath + " > " + filepath) os.system("rm " + temppath) def write_to_movie(scene, name): - #TODO, incorporate pause time frames = scene.frames progress_bar = progressbar.ProgressBar(maxval=len(frames)) progress_bar.start() print "writing " + name + "..." + filepath = os.path.join(MOVIE_DIR, name) + filedir = "/".join(filepath.split("/")[:-1]) + if not os.path.exists(filedir): + os.makedirs(filedir) + rate = int(1/scene.frame_duration) + tmp_stem = os.path.join(TMP_IMAGE_DIR, name.replace("/", "_")) suffix = "-%04d.png" image_files = [] for frame, count in zip(frames, it.count()): progress_bar.update(int(0.9 * count)) - frame.save(tmp_stem + suffix%count) + Image.fromarray(frame).save(tmp_stem + suffix%count) image_files.append(tmp_stem + suffix%count) - filepath = os.path.join(MOVIE_DIR, name + ".mp4") - filedir = "/".join(filepath.split("/")[:-1]) - if not os.path.exists(filedir): - os.makedirs(filedir) commands = [ "ffmpeg", "-y", @@ -77,7 +87,7 @@ def write_to_movie(scene, name): "libx264", "-vf", "fps=%d,format=yuv420p"%int(1/scene.frame_duration), - filepath + filepath + ".mp4" ] os.system(" ".join(commands)) for image_file in image_files: @@ -85,10 +95,17 @@ def write_to_movie(scene, name): progress_bar.finish() + # vs = VideoSink(scene.shape, filepath, rate) + # for frame in frames: + # vs.run(frame) + # vs.close() + # progress_bar.finish() + + # filepath = os.path.join(MOVIE_DIR, name + ".mov") # fourcc = cv2.cv.FOURCC(*"8bps") # out = cv2.VideoWriter( - # filepath, fourcc, 1.0/animation.frame_duration, (WIDTH, HEIGHT), True + # filepath, fourcc, 1.0/scene.frame_duration, (DEFAULT_WIDTH, DEFAULT_HEIGHT), True # ) # progress = 0 # for frame in frames: @@ -103,7 +120,35 @@ def write_to_movie(scene, name): # progress_bar.finish() - +class VideoSink(object): + def __init__(self, size, filename="output", rate=10, byteorder="bgra") : + self.size = size + cmdstring = [ + 'mencoder', + '/dev/stdin', + '-demuxer', 'rawvideo', + '-rawvideo', 'w=%i:h=%i'%size[::-1]+":fps=%i:format=%s"%(rate,byteorder), + '-o', filename+'.mp4', + '-ovc', 'lavc', + ] + self.p = subprocess.Popen(cmdstring, stdin=subprocess.PIPE, shell=False) + + def run(self, image): + """ + Image comes in as HEIGHTxWIDTHx3 numpy array, order rgb + """ + assert image.shape == self.size + (3,) + r, g, b = [image[:,:,i].astype('uint32') for i in range(3)] + a = np.ones(image.shape[:2], dtype = 'uint32') + #hacky + image = sum([ + arr << 8**i + for arr, i in zip(range(4), [a, r, g, b]) + ]) + self.p.stdin.write(image.tostring()) + + def close(self): + self.p.stdin.close() diff --git a/mobject.py b/mobject.py index 1acc99a2..1c8bcf74 100644 --- a/mobject.py +++ b/mobject.py @@ -5,9 +5,10 @@ from PIL import Image from random import random from animate import * -from tex_image_utils import NAME_TO_IMAGE_FILE +from tex_utils import * import displayer as disp + class Mobject(object): """ Mathematical Object @@ -40,10 +41,10 @@ class Mobject(object): return self.name def display(self): - disp.get_image(self.points, self.rgbs).show() + Image.fromarray(disp.paint_mobject(self)).show() def save_image(self, name = None): - disp.get_image(self.points, self.rgbs).save( + Image.fromarray(disp.paint_mobject(self)).save( os.path.join(MOVIE_DIR, (name or str(self)) + ".png") ) @@ -286,7 +287,7 @@ class Line(Mobject1D): ]) -class Cube(Mobject2D): +class CubeWithFaces(Mobject2D): def generate_points(self): self.add_points([ sgn * np.array(coords) @@ -300,7 +301,7 @@ class Cube(Mobject2D): def unit_normal(self, coords): return np.array(map(lambda x : 1 if abs(x) == 1 else 0, coords)) -class CubeShell(Mobject1D): +class Cube(Mobject1D): DEFAULT_COLOR = "yellow" def generate_points(self): self.add_points([ @@ -449,24 +450,15 @@ class NumberLine(Mobject1D): for x in np.arange(-self.radius, self.radius, self.interval_size) for y in np.arange(-self.tick_size, self.tick_size, self.epsilon) ]) - if self.with_numbers: #TODO, make these numbers a separate object + if self.with_numbers: + #TODO, test vertical_displacement = -0.3 - max_explicit_num = 3 - num_to_name = dict( - (x, str(x)) - for x in range(-max_explicit_num, max_explicit_num + 1) - ) - num_to_name[max_explicit_num + 1] = "cdots" - num_to_name[-max_explicit_num - 1] = "cdots" - nums = CompoundMobject(*[ - ImageMobject( - NAME_TO_IMAGE_FILE[num_to_name[x]] - ).scale(0.6).center().shift( - [x * self.interval_size, vertical_displacement, 0] - ) - for x in range(-max_explicit_num - 1, max_explicit_num + 2) - ]) - self.add_points(nums.points, nums.rgbs) + nums = range(-self.radius, self.radius) + nums = map(lambda x : x / self.interval_size, nums) + mobs = tex_mobjects(*[str(num) for num in nums]) + for num, mob in zip(nums, mobs): + mob.center().shift([num, vertical_displacement, 0]) + self.add(*mobs) # class ComplexPlane(Grid): # def __init__(self, *args, **kwargs): @@ -529,15 +521,20 @@ class ImageMobject(Mobject2D): for i in indices ], dtype = 'float64') height, width = map(float, (height, width)) - if height / width > float(HEIGHT) / WIDTH: + if height / width > float(DEFAULT_HEIGHT) / DEFAULT_WIDTH: points *= 2 * SPACE_HEIGHT / height else: points *= 2 * SPACE_WIDTH / width self.add_points(points, rgbs = rgbs) - - +def tex_mobjects(expression, size = "\HUGE"): + images = tex_to_image(expression, size) + if isinstance(images, list): + #TODO, is checking listiness really the best here? + return [ImageMobject(im) for im in images] + else: + return ImageMobject(images) diff --git a/scene.py b/scene.py index 9ca345d9..ba4146d9 100644 --- a/scene.py +++ b/scene.py @@ -1,6 +1,7 @@ from PIL import Image from colour import Color import numpy as np +import itertools as it import warnings import time import os @@ -16,29 +17,47 @@ import displayer as disp class Scene(object): def __init__(self, + name = None, frame_duration = DEFAULT_FRAME_DURATION, - name = None): + background = None, + height = DEFAULT_HEIGHT, + width = DEFAULT_WIDTH,): self.frame_duration = frame_duration self.frames = [] - self.mobjects = set([]) + self.mobjects = [] + if background: + self.background = np.array(background) + #TODO, Error checking? + else: + self.background = np.zeros((height, width, 3)) + self.shape = self.background.shape[:2] self.name = name def __str__(self): return self.name or "Babadinook" #TODO def add(self, *mobjects): - #TODO, perhaps mobjects should be ordered, for foreground/background - self.mobjects.update(mobjects) + """ + Mobjects will be displayed, from background to foreground, + in the order with which they are entered. + """ + for mobject in mobjects: + #In case it's already in there, it should + #now be closer to the foreground. + self.remove(mobject) + self.mobjects.append(mobject) def remove(self, *mobjects): - self.mobjects.difference_update(mobjects) + for mobject in mobjects: + while mobject in self.mobjects: + self.mobjects.remove(mobject) - def animate(self, animations, - dither_time = DEFAULT_DITHER_TIME): - if isinstance(animations, Animation): - animations = [animations] - self.pause(dither_time) - run_time = max([anim.run_time for anim in animations]) + def animate(self, *animations): + #Runtime is determined by the first animation + run_time = animations[0].run_time + moving_mobjects = [a.mobject for a in animations] + self.remove(*moving_mobjects) + background = self.get_frame() print "Generating animations..." progress_bar = progressbar.ProgressBar(maxval=run_time) @@ -46,28 +65,31 @@ class Scene(object): for t in np.arange(0, run_time, self.frame_duration): progress_bar.update(t) + new_frame = background for anim in animations: - anim.update(t) - self.frames.append(self.get_frame(*animations)) + anim.update(t / anim.run_time) + new_frame = disp.paint_mobject(anim.mobject, new_frame) + self.frames.append(new_frame) for anim in animations: anim.clean_up() + self.add(*moving_mobjects) progress_bar.finish() - def pause(self, duration): + def get_frame(self): + frame = self.background + for mob in self.mobjects: + frame = disp.paint_mobject(mob, frame) + return frame + + def dither(self, duration = DEFAULT_DITHER_TIME): self.frames += [self.get_frame()]*int(duration / self.frame_duration) - def get_frame(self, *animations): - #Include animations so as to display mobjects not in the list - #TODO, This is temporary - mob = list(self.mobjects)[0] - return disp.get_image(mob.points, mob.rgbs) - def write_to_gif(self, name = None, end_dither_time = DEFAULT_DITHER_TIME): - self.pause(end_dither_time) + self.dither(end_dither_time) disp.write_to_gif(self, name or str(self)) def write_to_movie(self, name = None, end_dither_time = DEFAULT_DITHER_TIME): - self.pause(end_dither_time) + self.dither(end_dither_time) disp.write_to_movie(self, name or str(self)) diff --git a/tex_utils.py b/tex_utils.py new file mode 100644 index 00000000..9d18365a --- /dev/null +++ b/tex_utils.py @@ -0,0 +1,109 @@ +import os +import itertools as it +from PIL import Image +from constants import * + +def tex_to_image(expression, size = "\HUGE"): + """ + Returns list of images for correpsonding with a list of expressions + """ + return_list = False + arg = expression + if not isinstance(expression, str): + #TODO, verify that expression is iterable of strings + expression = "\n".join([ + "\onslide<%d>{"%count + exp + "}" + for count, exp in zip(it.count(1), expression) + ]) + return_list = True + filestem = os.path.join( + TEX_DIR, str(hash(expression + size)) + ) + if not os.path.exists(filestem + ".dvi"): + if not os.path.exists(filestem + ".tex"): + print " ".join([ + "Writing ", + "".join(arg), + "at size %s to "%(size), + filestem, + ]) + with open(TEMPLATE_TEX_FILE, "r") as infile: + body = infile.read() + body = body.replace(SIZE_TO_REPLACE, size) + body = body.replace(TEX_TEXT_TO_REPLACE, expression) + with open(filestem + ".tex", "w") as outfile: + outfile.write(body) + commands = [ + "latex", + "-interaction=batchmode", + "-output-directory=" + TEX_DIR, + filestem + ".tex" + ] + #TODO, Error check + os.system(" ".join(commands)) + assert os.path.exists(filestem + ".dvi") + result = dvi_to_png(filestem + ".dvi") + return result if return_list else result[0] + + + +def dvi_to_png(filename, regen_if_exists = False): + """ + Converts a dvi, which potentially has multiple slides, into a + directory full of enumerated pngs corresponding with these slides. + Returns a list of PIL Image objects for these images sorted as they + where in the dvi + """ + possible_paths = [ + filename, + os.path.join(TEX_DIR, filename), + os.path.join(TEX_DIR, filename + ".dvi"), + ] + for path in possible_paths: + if os.path.exists(path): + directory, filename = os.path.split(path) + name = filename.split(".")[0] + images_dir = os.path.join(TEX_IMAGE_DIR, name) + if not os.path.exists(images_dir): + os.mkdir(images_dir) + if os.listdir(images_dir) == [] or regen_if_exists: + commands = [ + "convert", + "-density", + str(PDF_DENSITY), + path, + "-size", + str(DEFAULT_WIDTH) + "x" + str(DEFAULT_HEIGHT), + os.path.join(images_dir, name + ".png") + ] + os.system(" ".join(commands)) + image_paths = [ + os.path.join(images_dir, name) + for name in os.listdir(images_dir) + if name.endswith(".png") + ] + image_paths.sort(cmp_enumerated_files) + return [Image.open(path).convert('RGB') for path in image_paths] + raise IOError("File not Found") + +def cmp_enumerated_files(name1, name2): + num1, num2 = [ + int(name.split(".")[0].split("-")[-1]) + for name in (name1, name2) + ] + return num1 - num2 + + + + + + + + + + + + + + +