From 087715e538700e11bbc78e6acb8d1631fa93b182 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 10 Aug 2018 15:12:49 -0700 Subject: [PATCH] Reimplented svg rendering using cairo, and changed vmobject color model to allow for gradeints and strokes with opacities. Many errors associated with python 2 to python 3 conversion are likely still present at this point. --- camera/camera.py | 163 +++++++------- mobject/mobject.py | 2 +- mobject/svg/svg_mobject.py | 2 +- mobject/types/vectorized_mobject.py | 317 +++++++++++++++++----------- requirements.txt | 2 +- utils/iterables.py | 6 +- utils/tex_file_writing.py | 8 +- 7 files changed, 296 insertions(+), 204 deletions(-) diff --git a/camera/camera.py b/camera/camera.py index 848bcc24..d5775983 100644 --- a/camera/camera.py +++ b/camera/camera.py @@ -10,6 +10,7 @@ import time from PIL import Image from colour import Color from scipy.spatial.distance import pdist +import cairo from constants import * from mobject.types.image_mobject import AbstractImageMobject @@ -50,6 +51,7 @@ class Camera(object): # z_buff_func is only used if the flag above is set to True. # round z coordinate to nearest hundredth when comparring "z_buff_func": lambda m: np.round(m.get_center()[2], 2), + "cairo_line_width_multiple": 0.01, } def __init__(self, background=None, **kwargs): @@ -278,14 +280,25 @@ class Camera(object): # Methods associated with svg rendering - def get_aggdraw_canvas(self): - if not hasattr(self, "canvas") or not self.canvas: - self.reset_aggdraw_canvas() - return self.canvas - - def reset_aggdraw_canvas(self): - image = Image.fromarray(self.pixel_array, mode=self.image_mode) - self.canvas = aggdraw.Draw(image) + def get_cairo_context(self): + # TODO, make sure this isn't run too much + pw = self.get_pixel_width() + ph = self.get_pixel_height() + fw = self.get_frame_width() + fh = self.get_frame_height() + surface = cairo.ImageSurface.create_for_data( + self.pixel_array, + cairo.FORMAT_ARGB32, + pw, ph + ) + ctx = cairo.Context(surface) + ctx.scale(pw, ph) + ctx.set_matrix(cairo.Matrix( + fdiv(pw, fw), 0, + 0, -fdiv(ph, fh), + pw / 2, ph / 2, + )) + return ctx def display_multiple_vectorized_mobjects(self, vmobjects): if len(vmobjects) == 0: @@ -301,90 +314,87 @@ class Camera(object): self.display_multiple_non_background_colored_vmobjects(batch) def display_multiple_non_background_colored_vmobjects(self, vmobjects): - self.reset_aggdraw_canvas() - canvas = self.get_aggdraw_canvas() for vmobject in vmobjects: - self.display_vectorized(vmobject, canvas) - canvas.flush() + self.display_vectorized(vmobject) - def display_vectorized(self, vmobject, canvas=None): + def display_vectorized(self, vmobject): if vmobject.is_subpath: # Subpath vectorized mobjects are taken care # of by their parent return - canvas = canvas or self.get_aggdraw_canvas() - pen, fill = self.get_pen_and_fill(vmobject) - pathstring = self.get_pathstring(vmobject) - symbol = aggdraw.Symbol(pathstring) - self.draw_background_stroke(canvas, vmobject, symbol) - canvas.symbol((0, 0), symbol, pen, fill) + ctx = self.get_cairo_context() + self.set_cairo_context_path(ctx, vmobject) + self.apply_stroke(ctx, vmobject, background=True) + self.apply_fill(ctx, vmobject) + self.apply_stroke(ctx, vmobject) + ctx.new_path() + return self - def draw_background_stroke(self, canvas, vmobject, symbol): - bs_width = vmobject.get_background_stroke_width() - if bs_width == 0: - return - bs_rgb = vmobject.get_background_stroke_rgb() - bs_hex = rgb_to_hex(bs_rgb) - pen = aggdraw.Pen(bs_hex, bs_width) - canvas.symbol((0, 0), symbol, pen, None) + def set_cairo_context_path(self, ctx, vmobject): + for vmob in it.chain([vmobject], vmobject.get_subpath_mobjects()): + points = vmob.points + ctx.new_sub_path() + ctx.move_to(*points[0][:2]) + for triplet in zip(points[1::3], points[2::3], points[3::3]): + ctx.curve_to(*it.chain(*[ + point[:2] for point in triplet + ])) + if vmob.is_closed(): + ctx.close_path() + return self - def get_pen_and_fill(self, vmobject): - stroke_width = max(vmobject.get_stroke_width(), 0) - if stroke_width == 0: - pen = None + def set_cairo_context_color(self, ctx, rgbas, vmobject): + if len(rgbas) == 0: + # Use reversed rgb because cairo surface is + # encodes it in reverse order + ctx.set_source_rgba( + *rgbas[0][2::-1], rgbas[0][3] + ) else: - stroke_rgb = self.get_stroke_rgb(vmobject) - stroke_hex = rgb_to_hex(stroke_rgb) - pen = aggdraw.Pen(stroke_hex, stroke_width) + points = vmobject.get_gradient_start_and_end_points() + pat = cairo.LinearGradient(*it.chain(*[ + point[:2] for point in points + ])) + offsets = np.linspace(1, 0, len(rgbas)) + for rgba, offset in zip(rgbas, offsets): + pat.add_color_stop_rgba( + offset, *rgba[2::-1], rgba[3] + ) + ctx.set_source(pat) + return self - fill_opacity = int(self.rgb_max_val * vmobject.get_fill_opacity()) - if fill_opacity == 0: - fill = None - else: - fill_rgb = self.get_fill_rgb(vmobject) - fill_hex = rgb_to_hex(fill_rgb) - fill = aggdraw.Brush(fill_hex, fill_opacity) + def apply_fill(self, ctx, vmobject): + self.set_cairo_context_color( + ctx, self.get_fill_rgbas(vmobject), vmobject + ) + ctx.fill_preserve() + return self - return (pen, fill) + def apply_stroke(self, ctx, vmobject, background=False): + width = vmobject.get_stroke_width(background) + self.set_cairo_context_color( + ctx, + self.get_stroke_rgbas(vmobject, background=background), + vmobject + ) + ctx.set_line_width( + width * self.cairo_line_width_multiple + ) + ctx.stroke_preserve() + return self - def color_to_hex_l(self, color): - try: - return color.get_hex_l() - except: - return Color(BLACK).get_hex_l() + def get_stroke_rgbas(self, vmobject, background=False): + return vmobject.get_stroke_rgbas(background) - def get_stroke_rgb(self, vmobject): - return vmobject.get_stroke_rgb() - - def get_fill_rgb(self, vmobject): - return vmobject.get_fill_rgb() - - def get_pathstring(self, vmobject): - result = "" - for mob in [vmobject] + vmobject.get_subpath_mobjects(): - points = mob.points - # points = self.adjust_out_of_range_points(points) - if len(points) == 0: - continue - coords = self.points_to_pixel_coords(points) - coord_strings = coords.flatten().astype(str) - # Start new path string with M - coord_strings[0] = "M" + coord_strings[0] - # The C at the start of every 6th number communicates - # that the following 6 define a cubic Bezier - coord_strings[2::6] = ["C" + str(s) for s in coord_strings[2::6]] - # Possibly finish with "Z" - if vmobject.mark_paths_closed: - coord_strings[-1] = coord_strings[-1] + " Z" - result += " ".join(coord_strings) - return result + def get_fill_rgbas(self, vmobject): + return vmobject.get_fill_rgbas() def get_background_colored_vmobject_displayer(self): # Quite wordy to type out a bunch - long_name = "background_colored_vmobject_displayer" - if not hasattr(self, long_name): - setattr(self, long_name, BackgroundColoredVMobjectDisplayer(self)) - return getattr(self, long_name) + bcvd = "background_colored_vmobject_displayer" + if not hasattr(self, bcvd): + setattr(self, bcvd, BackgroundColoredVMobjectDisplayer(self)) + return getattr(self, bcvd) def display_multiple_background_colored_vmobject(self, cvmobjects): displayer = self.get_background_colored_vmobject_displayer() @@ -595,6 +605,7 @@ class Camera(object): return centered_space_coords +# TODO class BackgroundColoredVMobjectDisplayer(object): def __init__(self, camera): self.camera = camera diff --git a/mobject/mobject.py b/mobject/mobject.py index 397c1b81..2166762a 100644 --- a/mobject/mobject.py +++ b/mobject/mobject.py @@ -878,7 +878,7 @@ class Mobject(Container): self.add(self.copy()) n -= 1 curr += 1 - indices = curr * np.arange(curr + n) / (curr + n) + indices = curr * np.arange(curr + n) // (curr + n) new_submobjects = [] for index in indices: submob = self.submobjects[index] diff --git a/mobject/svg/svg_mobject.py b/mobject/svg/svg_mobject.py index 50fc3e51..8ca662d5 100644 --- a/mobject/svg/svg_mobject.py +++ b/mobject/svg/svg_mobject.py @@ -375,7 +375,7 @@ class VMobjectFromSVGPathstring(VMobject): numbers = string_to_numbers(coord_string) if len(numbers) % 2 == 1: numbers.append(0) - num_points = len(numbers) / 2 + num_points = len(numbers) // 2 result = np.zeros((num_points, self.dim)) result[:, :2] = np.array(numbers).reshape((num_points, 2)) return result diff --git a/mobject/types/vectorized_mobject.py b/mobject/types/vectorized_mobject.py index 9ec444c9..3ca59868 100644 --- a/mobject/types/vectorized_mobject.py +++ b/mobject/types/vectorized_mobject.py @@ -9,8 +9,11 @@ from utils.bezier import get_smooth_handle_points from utils.bezier import interpolate from utils.bezier import is_closed from utils.bezier import partial_bezier_points -from utils.color import color_to_rgb +from utils.color import color_to_rgba +from utils.color import interpolate_color from utils.iterables import make_even +from utils.iterables import tuplify +from utils.iterables import stretch_array_to_length class VMobject(Mobject): @@ -18,11 +21,21 @@ class VMobject(Mobject): "fill_color": None, "fill_opacity": 0.0, "stroke_color": None, + "stroke_opacity": 1.0, "stroke_width": DEFAULT_POINT_THICKNESS, # The purpose of background stroke is to have - # something that won't overlap the fill + # something that won't overlap the fill, e.g. + # For text against some textured background "background_stroke_color": BLACK, + "background_stroke_opacity": 1.0, "background_stroke_width": 0, + # When a color c is set, there will be a second color + # computed based on interpolating c to WHITE by with + # gradient_to_white_factor, and the display will + # gradient to this secondary color in the direction + # of color_gradient_direction. + "color_gradient_direction": UL, + "gradient_to_white_factor": 0.2, # Indicates that it will not be displayed, but # that it should count in parent mobject's path "is_subpath": False, @@ -39,95 +52,95 @@ class VMobject(Mobject): # Colors def init_colors(self): - self.set_style_data( - fill_color=self.fill_color or self.color, - fill_opacity=self.fill_opacity, - stroke_color=self.stroke_color or self.color, - stroke_width=self.stroke_width, - background_stroke_color=self.background_stroke_color, - background_stroke_width=self.background_stroke_width, + self.set_fill( + color=self.fill_color or self.color, + opacity=self.fill_opacity, family=self.propagate_style_to_family ) + self.set_stroke( + color=self.stroke_color or self.color, + width=self.stroke_width, + opacity=self.stroke_opacity, + family=self.propagate_style_to_family + ) + self.set_background_stroke( + color=self.background_stroke_color, + width=self.background_stroke_width, + opacity=self.background_stroke_opacity, + family=self.propagate_style_to_family, + ) return self - def set_family_attr(self, attr, value): - for mob in self.submobject_family(): - setattr(mob, attr, value) + def get_rgbas_array(self, color=None, opacity=None): + """ + First arg can be either a color, or a tuple/list of colors. + Likewise, opacity can either be a float, or a tuple of floats. + If self.gradient_to_white_factor is not zero, and only + one color was passed in, a second slightly light color + will automatically be added for the gradient + """ + if color is None: + color = self.color + colors = list(tuplify(color)) + opacities = list(tuplify(opacity)) + g2w_factor = self.get_gradient_to_white_factor() + if g2w_factor != 0 and len(colors) == 1: + lighter_color = interpolate_color( + colors[0], WHITE, g2w_factor + ) + colors.append(lighter_color) - def set_style_data(self, - fill_color=None, - fill_opacity=None, - stroke_color=None, - stroke_width=None, - background_stroke_color=None, - background_stroke_width=None, - family=True - ): - kwargs = { - "fill_color": fill_color, - "fill_opacity": fill_opacity, - "stroke_color": stroke_color, - "stroke_width": stroke_width, - "background_stroke_color": background_stroke_color, - "background_stroke_width": background_stroke_width, - "family": family, - } - for key in "fill_color", "stroke_color", "background_stroke_color": - # Instead of setting a self.fill_color attr, - # set a numerical self.fill_rgb to make - # interpolation easier - key_with_rgb = key.replace("color", "rgb") - color = kwargs[key] - if color is not None: - setattr(self, key_with_rgb, color_to_rgb(color)) - for key in "fill_opacity", "stroke_width", "background_stroke_width": - if kwargs[key] is not None: - setattr(self, key, kwargs[key]) - if family: - for mob in self.submobjects: - mob.set_style_data(**kwargs) - return self + return np.array([ + color_to_rgba(c, o) + for c, o in zip(*make_even(colors, opacities)) + ]) def set_fill(self, color=None, opacity=None, family=True): - return self.set_style_data( - fill_color=color, - fill_opacity=opacity, - family=family - ) + if opacity is None: + opacity = self.get_fill_opacity() + self.fill_rgbas = self.get_rgbas_array(color, opacity) + if family: + for submobject in self.submobjects: + submobject.set_fill(color, opacity, family) + return self - def set_stroke(self, color=None, width=None, family=True): - return self.set_style_data( - stroke_color=color, - stroke_width=width, - family=family - ) + def set_stroke(self, color=None, width=None, opacity=None, + background=False, family=True): + if opacity is None: + opacity = self.get_stroke_opacity(background) - def set_background_stroke(self, color=None, width=None, family=True): - return self.set_style_data( - background_stroke_color=color, - background_stroke_width=width, - family=family - ) + if background: + array_name = "background_stroke_rgbas" + width_name = "background_stroke_width" + else: + array_name = "stroke_rgbas" + width_name = "stroke_width" + rgbas = self.get_rgbas_array(color, opacity) + setattr(self, array_name, rgbas) + if width is not None: + setattr(self, width_name, width) + if family: + for submobject in self.submobjects: + submobject.set_stroke( + color, width, opacity, background, family + ) + return self + + def set_background_stroke(self, **kwargs): + kwargs["background"] = True + self.set_stroke(**kwargs) + return self def set_color(self, color, family=True): - self.set_style_data( - stroke_color=color, - fill_color=color, - family=family - ) - self.color = color + self.set_fill(color, family=family) + self.set_stroke(color, family=family) return self def match_style(self, vmobject): - self.set_style_data( - fill_color=vmobject.get_fill_color(), - fill_opacity=vmobject.get_fill_opacity(), - stroke_color=vmobject.get_stroke_color(), - stroke_width=vmobject.get_stroke_width(), - background_stroke_color=vmobject.get_background_stroke_color(), - background_stroke_width=vmobject.get_background_stroke_width(), - family=False - ) + for a_name in ["fill_rgbas", "stroke_rgbas", "background_stroke_rgbas"]: + setattr(self, np.array(get_attr(vmobject, a_name))) + self.stroke_width = vmobject.stroke_width + self.background_stroke_width = vmobject.background_stroke_width # Does its best to match up submobject lists, and # match styles accordingly @@ -151,52 +164,99 @@ class VMobject(Mobject): ) return self - def get_fill_rgb(self): - return np.clip(self.fill_rgb, 0, 1) + def get_fill_rgbas(self): + return np.clip(self.fill_rgbas, 0, 1) def get_fill_color(self): - try: - self.fill_rgb = np.clip(self.fill_rgb, 0.0, 1.0) - return Color(rgb=self.fill_rgb) - except: - return Color(WHITE) + """ + If there are multiple colors (for gradient) + this returns the first one + """ + return self.get_fill_colors()[0] def get_fill_opacity(self): - return np.clip(self.fill_opacity, 0, 1) + """ + If there are multiple opacities, this returns the + first + """ + return self.get_fill_opacities()[0] - def get_stroke_rgb(self): - return np.clip(self.stroke_rgb, 0, 1) + def get_fill_colors(self): + return [ + Color(rgb=rgba[:3]) + for rgba in self.get_fill_rgbas() + ] - def get_stroke_color(self): - try: - self.stroke_rgb = np.clip(self.stroke_rgb, 0, 1) - return Color(rgb=self.stroke_rgb) - except: - return Color(WHITE) + def get_fill_opacities(self): + return self.get_fill_rgbas()[:, 3] - def get_stroke_width(self): - return max(0, self.stroke_width) + def get_stroke_rgbas(self, background=False): + if background: + rgbas = self.background_stroke_rgbas + else: + rgbas = self.stroke_rgbas + return np.clip(rgbas, 0, 1) - def get_background_stroke_rgb(self): - return np.clip(self.background_stroke_rgb, 0, 1) + def get_stroke_color(self, background=False): + return self.get_stroke_colors(background)[0] - def get_background_stroke_color(self): - try: - self.background_stroke_rgb = np.clip( - self.background_stroke_rgb, 0, 1 - ) - return Color(rgb=self.background_stroke_rgb) - except: - return Color(WHITE) + def get_stroke_width(self, background=False): + if background: + width = self.background_stroke_width + else: + width = self.stroke_width + return max(0, width) - def get_background_stroke_width(self): - return max(0, self.background_stroke_width) + def get_stroke_opacity(self, background=False): + return self.get_stroke_opacities(background)[0] + + def get_stroke_colors(self, background=False): + return [ + Color(rgb=rgba[:3]) + for rgba in self.get_stroke_rgbas(background) + ] + + def get_stroke_opacities(self, background=False): + return self.get_stroke_rgbas(background)[:, 3] def get_color(self): - if self.fill_opacity == 0: + if np.all(self.get_fill_opacities() == 0): return self.get_stroke_color() return self.get_fill_color() + def set_color_gradient_direction(self, direction, family=True): + direction = np.array(direction) + if family: + for submob in self.submobject_family(): + submob.color_gradient_direction = direction + else: + self.color_gradient_direction = direction + return self + + def set_gradient_to_white_factor(self, factor, family=True): + if family: + for submob in self.submobject_family(): + submob.gradient_to_white_factor = factor + else: + self.gradient_to_white_factor = factor + return self + + def get_color_gradient_direction(self): + return np.array(self.color_gradient_direction) + + def get_gradient_to_white_factor(self): + return self.gradient_to_white_factor + + def get_gradient_start_and_end_points(self): + direction = self.get_color_gradient_direction() + c = self.get_center() + bases = np.array([ + self.get_edge_center(vect) - c + for vect in [RIGHT, UP, OUT] + ]).transpose() + offset = np.dot(bases, direction) + return (c + offset, c - offset) + def color_using_background_image(self, background_image_file): self.background_image_file = background_image_file self.set_color(WHITE) @@ -357,7 +417,7 @@ class VMobject(Mobject): return bezier(self.points[3 * n:3 * n + 4]) def get_num_anchor_points(self): - return (len(self.points) - 1) / 3 + 1 + return (len(self.points) - 1) // 3 + 1 def point_from_proportion(self, alpha): num_cubics = self.get_num_anchor_points() - 1 @@ -379,12 +439,13 @@ class VMobject(Mobject): return self.get_anchors() # Alignment - def align_points(self, mobject): - Mobject.align_points(self, mobject) - is_subpath = self.is_subpath or mobject.is_subpath - self.is_subpath = mobject.is_subpath = is_subpath - mark_closed = self.mark_paths_closed and mobject.mark_paths_closed - self.mark_paths_closed = mobject.mark_paths_closed = mark_closed + def align_points(self, vmobject): + Mobject.align_points(self, vmobject) + self.align_rgbas(vmobject) + is_subpath = self.is_subpath or vmobject.is_subpath + self.is_subpath = vmobject.is_subpath = is_subpath + mark_closed = self.mark_paths_closed and vmobject.mark_paths_closed + self.mark_paths_closed = vmobject.mark_paths_closed = mark_closed return self def align_points_with_larger(self, larger_mobject): @@ -411,7 +472,7 @@ class VMobject(Mobject): # and its value tells you the appropriate index of # the smaller curve. index_allocation = (np.arange(curr + n - 1) * - num_curves) / (curr + n - 1) + num_curves) // (curr + n - 1) for index in range(num_curves): curr_bezier_points = self.points[3 * index:3 * index + 4] num_inter_curves = sum(index_allocation == index) @@ -427,6 +488,19 @@ class VMobject(Mobject): self.set_points(points) return self + def align_rgbas(self, vmobject): + attrs = ["fill_rgbas", "stroke_rgbas", "background_stroke_rgbas"] + for attr in attrs: + a1 = getattr(self, attr) + a2 = getattr(vmobject, attr) + if len(a1) > len(a2): + new_a2 = stretch_array_to_length(a2, len(a1)) + setattr(vmobject, attr, new_a2) + elif len(a2) > len(a1): + new_a1 = stretch_array_to_length(a1, len(a2)) + setattr(self, attr, new_a1) + return self + def get_point_mobject(self, center=None): if center is None: center = self.get_center() @@ -439,12 +513,13 @@ class VMobject(Mobject): def interpolate_color(self, mobject1, mobject2, alpha): attrs = [ - "fill_rgb", - "fill_opacity", - "stroke_rgb", + "fill_rgbas", + "stroke_rgbas", + "background_stroke_rgbas", "stroke_width", - "background_stroke_rgb", "background_stroke_width", + "color_gradient_direction", + "gradient_to_white_factor", ] for attr in attrs: setattr(self, attr, interpolate( @@ -547,6 +622,6 @@ class DashedMobject(VMobject): for i in range(self.dashes_num): a = ((1 + buff) * i) / self.dashes_num b = 1 - ((1 + buff) * (self.dashes_num - 1 - i)) / self.dashes_num - dash = VMobject(color=self.color) + dash = VMobject(color=self.get_color()) dash.pointwise_become_partial(mobject, a, b) self.submobjects.append(dash) diff --git a/requirements.txt b/requirements.txt index 688dd407..c8c05da1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,4 @@ progressbar==2.5 scipy==1.1.0 tqdm==4.24.0 opencv-python==3.4.2.17 -git+https://github.com/scottopell/aggdraw-64bits@c95aac4369038706943fd0effb7d888683860e5a#egg=aggdraw +pycairo==1.17.1 diff --git a/utils/iterables.py b/utils/iterables.py index e5fed77f..5706d3cc 100644 --- a/utils/iterables.py +++ b/utils/iterables.py @@ -72,7 +72,7 @@ def tuplify(obj): return (obj,) try: return tuple(obj) - except: + except TypeError: return (obj,) @@ -90,8 +90,8 @@ def make_even(iterable_1, iterable_2): list_1, list_2 = list(iterable_1), list(iterable_2) length = max(len(list_1), len(list_2)) return ( - [list_1[(n * len(list_1)) / length] for n in range(length)], - [list_2[(n * len(list_2)) / length] for n in range(length)] + [list_1[(n * len(list_1)) // length] for n in range(length)], + [list_2[(n * len(list_2)) // length] for n in range(length)] ) diff --git a/utils/tex_file_writing.py b/utils/tex_file_writing.py index af58ebb2..f41ff1ad 100644 --- a/utils/tex_file_writing.py +++ b/utils/tex_file_writing.py @@ -1,11 +1,17 @@ import os +import hashlib + from constants import TEX_DIR from constants import TEX_TEXT_TO_REPLACE def tex_hash(expression, template_tex_file): - return str(hash(expression + template_tex_file)) + id_str = str(expression + template_tex_file) + hasher = hashlib.sha256() + hasher.update(id_str.encode()) + # Truncating at 16 bytes for cleanliness + return hasher.hexdigest()[:16] def tex_to_svg_file(expression, template_tex_file):