Completed Tesla Cybertruck in Matplotlib

My name is Ted Petrou, founder of Dunder Data, and in this tutorial you will learn how to create the new Tesla Cybertruck using Matplotlib. I was inspired by the image below which was originally created by Lynn Fisher (without Matplotlib).

Before going into detail, let’s jump to the results. Here is the completed recreation of the Tesla Cybertruck that drives off the screen.

Tutorial#

A tutorial now follows containing all the steps that creates a Tesla Cybertruck that drives. It covers the following topics:

  • Figure and Axes setup
  • Adding shapes
  • Color gradients
  • Animation

Understanding these topics should give you enough to start animating your own figures in Matplotlib. This tutorial is not suited for those with no Matplotlib experience. You need to understand the relationship between the Figure and Axes and how to use the object-oriented interface of Matplotlib.

Figure and Axes setup#

We first create a Matplotlib Figure without any Axes (the plotting surface). The function create_axes adds an Axes to the Figure, sets the x-limits to be twice the y-limits (to match the ratio of the figure dimensions (16 x 8)), fills in the background with two different dark colors using fill_between, and adds grid lines to make it easier to plot the objects in the exact place you desire. Set the draft parameter to False when you want to remove the grid lines, tick marks, and tick labels.

import numpy as np
import matplotlib.pyplot as plt

fig = plt.Figure(figsize=(16, 8))


def create_axes(draft=True):
    ax = fig.add_subplot()
    ax.grid(True)
    ax.set_ylim(0, 1)
    ax.set_xlim(0, 2)
    ax.fill_between(x=[0, 2], y1=0.36, y2=1, color="black")
    ax.fill_between(x=[0, 2], y1=0, y2=0.36, color="#101115")
    if not draft:
        ax.grid(False)
        ax.axis("off")


create_axes()
fig

png

Shapes in Matplotlib#

Most of the Cybertruck is composed of shapes (patches in Matplotlib terminology) - circles, rectangles, and polygons. These shapes are available in the patches Matplotlib module. After importing, we instantiate single instances of these patches and then call the add_patch method to add the patch to the Axes.

For the Cybertruck, I used three patches, Polygon, Rectangle, and Circle. They each have different parameters available in their constructor. I first constructed the body of the car as four polygons. Two other polygons were used for the rims. Each polygon is provided a list of x, y coordinates where the corner points are located. Matplotlib connects all the points in the order given and fills it in with the provided color.

Notice how the Axes is retrieved as the first line of the function. This is used throughout the tutorial.

from matplotlib.patches import Polygon, Rectangle, Circle


def create_body():
    ax = fig.axes[0]
    top = Polygon([[0.62, 0.51], [1, 0.66], [1.6, 0.56]], color="#DCDCDC")
    windows = Polygon(
        [[0.74, 0.54], [1, 0.64], [1.26, 0.6], [1.262, 0.57]], color="black"
    )
    windows_bottom = Polygon(
        [[0.8, 0.56], [1, 0.635], [1.255, 0.597], [1.255, 0.585]], color="#474747"
    )
    base = Polygon(
        [
            [0.62, 0.51],
            [0.62, 0.445],
            [0.67, 0.5],
            [0.78, 0.5],
            [0.84, 0.42],
            [1.3, 0.423],
            [1.36, 0.51],
            [1.44, 0.51],
            [1.52, 0.43],
            [1.58, 0.44],
            [1.6, 0.56],
        ],
        color="#1E2329",
    )
    left_rim = Polygon(
        [
            [0.62, 0.445],
            [0.67, 0.5],
            [0.78, 0.5],
            [0.84, 0.42],
            [0.824, 0.42],
            [0.77, 0.49],
            [0.674, 0.49],
            [0.633, 0.445],
        ],
        color="#373E48",
    )
    right_rim = Polygon(
        [
            [1.3, 0.423],
            [1.36, 0.51],
            [1.44, 0.51],
            [1.52, 0.43],
            [1.504, 0.43],
            [1.436, 0.498],
            [1.364, 0.498],
            [1.312, 0.423],
        ],
        color="#4D586A",
    )
    ax.add_patch(top)
    ax.add_patch(windows)
    ax.add_patch(windows_bottom)
    ax.add_patch(base)
    ax.add_patch(left_rim)
    ax.add_patch(right_rim)


