Using Matplotlib To Create The 50th Article Cover Mosaic Animation (#50 Part 2)
The Matplotlib animation is here • A step-by-step tutorial to create the animation that generates the mosaic with the 49 previous articles' cover images
How did I create this animation using Matplotlib?
This is the second part of my #50 article. I won't dwell further on what I discussed in Part 1 as I want to get straight to this animation.
There are surely other ways to create this animation in Python. But I default to Matplotlib when I need any plots or displays, even when those plots are images and moving!
If you want to see another Matplotlib animation, I wrote this 3D orbiting planets one in the pre-Substack era: Simulating a 3D Solar System In Python Using Matplotlib
Before You Start
Before you start, you'll need 49 square images for this animation. They don't have to be 49, of course, but you should have a square number. You can use your own images, but you can also use the same ones I'm using in this article (the cover images of all the 49 articles on The Python Coding Stack so far). Download the folder with the images here.
You'll also use the third-party libraries Matplotlib, NumPy, and Pillow, in addition to some modules in the standard library. You can download these libraries from the terminal using pip
(or your favourite package manager):
$ pip install matplotlib numpy pillow
I'll assume the images are in a folder called article_images
within the main project folder. All my images are named using a three-digit number with leading zeros, for example, 007.jpg
and 035.jpg
. The first one is 000.jpg
.
Reading The Images
Let's start with a function to read an image from the JPG file and convert it into a NumPy array. Note that since the Pillow library is a fork of the older PIL library, you need to use PIL
in your imports:
In the function get_image()
, you first rely on f-strings and the :03
format specifier, which ensures image_number
is displayed using three digits, with leading zeros to fill the three digits if necessary. Therefore, the f-string f"{7:03}.jpg"
outputs 007.jpg
. This works specifically for the file names I have in this example.
You can use strings to concatenate the relative path name. However, using pathlib
and its Path
object makes this more robust and allows you to use the forward slash operator to create a path.
Next, you open the image file using Pillow's Image.open()
method and convert it into a NumPy array. I also added some error handling.
Setting Up The Grid
You have a way of reading an image into a NumPy array. Next, you need to set up the grid where all the final images will go. I'll assume all the images are the same size to simplify the code. You can set the overall size of the final mosaic to the size of one of the original images.
But you also need to calculate the size of the small images which need to fit into a single cell in the final mosaic. I'm truncating the display of the code in these code snippets using # ...
as a placeholder for code from earlier in the article:
The image is a three-dimensional array to account for the colour image's red, green, and blue channels. Some images may have a fourth dimension with the transparency value, but the images I use in this example do not.
You use NumPy's .shape
attribute to get the width and height of the image in pixels and the number of dimensions, which is 3
. You determine the number of images by finding how many files in the folder have the .jpg
extension using Path.glob()
. This method returns a generator, so you first cast into a list before finding its length since generators don't have a length.
We'll assume the number of images is a square and calculate its square root. You could import the math
module and use math.sqrt()
, but I chose to raise the number of images to the power of 0.5
, which also gives the square root. You also cast the result to an integer using int()
, which also takes care of the case when you don't have a square number of images. Casting to an integer using int()
returns the floor of any float argument. Therefore, the grid_size
will be the largest possible square you can use for the number of images in the folder. For example, if you had 60
images in the folder, you'll still have a 7 x 7
grid since the square root of 60
is about 7.7
, but this becomes 7
when you cast it into an integer.
The small images in the final mosaic will have dimensions cell_width
and cell_height
. In this example, the images are 800 x 800
pixels, and the grid is 7 x 7
. Therefore, the small cells' width and height are 800 / 7
.
Let's also import matplotlib.pyplot
and create the Matplotlib figure. Note that I split some lines into multi-lines to display them better on this page:
The list of tuples grid_coordinates
contains the pairs of indices representing the 49 cells in the grid. I chose the order of the coordinate pairs to suit the order in which I want the grid to form in the animation. The list of tuples looks like this (I truncated the output):
[(0, 6), (1, 6), (2, 6), (3, 6), (4, 6), (5, 6), (6, 6), (0, 5), ..., (6, 0)]
Controlling Where And How Images Are Plotted
A quick aside before we proceed with building the animation. Let's create three square images using NumPy and show them on the same plot. The squares have different sizes. This demonstration uses a separate script, which I named placing_images.py
to distinguish from the main script we're working on in this article:
The black square is 200 x 200
pixels. The white square is smaller and is 80 x 80
pixels, and the grey square is even smaller, 40 x 40
.
This code gives the following plot showing all three squares:
All squares are placed at the bottom left of the figure. What if you want the smaller squares displayed elsewhere within the figure? You can use the extent
keyword argument in imshow()
:
The extent
parameter needs a sequence of values that set the image's left, right, bottom, and top edges in that order. Here's the output showing the white square placed between 50
and 130
horizontally and between 30
and 110
vertically. The grey square has different values:
The width and height determined by extent
don't have to be identical to the size of the image. The image will be stretched to fit within the bounds you choose in the extent
argument.
And what if you want to control which image is on top? Yes, you can change the order of plotting. But you can also use the zorder
keyword argument, which sets the layer you want an image to be plotted in:
The white square is now plotted above the grey square since it has a higher zorder
value:
And try setting the large black square's zorder
to 3
to see what happens:
ax.imshow(black_square, zorder=3)
Converting From Cell Indices To Pixel Coordinates
Let's get back to the animation. And let's say you want to plot an image in the cell (3, 2)
within the mosaic. What are the left, right, bottom, and top edges of that cell so that the image can be shrunk to fit within this area?
You can define a function to help with this conversion:
This function returns the coordinates of the bottom-left corner of the cell within the grid. Recall that since the first index is 0
, the grid with coordinates (0, 0)
also returns the pixel (0, 0)
.
Creating The Animation
It's time to start creating the animated part of the animation. Here's what you need:
Iterate through all the cover images. I'll use the term "cover images" in the article to refer to the article covers. These are the images in the JPG files.
Show each image in full size.
Calculate the final position of the image, which depends on which cell in the grid it's assigned to.
Show the image shrinking in several stages. The image shrinks towards its final location in the grid.
Let's start creating an animation with Matplotlib. I've chosen the FuncAnimation
route over the ArtistAnimation
one. Before briefly explaining the difference, a caveat: I won't go into all the details on how to create a Matplotlib animation. That's beyond the scope of this demonstration. Perhaps I'll write a standalone article on Matplotlib animations in the future.
FuncAnimation
creates an animation by calling a function to generate each frame. ArtistAnimation
uses a fixed set of Matplotlib Artist
objects to create the animation. By using FuncAnimation
, each frame is created when it's needed rather than in advance.
Here's the FuncAnimation
call:
You import FuncAnimation
from matplotlib.animation
and add a new variable at the top of the script, frames_per_image
. This represents how many frames in the animation are allocated to each cover image as it shrinks from full size to its final smaller size. This version uses a value of 5
. Therefore, each cover image will need 5
frames to go from large to small. You can change this value to a larger number later to make the animation smoother. But it's best to use a small number while writing and testing the code.
FuncAnimation
needs to call a function to create each frame. You can create this function, which I called animate()
, and put an ellipsis (...
) for now as a placeholder. We'll work on this function later.
Finally, you create a FuncAnimation
object by passing five arguments:
The figure,
fig
The animation function,
animate()
The total number of frames in the animation,
frames_per_image * num_images
The time interval between frames (in milliseconds), and
The Boolean value
False
that's assigned to therepeat
parameter to indicate the animation shouldn't start again when it ends.
Displaying each cover image
The animation runs by calling the animate()
function for each value determined in the frames
keyword argument when you call FuncAnimation
. Incidentally, you need to assign this FuncAnimation
object to a variable name, which I called animation
in this example, even though you never explicitly use this variable name. This ensures the object stays in memory and is not garbage collected before the figure is plotted. Try removing the assignment to animation
and see what the error message says!
In this example, animate()
is called 5 x 49
times since there are 49
images, and you're using 5
frames per image to shrink each cover image into its final cell. The plan with these 245
frames is the following:
Determine which cover image you're dealing with. The first five frames will deal with image
000.jpg
, the next five frames with001.jpg
, and so on.In the first frame dealing with a particular cover image, create the image artist* in Matplotlib using
imshow()
.In the remaining frames for the same cover image, change the size and location of the image displayed. The shrinking image should converge to its final destination, which is the small cell on the
7 x 7
grid to which it belongs.
*"Artist" is the term Matplotlib uses to refer to a general object you can interact with in a plot. The method .imshow()
returns an artist to represent the 2D image. I'll use the term "image artist" to refer to the Matplotlib object and to distinguish it from the cover image, which refers to the picture you see in the JPG files.
Let's start with these steps. You'll only create the image artist for each cover image once. You can do this by calling ax.imshow()
in the first frame in each set of five. A reminder that I'm referring to "a set of five frames" because frames_per_image = 5
. I find it's clearer to refer to an actual number in the article text. But this value can change.
You can store the image artists returned by ax.imshow()
in a list. However, you'll have more things to store for each cover image soon, so you can create a dictionary instead:
When frame_in_current_image
is 0
, which means it's the first frame for a new cover image, you create a dictionary to append to the image_artists
list. There's only one key-value pair for now, but you'll add more soon. The value assigned to the "artist"
key is the image artist returned by ax.imshow()
. You call this method with two arguments:
get_image()
returns a NumPy array representing the image.extent
is the keyword you used earlier. The tuplestart_extent
you created before defininganimate()
represents the whole image. Soon, you'll use different values for the extent of each image artist.
Here's the animation created by this code. Don't worry if you notice your animation slowing down as it progresses. We'll discuss this later:
Each cover image is displayed for five frames of the animation, and then the following cover image is displayed on top. You can adjust the speed of the animation by changing the interval
argument when calling FuncAnimation
.
Shrinking each cover image into its cell
The first frame in each set of five frames displays the correct image. Now, you must deal with the second to fifth frame in each set. Each image needs to shrink and shift sideways to end up in its final cell within the 7 x 7
grid.
Let's work out the extent needed for the final image, which represents the cell in the 7 x 7
grid. This is the final destination for each cover image:
You convert the grid coordinates into pixel values within the if
block that runs only for the first frame in each set of five. The list grid_coordinates
contains tuples with pairs of coordinates. Therefore, you use the unpacking operator *
to pass the separate values to convert_grid_to_pixel()
.
Using the values returned by convert_grid_to_pixel()
, which represent the bottom-left corner of the cell, and the width and height of each cell, you can determine the final extent. You add this to the dictionary for this cover image along with the image artist.
Now, you can calculate the extent values required for the intermediate stages between the first and fifth frames in each set of five:
There are a couple of additions at this stage:
The new function
calculate_extent()
works out the new extent based on the stage of the animation, the start extent, and the end extent. The return value is a list comprehension in which each of the four numbers representing the left, right, bottom, and top are calculated from the start and end values and the stage of the animation.The
animate()
function calculates the new extent for each frame using thecalculate_extent()
function. The first two arguments areframe_in_current_image
andframes_per_image
. Therefore, the extent is calculated based on the stage within the "mini animation"–the set of five frames in which the cover image shrinks into its cell.The extent of the existing image artist representing the cover image is changed using Matplotlib's
set_extent()
method.
There's a bit more you need to do before you see the fruits of all this hard work.
Setting the start of the animation
There's one last change before you get a functioning animation. You need to determine what's drawn at the start of the animation. At the moment, the artist from the first frame is used. However, we want to start with a white background, which is the default background of a Matplotlib plot.
FuncAnimation()
can take another keyword argument, init_func
. This argument is a function that determines the first frame of the animation. However, you don't really want this function to do anything since the white figure background is good enough. But you still need a function to use as an argument:
The argument assigned to the parameter init_func
is a lambda
function that returns None
. And this does the trick:
A Note On Efficiency And Alternatives
You may have noticed the animation slowing down as more cover images are added. It has more artists to draw in each frame. There are solutions to this problem (there are almost always solutions to problems if you're willing to increase complexity!) However, I won't make any of these changes in this article. The final change I'll make in the next section will make this change mostly unnecessary.
One option is to experiment with the blit=True
argument in FuncAnimation()
. This allows the animation to only update the artists that changed and not the others. This option requires changes to animate()
, specifically to return a sequence of the artists that include changes.
Another option is not to use Matplotlib to resize the images. Instead, the images could be resized using another module such as Pillow, OpenCV or scikit-image and the frames could be created using these modules.
However, I'll move straight to the final section of this article, in which I won't display the animation at all in Matplotlib!
Saving The Animation As A Movie File
The figure in which the animation runs is displayed with the final call in this code, plt.show()
. Since the animation is a bit slow, let's remove this line!
Instead, we can save the animation directly as an .mp4
movie file. You'll need to install FFmpeg first. You can find instructions for whatever platform you're using here [I'm experimenting with Perplexity!]
Here's the line we need to save the animation directly to a movie file:
Or you can also use PillowWriter
if you want to create an animated GIF. You're already using Pillow in this code, so this is more straightforward. You need to import PillowWriter
from matplotlib.animation
first:
Whichever option you choose, change frames_per_image
from 5
to 15
or whatever value you prefer, run the code, go and make yourself a cup of tea, and then return to find the animation file waiting for you.
The full final code is shown below:
Final Words
I've said a lot already. I'll be writing more about Matplotlib in the future–and not just animations.
If you want to see another Matplotlib animation, I wrote this 3D orbiting planets one in the pre-Substack era: Simulating a 3D Solar System In Python Using Matplotlib
Code in this article uses Python 3.12
Stop Stack
#50
If you like my style of communication and the topics I talk about, you may be interested in The Python Coding Place. This is my platform where I have plenty of video courses (with plenty more coming over the coming months), a community forum, weekly videos, and coming soon, live workshops and cohort courses. Any questions, just reply to this email to ask.
If you read my articles often, and perhaps my posts on social media, too, you've heard me talk about The Python Coding Place several times. But you haven't heard me talk a lot about is Codetoday Unlimited, a platform for teenagers to learn to code in Python. The beginner levels are free so everyone can start their Python journey. If you have teenage daughters or sons, or a bit younger, too, or nephews and nieces, or neighbours' children, or any teenager you know, really, send them to Codetoday Unlimited so they can start learning Python or take their Python to the next level if they've already covered some of the basics.
Each article is the result of years of experience and many hours of work. Hope you enjoy each one and find them useful. If you're in a position to do so, you can support this Substack further with a paid subscription. In addition to supporting this work, you'll get access to the full archive of articles. Alternatively, if you become a member of The Python Coding Place, you'll get access to all articles on The Stack as part of that membership. Of course, there's plenty more at The Place, too.
Appendix: Code Blocks
Code Block #1
import pathlib
import numpy as np
from PIL import Image
image_folder = "article_images"
def get_image(image_number):
image_file_name = f"{image_number:03}.jpg"
image_path = pathlib.Path(image_folder) / image_file_name
try:
with Image.open(image_path) as image:
return np.array(image)
except FileNotFoundError:
print(f"The file {image_file_name} was not found.")
return None
Code Block #2
# ...
# Set up the grid
width, height, dims = get_image(0).shape
# Find how many jpg files there are in folder
num_images = len(
list(pathlib.Path(image_folder).glob("*.jpg"))
)
# Square root of the number of images gives the grid size
grid_size = int(num_images ** 0.5) # 7x7 grid if 49 images
cell_width = width / grid_size
cell_height = height / grid_size
Code Block #3
import pathlib
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
# ...
# Create figure
fig, ax = plt.subplots()
ax.axis("off")
ax.axis("tight")
ax.set_xlim(0, width)
ax.set_ylim(0, height)
# Create a list of tuples with coordinates to
# represent each cell in the grid
grid_coordinates = [
(x, y)
for y in range(grid_size - 1, -1, -1)
for x in range(grid_size)
]
Code Block #4
# placing_images.py
import matplotlib.pyplot as plt
import numpy as np
black_square = np.zeros((200, 200, 3), dtype=np.uint8)
white_square = 255 * np.ones((80, 80, 3), dtype=np.uint8)
grey_square = 150 * np.ones((40, 40, 3), dtype=np.uint8)
fig, ax = plt.subplots()
ax.imshow(black_square)
ax.imshow(white_square)
ax.imshow(grey_square)
ax.set_xlim(0, 200)
ax.set_ylim(0, 200)
plt.show()
Code Block #5
# placing_images.py
import matplotlib.pyplot as plt
import numpy as np
black_square = np.zeros((200, 200, 3), dtype=np.uint8)
white_square = 255 * np.ones((80, 80, 3), dtype=np.uint8)
grey_square = 150 * np.ones((40, 40, 3), dtype=np.uint8)
fig, ax = plt.subplots()
ax.imshow(black_square)
ax.imshow(white_square, extent=(50, 130, 30, 110))
ax.imshow(grey_square, extent=(100, 140, 85, 125))
ax.set_xlim(0, 200)
ax.set_ylim(0, 200)
plt.show()
Code Block #6
# placing_images.py
import matplotlib.pyplot as plt
import numpy as np
black_square = np.zeros((200, 200, 3), dtype=np.uint8)
white_square = 255 * np.ones((200, 200, 3), dtype=np.uint8)
grey_square = 150 * np.ones((200, 200, 3), dtype=np.uint8)
fig, ax = plt.subplots()
ax.imshow(black_square)
ax.imshow(white_square, extent=(50, 130, 30, 110), zorder=2)
ax.imshow(grey_square, extent=(100, 140, 85, 125), zorder=1)
ax.set_xlim(0, 200)
ax.set_ylim(0, 200)
plt.show()
Code Block #7
# ...
def get_image(image_number):
# ...
def convert_grid_to_pixel(grid_x, grid_y):
return grid_x * cell_width, grid_y * cell_height
# ...
Code Block #8
import pathlib
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from PIL import Image
frames_per_image = 5
image_folder = "article_images"
# ...
# Create the FuncAnimation
def animate(frame):
...
animation = FuncAnimation(
fig,
animate,
frames=frames_per_image * num_images,
interval=20,
repeat=False,
)
plt.show()
Code Block #9
# ...
# Create the FuncAnimation
image_artists = []
# Here's what this list of dictionaries will look like
# We'll add more to this template later
# Each dictionary is associated with a cover image
# through its index
# image_artists = [
# {"artist": <artist object>, ...},
# {"artist": <artist object>, ...},
# ...
# ]
start_extent = (0, width, 0, height)
def animate(frame):
# temporary line to allow us to follow the progress of the animation
ax.set_title(f"Frame {frame+1} of {frames_per_image*num_images}")
image_number = frame // frames_per_image
frame_in_current_image = frame % frames_per_image
# Read image, create image artist, and add to list
# if first frame for this cover image
if frame_in_current_image == 0:
# Add a dictionary for each cover image
image_artists.append(
{
"artist": ax.imshow(
get_image(image_number),
extent=start_extent,
)
}
)
animation = FuncAnimation(
fig,
animate,
frames=frames_per_image * num_images,
interval=20,
repeat=False,
)
plt.show()
Code Block #10
# ...
# Create the FuncAnimation
image_artists = []
# image_artists = [
# {"artist": None, "end_extent": (...)},
# {"artist": None, "end_extent": (...)},
# ...
# ]
start_extent = (0, width, 0, height)
def animate(frame):
# temporary line to allow us to follow the progress of the animation
ax.set_title(f"Frame {frame+1} of {frames_per_image*num_images}")
image_number = frame // frames_per_image
frame_in_current_image = frame % frames_per_image
# Read image, create image artist, and add to list
# if first frame for this cover image
if frame_in_current_image == 0:
# Convert grid position to pixel position
# of the bottom-left corner of the image
pixel_x, pixel_y = convert_grid_to_pixel(
*grid_coordinates[image_number]
)
# Set the extent needed for the start and end
# versions of this cover image
end_extent = (
pixel_x,
pixel_x + cell_width,
pixel_y,
pixel_y + cell_height,
)
# Add a dictionary for each cover image
image_artists.append(
{
"artist": ax.imshow(
get_image(image_number),
extent=start_extent,
),
"end_extent": end_extent,
}
)
animation = FuncAnimation(
fig,
animate,
frames=frames_per_image * num_images,
interval=20,
repeat=False,
)
plt.show()
Code Block #11
# ...
def get_image(image_number):
# ...
def convert_grid_to_pixel(grid_x, grid_y):
# ...
def calculate_extent(
frame, total_frames, start_extent, end_extent
):
stage_of_animation = (frame + 1) / total_frames
# return the new extent
return [
start + (end - start) * stage_of_animation
for start, end in zip(start_extent, end_extent)
]
# ...
# Create the FuncAnimation
image_artists = []
start_extent = (0, width, 0, height)
def animate(frame):
image_number = frame // frames_per_image
frame_in_current_image = frame % frames_per_image
# Read image, create image artist, and add to list
# if first frame for this cover image
if frame_in_current_image == 0:
# ...
# Calculate the extent for each frame in the animation
new_extent = calculate_extent(
frame_in_current_image,
frames_per_image,
start_extent,
image_artists[image_number]["end_extent"]
)
artist = image_artists[image_number]["artist"]
artist.set_extent(new_extent)
animation = FuncAnimation(
fig,
animate,
frames=frames_per_image * num_images,
interval=20,
repeat=False,
)
plt.show()
Code Block #12
# ...
# Create the FuncAnimation
image_artists = []
start_extent = (0, width, 0, height)
def animate(frame):
# ...
animation = FuncAnimation(
fig,
animate,
frames=frames_per_image * num_images,
interval=20,
repeat=False,
init_func=lambda: None,
)
plt.show()
Code Block #13
# ...
animation = FuncAnimation(
fig,
animate,
frames=frames_per_image * num_images,
interval=20,
repeat=False,
init_func=lambda: None,
)
animation.save(
"mosaic_animation_final.mp4",
writer="ffmpeg",
fps=15,
)
Code Block #14
# ...
from matplotlib.animation import FuncAnimation, PillowWriter
# ...
animation = FuncAnimation(
fig,
animate,
frames=frames_per_image * num_images,
interval=20,
repeat=False,
init_func=lambda: None,
)
animation.save(
"mosaic_animation_final.gif",
writer=PillowWriter(fps=15),
)
Code Block #15
import pathlib
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from PIL import Image
frames_per_image = 15
image_folder = "article_images"
def get_image(image_number):
image_file_name = f"{image_number:03}.jpg"
image_path = pathlib.Path(image_folder) / image_file_name
try:
with Image.open(image_path) as image:
return np.array(image)
except FileNotFoundError:
print(f"The file {image_file_name} was not found.")
return None
def convert_grid_to_pixel(grid_x, grid_y):
return grid_x * cell_width, grid_y * cell_height
def calculate_extent(
frame, total_frames, start_extent, end_extent
):
stage_of_animation = (frame + 1) / total_frames
# return the new extent
return [
start + (end - start) * stage_of_animation
for start, end in zip(start_extent, end_extent)
]
# Set up the grid
width, height, dims = get_image(0).shape
# Find how many jpg files there are in folder
num_images = len(
list(pathlib.Path(image_folder).glob("*.jpg"))
)
# Square root of the number of images gives the grid size
grid_size = int(num_images**0.5) # 7x7 grid if 49 images
cell_width = width / grid_size
cell_height = height / grid_size
# Create figure
fig, ax = plt.subplots()
ax.axis("off")
ax.axis("tight")
ax.set_xlim(0, width)
ax.set_ylim(0, height)
# Create a list of tuples with coordinates to
# represent each cell in the grid
grid_coordinates = [
(x, y)
for y in range(grid_size - 1, -1, -1)
for x in range(grid_size)
]
# Create the FuncAnimation
image_artists = []
start_extent = (0, width, 0, height)
def animate(frame):
image_number = frame // frames_per_image
frame_in_current_image = frame % frames_per_image
# Read image, create image artist, and add to list
# if first frame for this cover image
if frame_in_current_image == 0:
# Convert grid position to pixel position
# of the top-left corner of the image
pixel_x, pixel_y = convert_grid_to_pixel(
*grid_coordinates[image_number]
)
# Set the extent needed for the start and end
# versions of this cover image
end_extent = (
pixel_x,
pixel_x + cell_width,
pixel_y,
pixel_y + cell_height,
)
# Add a dictionary for each cover image
image_artists.append(
{
"artist": ax.imshow(
get_image(image_number),
extent=start_extent,
),
"end_extent": end_extent,
}
)
# Calculate the extent for each frame in the animation
new_extent = calculate_extent(
frame_in_current_image,
frames_per_image,
start_extent,
image_artists[image_number]["end_extent"]
)
artist = image_artists[image_number]["artist"]
artist.set_extent(new_extent)
animation = FuncAnimation(
fig,
animate,
frames=frames_per_image * num_images,
interval=20,
repeat=False,
init_func=lambda: None,
)
animation.save(
"mosaic_animation_final.mp4",
writer="ffmpeg",
fps=15,
)
# plt.show()