Animating a Galton board in Python and manim

What is manim

Manim is a Python library for creating mathematical animations. There are two versions of the library. One is community edition, a fork of the original library that is being developed by the community. The second one is ManimGL, the version Sanderson is working on. In general, the versions are incompatible, so if you look at the sources or look for examples, pay attention to this. You can read the details Here. For beginners it is better to use the community edition, which is what I did.

Why Galton's board

The videos from 3blue1brown are cool, but besides the videos themselves, I was interested in how they were made. A quick Google showed that the videos are made using Python code, which is interesting in itself. I can write code, so I wanted to try it myself. But what exactly? There were a lot of math videos, and I’m not a mathematician to make math videos. After playing around with drawing graphs, I decided to make an animation of Galton's board. Wiki definition – Galton's board – a device invented by the English scientist Francis Galton and intended to demonstrate the central limit theorem. The balls fall from above, obstacles are made in a checkerboard pattern, at each obstacle the ball has an equal chance of bouncing to the left or to the right, and so on in each row. This is what it looks like in the screenshot, there is a GIF under the spoiler.

Galton's board

Galton's board

GIF with animation
Galton's board

Galton's board

The animation looks much better in the video.

A small disclaimer, I have not written anything at all in Python before, I ask professional Pythonists to understand and forgive any possible clumsiness.

Installation and the simplest scene

Everything is installed as usual, here it is instructions page, but there is a subtlety. LaTex is listed as optional, but it's better to install it right away. If there is no latek (and it is needed, which is not always the case), then during the build an uninformative error appears. More precisely, a big bunch of errors, one of which says about latex. Details about rendering text and formulas here, very briefly, manim can render text and formulas in two different ways and latex is not always needed. As a result, it’s easier to install it right away than to figure out exactly how a specific object will be rendered.

First, a simple example. Custom visit neighbors printing “Hello world” was not invented by us, and it will not end by us, so HelloWorld first.

Create a file scene.py with this content

from manim import *

class HelloWorld(Scene):
    def construct(self):

        helloWorld = Text("Hello World")
        self.play(FadeIn(helloWorld, run_time = 3))

On the command line we write manim scene.py HelloWorld -pqm –flush_cache and watch the video.

To create animation you need a class with a method constructthe class inherits from Scene. All animations should be in this method. You can, of course, create auxiliary methods, classes and generally anything you like, but the animations themselves must be in the method construct. That's all, as you can see, it's very simple.

What do command line options mean? WITH HelloWorld And scene.py everything is obvious. The -p flag means preview so that the video opens automatically after rendering. Flag qm – quality medium (1280×720 30FPS), possible quality options from low to high – l|m|h|p|k. Flag –flush_cache clears the cache. Why do you need to clear the cache? I had manim glitch periodically, when after small changes in the scene the video did not change. After some experiments and google I started using –flush_cache and the problem went away. It was a typical Heisenbug that came and went. You won't necessarily have one, but it happens. Complete list of flags here.

Now a little about the coordinate system and the size of the scene. By default, the scene size is 8 units in height and 16 and 2/9 in width. The origin is in the middle. For clarity, let’s change our HelloWorld as in the code below, and at the same time draw a bounding box for the text, this can be useful for placing objects on the stage. Let's also paint the background gray, this will make the size of the scene clearer. The distance between the points is 1 unit, there is a little space on the left and right, and the points above and below are exactly along the edges.

from manim import *

class HelloWorld(Scene):
    def construct(self):

        self.camera.background_color = GRAY

        for x in range(-7, 8):
            for y in range(-4, 5):
                dot = Dot(np.array([x, y, 0]), radius=0.05)
                self.add(dot) 

        ax = Axes(x_range=[-7, 7], y_range=[-4, 4], x_length=14, y_length=8)
        self.add(ax)

        helloWorld = Text("Hello World")

        box = SurroundingRectangle(helloWorld, color=YELLOW)
        self.add(box)

        self.play(FadeIn(helloWorld, run_time = 3))

This is what it looks like, albeit a little scary, but everything is clear with the dimensions. I specifically took a screenshot of the player so that you could see where the background of the stage is and where the background of the player itself is. It seems like a small thing, but the background of the scene is black by default and they merge. Because of this, it may not be clear where exactly the boundaries of the scene are.

Coordinate system in manim

Coordinate system in manim

Galton's board