create_body()
fig

png

Tires#

I used three Circle patches for each of the tires. You must provide the center and radius. For the innermost circles (the “spokes”), I’ve set the zorder to 99. The zorder determines the order of how plotting objects are layered on top of each other. The higher the number, the higher up on the stack of layers the object will be plotted. During the next step, we will draw some rectangles through the tires and they need to be plotted underneath these spokes.

def create_tires():
    ax = fig.axes[0]
    left_tire = Circle((0.724, 0.39), radius=0.075, color="#202328")
    right_tire = Circle((1.404, 0.39), radius=0.075, color="#202328")
    left_inner_tire = Circle((0.724, 0.39), radius=0.052, color="#15191C")
    right_inner_tire = Circle((1.404, 0.39), radius=0.052, color="#15191C")
    left_spoke = Circle((0.724, 0.39), radius=0.019, color="#202328", zorder=99)
    right_spoke = Circle((1.404, 0.39), radius=0.019, color="#202328", zorder=99)
    left_inner_spoke = Circle((0.724, 0.39), radius=0.011, color="#131418", zorder=99)
    right_inner_spoke = Circle((1.404, 0.39), radius=0.011, color="#131418", zorder=99)

    ax.add_patch(left_tire)
    ax.add_patch(right_tire)
    ax.add_patch(left_inner_tire)
    ax.add_patch(right_inner_tire)
    ax.add_patch(left_spoke)
    ax.add_patch(right_spoke)
    ax.add_patch(left_inner_spoke)
    ax.add_patch(right_inner_spoke)


create_tires()
fig

png

Axles#

I used the Rectangle patch to represent the two ‘axles’ (this isn’t the correct term, but you’ll see what I mean) going through the tires. You must provide a coordinate for the lower left corner, a width, and a height. You can also provide it an angle (in degrees) to control its orientation. Notice that they go under the spokes plotted from above. This is due to their lower zorder.

def create_axles():
    ax = fig.axes[0]
    left_left_axle = Rectangle(
        (0.687, 0.427), width=0.104, height=0.005, angle=315, color="#202328"
    )
    left_right_axle = Rectangle(
        (0.761, 0.427), width=0.104, height=0.005, angle=225, color="#202328"
    )
    right_left_axle = Rectangle(
        (1.367, 0.427), width=0.104, height=0.005, angle=315, color="#202328"
    )
    right_right_axle = Rectangle(
        (1.441, 0.427), width=0.104, height=0.005, angle=225, color="#202328"
    )

    ax.add_patch(left_left_axle)
    ax.add_patch(left_right_axle)
    ax.add_patch(right_left_axle)
    ax.add_patch(right_right_axle)


create_axles()
fig

png

Other details#

The front bumper, head light, tail light, door and window lines are added below. I used regular Matplotlib lines for some of these. Those lines are not patches and get added directly to the Axes without any other additional method.

def create_other_details():
    ax = fig.axes[0]
    # other details
    front = Polygon(
        [[0.62, 0.51], [0.597, 0.51], [0.589, 0.5], [0.589, 0.445], [0.62, 0.445]],
        color="#26272d",
    )
    front_bottom = Polygon(
        [[0.62, 0.438], [0.58, 0.438], [0.58, 0.423], [0.62, 0.423]], color="#26272d"
    )
    head_light = Polygon(
        [[0.62, 0.51], [0.597, 0.51], [0.589, 0.5], [0.589, 0.5], [0.62, 0.5]],
        color="aqua",
    )
    step = Polygon(
        [[0.84, 0.39], [0.84, 0.394], [1.3, 0.397], [1.3, 0.393]], color="#1E2329"
    )

    # doors
    ax.plot([0.84, 0.84], [0.42, 0.523], color="black", lw=0.5)
    ax.plot([1.02, 1.04], [0.42, 0.53], color="black", lw=0.5)
    ax.plot([1.26, 1.26], [0.42, 0.54], color="black", lw=0.5)
    ax.plot([0.84, 0.85], [0.523, 0.547], color="black", lw=0.5)
    ax.plot([1.04, 1.04], [0.53, 0.557], color="black", lw=0.5)
    ax.plot([1.26, 1.26], [0.54, 0.57], color="black", lw=0.5)

    # window lines
    ax.plot([0.87, 0.88], [0.56, 0.59], color="black", lw=1)
    ax.plot([1.03, 1.04], [0.56, 0.63], color="black", lw=0.5)

    # tail light
    tail_light = Circle((1.6, 0.56), radius=0.007, color="red", alpha=0.6)
    tail_light_center = Circle((1.6, 0.56), radius=0.003, color="yellow", alpha=0.6)
    tail_light_up = Polygon(
        [[1.597, 0.56], [1.6, 0.6], [1.603, 0.56]], color="red", alpha=0.4
    )
    tail_light_right = Polygon(
        [[1.6, 0.563], [1.64, 0.56], [1.6, 0.557]], color="red", alpha=0.4
    )
    tail_light_down = Polygon(
        [[1.597, 0.56], [1.6, 0.52], [1.603, 0.56]], color="red", alpha=0.4
    )

    ax.add_patch(front)
    ax.add_patch(front_bottom)
    ax.add_patch(head_light)
    ax.add_patch(step)
    ax.add_patch(tail_light)
    ax.add_patch(tail_light_center)
    ax.add_patch(tail_light_up)
    ax.add_patch(tail_light_right)
    ax.add_patch(tail_light_down)


create_other_details()
fig

png

Color gradients for the head light beam#

The head light beam has a distinct color gradient that dissipates into the night sky. This is challenging to complete. I found an excellent answer on Stack Overflow from user Joe Kington on how to do this. We begin by using the imshow function which creates images from 3-dimensional arrays. Our image will simply be a rectangle of colors.

We create a 1 x 100 x 4 array that represents 1 row by 100 columns of points of RGBA (red, green, blue, alpha) values. Every point is given the same red, green, and blue values of (0, 1, 1) which represents the color ‘aqua’. The alpha value represents opacity and ranges between 0 and 1 with 0 being completely transparent (invisible) and 1 being opaque. We would like the opacity to decrease as the light extends further from the head light (that is further to the left). The NumPy linspace function is used to create an array of 100 numbers increasing linearly from 0 to 1. This array will be set as the alpha values.

The extent parameter defines the rectangular region where the image will be shown. The four values correspond to xmin, xmax, ymin, and ymax. The 100 alpha values will be mapped to this region beginning from the left. The array of alphas begins at 0, which means that the very left of this rectangular region will be transparent. The opacity will increase moving to the right-side of the rectangle where it eventually reaches 1.

import matplotlib.colors as mcolors


def create_headlight_beam():
    ax = fig.axes[0]
    z = np.empty((1, 100, 4), dtype=float)
    rgb = mcolors.colorConverter.to_rgb("aqua")
    alphas = np.linspace(0, 1, 100)
    z[:, :, :3] = rgb
    z[:, :, -1] = alphas
    im = ax.imshow(z, extent=[0.3, 0.589, 0.501, 0.505], zorder=1)


create_headlight_beam()
fig

png

Headlight Cloud#

The cloud of points surrounding the headlight beam is even more challenging to complete. This time, a 100 x 100 grid of points was used to control the opacity. The opacity is directly proportional to the vertical distance from the center beam. Additionally, if a point was outside of the diagonal of the rectangle defined by extent, its opacity was set to 0.

def create_headlight_cloud():
    ax = fig.axes[0]
    z2 = np.empty((100, 100, 4), dtype=float)
    rgb = mcolors.colorConverter.to_rgb("aqua")
    z2[:, :, :3] = rgb
    for j, x in enumerate(np.linspace(0, 1, 100)):
        for i, y in enumerate(np.abs(np.linspace(-0.2, 0.2, 100))):
            if x * 0.2 > y:
                z2[i, j, -1] = 1 - (y + 0.8) ** 2
            else:
                z2[i, j, -1] = 0
    im2 = ax.imshow(z2, extent=[0.3, 0.65, 0.45, 0.55], zorder=1)


create_headlight_cloud()
fig

png

Creating a Function to Draw the Car#

All of our work from above can be placed in a single function that draws the car. This will be used when initializing our animation. Notice, that the first line of the function clears the Figure, which removes our Axes. If we don’t clear the Figure, then we will keep adding more and more Axes each time this function is called. Since this is our final product, we set draft to False.

def draw_car():
    fig.clear()
    create_axes(draft=False)
    create_body()
    create_tires()
    create_axles()
    create_other_details()
    create_headlight_beam()
    create_headlight_beam()


draw_car()
fig

png

Animation#

Animation in Matplotlib is fairly straightforward. You must create a function that updates the position of the objects in your figure for each frame. This function is called repeatedly for each frame.

In the update function below, we loop through each patch, line, and image in our Axes and reduce the x-value of each plotted object by .015. This has the effect of moving the truck to the left. The trickiest part was changing the x and y values for the rectangular tire ‘axles’ so that it appeared that the tires were rotating. Some basic trigonometry helps calculate this.

Implicitly, Matplotlib passes the update function the frame number as an integer as the first argument. We accept this input as the parameter frame_number. We only use it in one place, and that is to do nothing during the first frame.

Finally, the FuncAnimation class from the animation module is used to construct the animation. We provide it our original Figure, the function to update the Figure (update), a function to initialize the Figure (draw_car), the total number of frames, and any extra arguments used during update (fargs).

from matplotlib.animation import FuncAnimation


def update(frame_number, x_delta, radius, angle):
    if frame_number == 0:
        return
    ax = fig.axes[0]
    for patch in ax.patches:
        if isinstance(patch, Polygon):
            arr = patch.get_xy()
            arr[:, 0] -= x_delta
        elif isinstance(patch, Circle):
            x, y = patch.get_center()
            patch.set_center((x - x_delta, y))
        elif isinstance(patch, Rectangle):
            xd_old = -np.cos(np.pi * patch.angle / 180) * radius
            yd_old = -np.sin(np.pi * patch.angle / 180) * radius
            patch.angle += angle
            xd = -np.cos(np.pi * patch.angle / 180) * radius
            yd = -np.sin(np.pi * patch.angle / 180) * radius
            x = patch.get_x()
            y = patch.get_y()
            x_new = x - x_delta + xd - xd_old
            y_new = y + yd - yd_old
            patch.set_x(x_new)
            patch.set_y(y_new)

    for line in ax.lines:
        xdata = line.get_xdata()
        line.set_xdata(xdata - x_delta)

    for image in ax.images:
        extent = image.get_extent()
        extent[0] -= x_delta
        extent[1] -= x_delta


animation = FuncAnimation(
    fig, update, init_func=draw_car, frames=110, repeat=False, fargs=(0.015, 0.052, 4)
)

Save animation#

Finally, we can save the animation as an mp4 file (you must have ffmpeg installed for this to work). We set the frames-per-second (fps) to 30. From above, the total number of frames is 110 (enough to move the truck off the screen) so the video will last nearly four seconds (110 / 30).

animation.save("tesla_animate.mp4", fps=30, bitrate=3000)

Continue Animating#

I encourage you to add more components to your Cybertruck animation to personalize the creation. I suggest encapsulating each addition with a function as done in this tutorial.