The main idea is to draw in advance for each ball its own trajectory with bounces to the right/left on obstacles and move the ball along it in each frame. You also need the obstacles themselves, a general counter and counters for each column. Sounds simple, but there were some pitfalls. Next I will show the main points that are worth paying attention to.

Static objects

I made the counters for each column into a table, there are tables in manim several different. I had a table with integers, so I used IntegerTable. The code is below.

    def createTable(self):
        table = IntegerTable(
            [[0, 0, 0, 0, 0, 0, 0, 0],],
            line_config={"stroke_width": 1, "color": YELLOW},
            include_outer_lines=False
            )
        table.scale(.5)
        table.shift(DOWN * 3.7).shift(LEFT * 3)

        return table

Pay attention to the challenges table.scale And table.shift. To change the default size scaleto change the default position on the stage shift. The constants DOWN, LEFT (and other directions, there are diagonals) are vectors like these

DOWN: Vector3D = np.array((0.0, -1.0, 0.0))
UL: Vector3D = UP + LEFT

The general counter (Items count: N) is created like this

    def createCounter(self):
        counter = Integer(0).shift(RIGHT * 4).shift(DOWN * .6)
        text = Text('Items count:', font_size = 28)
        text.next_to(counter, LEFT)

        return VGroup(counter, text)  

pay attention to text.next_to, this is how objects are aligned in manim. The method creates two objects and combines them into a group. In the future, you can work with this group as with one object. This is convenient and will come in handy in the future.

The “board” itself of hexagons is drawn in a trivial manner; you just need to level it so that there is space left for the columns and counters below.

Code inside the method construct, which calls methods to create static objects and draws them to the stage. Objects appear on the stage through FadeIn animation (there is also FadeOut). You can simply add them to the scene, they will appear immediately, but with animation they look more beautiful. If you want everything to appear at the same time, you can combine all objects into a VGroup.

        table = self.createTable()
        counter = self.createCounter()
        hexagons = self.createHexagons()
		
		self.play(FadeIn(hexagons, run_time = 1))
        self.play(FadeIn(table, run_time = 1))
        self.play(FadeIn(counter, run_time = 1))

        #group = VGroup(hexagons, table, counter)
        #self.play(FadeIn(group, run_time = 1))

        self.wait(3)

Trajectories

The life of a ball in a Galton board is simple – it appears above the stage and falls down, deflecting to the right or left when it hits an obstacle. As a result, fall into a specific “glass” at a certain place in the “glass”.

To calculate the trajectory you need to know

  • starting point (one and does not change, set in the config)

  • points of impact on obstacles (many and depend on the size and coordinates of the obstacles), must be calculated based on the size and coordinates of the hexagons

  • place in the “glass” – depends on how many balls are already in the “glass”, to calculate you need to know how many balls have already fallen into the “glass”

To update the values ​​of the necessary counters at the right moments, and also so that the ball appears at the right time, you need to know

  • frame number when the ball appears and begins to fall, in other words, when the ball begins to move along the trajectory

  • frame number when the ball fell into the glass to increase the total counter

  • the number of the “glass” where the ball fell in order to increase the count of balls in a specific “glass”

I made a special class for the ball that stores (almost) everything listed above

class Item:
    circle = None
    path = None
    startFrame = 0
    stackIndex = 0
    isActive = True

circle – manim object to display on the stage, created equally for all balls

circle = Circle(radius=circleRadius, color=GREEN, fill_opacity=1)

path – the trajectory of the ball, created from pieces of lines and curves like this (code strongly simplified, leaving the main thing)

    path = Line(firstDot, nextDot, stroke_width=1)
	for (some condition):
		pathTmp = ArcBetweenPoints(previousDot, nextDot, angle=-PI/2, stroke_width=1)
		path.append_vectorized_mobject(pathTmp)

And the main thing here is path.append_vectorized_mobject(pathTmp). A trajectory requires one continuous curve and this method allows you to assemble a continuous curve from pieces.

This is what the trajectories look like, the first part is the same for everyone, the middle is different for everyone, and the last part is different for everyone (each ball should have its own place in the “glass”).

Visualization of ball trajectories

Visualization of ball trajectories

Animation

Animation in manim is easy to do; there was already an example with FadeIn. Here is another example – a smooth change of text color, done literally in one line. In general, you can animate almost anything, you can see the types of animations here.

class HelloWorld(Scene):
    def construct(self):

        helloWorld = Text("Hello World")
        self.play(helloWorld.animate.set_color(RED), run_time = 3)

Our animation is more complicated and this method is not suitable, because… you need to move many balls at the same time. Each along its own trajectory, each at its own interval, and at strictly defined moments, change the meter readings. There are a couple of methods for such things UpdateFromFunc And UpdateFromAlphaFunc. One of the arguments is a callback function that will be called every frame, and inside this function you can do whatever you want. These functions are practically no different, UpdateFromAlphaFunc passes the alpha parameter, which smoothly changes from 0 at the beginning of the animation to 1 at the end. We don't need this, so we use UpdateFromFunc.

Here is the call to the “main” animation

        wrapper = VGroup(table, counter)
        for item in items:
            wrapper.add(item.circle)

        runTime = GaltonBoard.config["runTime"]
        self.play(UpdateFromFunc(wrapper, updateFrameFunction), run_time=runTime)

Everything is quite simple, but there is a subtlety – in order for everything to animate normally, all objects that will change must be passed to this function. Sometimes some simple animations worked fine without this, just passing a dummy there. The solution is simple: we add all the necessary objects to the VGroup and everything always works. But you need to pay attention to this.

The function itself

updateFrameFunction
        def updateFrameFunction(table):
            durationSeconds = GaltonBoard.config["durationSeconds"]
            durationFrames = durationSeconds * self.camera.frame_rate
            self.frameNumber += 1

            for item in items:
                if item.isActive and self.frameNumber > item.startFrame:
                    alpha = (self.frameNumber - item.startFrame) / durationFrames
                    if (alpha <= 1.0) :
                        point = item.path.point_from_proportion(rate_functions.linear(alpha))
                        item.circle.move_to(point)     
                    else:
                        updateCounter()
                        updateStackValue(item.stackIndex)
                        item.isActive = False

The function is called every frame, when called we increase the frame counter and from there we dance further. Pay attention to the following lines, they contain all the magic

point = item.path.point_from_proportion(rate_functions.linear(alpha))
item.circle.move_to(point)

The trajectory is very tortuous and we need to calculate for each frame the point on this trajectory where the ball should be. Fortunately, there are already ready-made methods for this. Alpha is part of the path already traveled, varies from 0 to 1, and is calculated based on the frame counter. The ball moves uniformly, so linear interpolation is used (rate_functions.linear), but there is also other for every taste.

Another small piece of code – how to update a value in a table, stackValueIndex is the column number (which is in the ball class and is calculated in advance), numbering starts from one (Pascal?).

updateStackValue
        def updateStackValue(stackValueIndex):
            cell = table.get_entries((1, stackValueIndex + 1))
            val = cell.get_value()
            val += 1
            cell.set_value(val)

Putting it all together and this is the result

Various little things

There is a lot in the code, but not everything is specified in the config. You can play with the main things – the number of balls, the delay between balls and the speed of falling. You can make a triangle or a circle from a hexagon; this is not a parameter in the config, but it’s easy to do. Literally replacing one single line in the createHexagons method.

tmp = RegularPolygon(n=6, radius = hexSize, start_angle = .5)
# uncomment the following lines to get triangles of circles instead of hexagons
#tmp = RegularPolygon(n=3, radius = hexSize)
#tmp = Circle(radius = hexSize)
Triangles instead of hexes

Triangles instead of hexes

Circles instead of triangles

Circles instead of triangles

It is possible, unlike the real Galton board, to make different probabilities instead of the same probability of rebound. In the picture below, 1/3 bounce is to the left and 2/3 to the right.

Skew in the probability of a ball bouncing on an obstacle

Skew in the probability of a ball bouncing on an obstacle

Conclusion

I liked creating videos from Python code. Once upon a time I dabbled in 3D Studio Max and there is something to compare it with. Although 3D Max is more about modeling, you can also make videos in it. It also had its own scripting language, but still the approaches were completely different. What did I like? As a developer with many years of experience, writing code is of course easier. Working in a familiar IDE, examples can simply be copied as text and pasted into the desired place in your animation, and you can immediately change something right in the code. I think it will be easier for a developer to write code than to deal with specialized software. On the other hand, the opposite is also true: if a person owns software for 3D modeling and animation, writing code will be unusual, and the approach itself will be different. The capabilities also vary greatly; manim is still a specialized and quite simple thing.

Let me sum it up. If you are a developer and want to make a video, you don’t have to study specialized software. You can make a video from code using manim. I hope my story will help with this.

Sources on github.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *