Making a map animation
Making a mapping movie¶
Introduction¶
As part of working my way through the Udemy course Udemy 30-Day Map Challenge 2025: Director's Cut, there was a small segment on making animated maps.
I ran across a few gotchas in the process, and learned about a new library PyAC.
The essential process is to use matplotlib to produce a number of JPEG images of a map (each image to be a frame in an animation). We then stitch these into an MP4 file. I will go through the process below, and then show some of the pitfall I fells into
Implementation¶
Package imports¶
- os used for file access
- imageio.v3 used for image management
import os
import imageio.v3 as iio
from IPython.display import IFrame
%load_ext watermark
Images¶
Assume we have alreay created a folder ___frames_neon___ with the images to made into a map.
Note that the images are stored a JPG files: when I initially used PNG files, I came unstuck. The PNG files were 4 planes deep (I assume RGB+alpha), and the movie making software assumed 3 planes (see below).
List the image file names
folder = "frames_neon"
files = sorted([f for f in os.listdir(folder) if f.endswith(".jpg")])
files[0:5]
['frame_0000.jpg', 'frame_0001.jpg', 'frame_0002.jpg', 'frame_0003.jpg', 'frame_0004.jpg']
Get a list of images in the correct order
images = [iio.imread(os.path.join(folder, f)) for f in files]
Making MP4¶
We use a plugin pyav. In their own words:
PyAV is a Pythonic binding for the FFmpeg libraries. We aim to provide all of the power and control of the underlying library, but manage the gritty details as much as possible.
We write the movie to a file "neon.mp4". Note the code to make the dimension of the images (height, width) even numbers, and the line of code setting a time_base variable
video_writer = iio.imopen("neon.mp4", "w", plugin="pyav")
video_writer.init_video_stream(codec="libx264", fps=20)
# Refer to
# https://github.com/imageio/imageio/issues/1139
video_writer._video_stream.codec_context.time_base = video_writer._video_stream.time_base
l_x, l_y, _ = images[0].shape
l_x = (l_x//2)*2
l_y = (l_y//2)*2
for image in images:
video_writer.write_frame(image[0:l_x,0:l_y,])
#end for
video_writer.close()
Display MP4 player snapshot in notebook
IFrame("images/map_movie.png", width=800, height=400)
video_writer = iio.imopen("bad.mp4", "w", plugin="pyav")
video_writer.init_video_stream(codec="libx264", fps=20)
# Refer to
# https://github.com/imageio/imageio/issues/1139
video_writer._video_stream.codec_context.time_base = video_writer._video_stream.time_base
l_x, l_y, _ = images[0].shape
for image in images:
video_writer.write_frame(image[0:l_x,0:l_y,])
#end for
video_writer.close()
--------------------------------------------------------------------------- ExternalError Traceback (most recent call last) Cell In[11], line 11 8 l_x, l_y, _ = images[0].shape 10 for image in images: ---> 11 video_writer.write_frame(image[0:l_x,0:l_y,]) 12 #end for 13 video_writer.close() File ~\anaconda3\envs\30DayMapChallenge2025\Lib\site-packages\imageio\plugins\pyav.py:977, in PyAVPlugin.write_frame(self, frame, pixel_format) 974 stream.width = av_frame.width 975 stream.height = av_frame.height --> 977 for packet in stream.encode(av_frame): 978 self._container.mux(packet) File ~\anaconda3\envs\30DayMapChallenge2025\Lib\site-packages\av\video\stream.py:30, in av.video.stream.VideoStream.encode() 25 raise AttributeError( 26 f"'{type(self).__name__}' object has no attribute '{name}'" 27 ) 28 return getattr(self.codec_context, name) ---> 30 @cython.ccall 31 def encode(self, frame: VideoFrame | None = None): 32 """ 33 Encode an :class:`.VideoFrame` and return a list of :class:`.Packet`. 34 (...) 37 .. seealso:: This is mostly a passthrough to :meth:`.CodecContext.encode`. 38 """ 40 packets = self.codec_context.encode(frame) File ~\anaconda3\envs\30DayMapChallenge2025\Lib\site-packages\av\video\stream.py:40, in av.video.stream.VideoStream.encode() 30 @cython.ccall 31 def encode(self, frame: VideoFrame | None = None): 32 """ 33 Encode an :class:`.VideoFrame` and return a list of :class:`.Packet`. 34 (...) 37 .. seealso:: This is mostly a passthrough to :meth:`.CodecContext.encode`. 38 """ ---> 40 packets = self.codec_context.encode(frame) 41 packet: Packet 42 for packet in packets: File ~\anaconda3\envs\30DayMapChallenge2025\Lib\site-packages\av\codec\context.py:455, in av.codec.context.CodecContext.encode() 453 """Encode a list of :class:`.Packet` from the given :class:`.Frame`.""" 454 res = [] --> 455 for frame in self._prepare_and_time_rebase_frames_for_encode(frame): 456 for packet in self._send_frame_and_recv(frame): 457 self._setup_encoded_packet(packet) File ~\anaconda3\envs\30DayMapChallenge2025\Lib\site-packages\av\codec\context.py:439, in av.codec.context.CodecContext._prepare_and_time_rebase_frames_for_encode() 436 if self.ptr.codec_type not in [lib.AVMEDIA_TYPE_VIDEO, lib.AVMEDIA_TYPE_AUDIO]: 437 raise NotImplementedError("Encoding is only supported for audio and video.") --> 439 self.open(strict=False) 441 frames = self._prepare_frames_for_encode(frame) 443 # Assert the frames are in our time base. 444 # TODO: Don't mutate time. File ~\anaconda3\envs\30DayMapChallenge2025\Lib\site-packages\av\codec\context.py:248, in av.codec.context.CodecContext.open() 245 self.ptr.time_base.num = 1 246 self.ptr.time_base.den = lib.AV_TIME_BASE --> 248 err_check( 249 lib.avcodec_open2(self.ptr, self.codec.ptr, cython.address(options.ptr)), 250 f'avcodec_open2("{self.codec.name}", {self.options})', 251 ) 252 self.is_open = True 253 self.options = dict(options) File ~\anaconda3\envs\30DayMapChallenge2025\Lib\site-packages\av\error.py:411, in av.error.err_check() 408 message = error_buffer or os.strerror(code) 410 cls = classes.get(code, UndefinedError) --> 411 raise cls(code, message, filename, log) 412 finally: 413 free(error_buffer) ExternalError: [Errno 542398533] Generic error in an external library: 'avcodec_open2("libx264", {})'
Not setting time_base¶
Setting the time_base (it appears) is a workaround for a bug that was introduced in pyav 15.0, and seems to be still there at pyav 17.0.0.
Again, the diagnostic is also not very helpful
video_writer = iio.imopen("bad.mp4", "w", plugin="pyav")
video_writer.init_video_stream(codec="libx264", fps=20)
# Refer to
# https://github.com/imageio/imageio/issues/1139
# video_writer._video_stream.codec_context.time_base = video_writer._video_stream.time_base
l_x, l_y, _ = images[0].shape
l_x = (l_x//2)*2
l_y = (l_y//2)*2
for image in images:
video_writer.write_frame(image[0:l_x,0:l_y,])
#end for
video_writer.close()
--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) Cell In[12], line 13 10 l_y = (l_y//2)*2 12 for image in images: ---> 13 video_writer.write_frame(image[0:l_x,0:l_y,]) 14 #end for 15 video_writer.close() File ~\anaconda3\envs\30DayMapChallenge2025\Lib\site-packages\imageio\plugins\pyav.py:964, in PyAVPlugin.write_frame(self, frame, pixel_format) 961 plane_array[...] = frame 963 stream = self._video_stream --> 964 av_frame.time_base = stream.codec_context.time_base 965 av_frame.pts = self.frames_written 966 self.frames_written += 1 File ~\anaconda3\envs\30DayMapChallenge2025\Lib\site-packages\av\frame.py:144, in av.frame.Frame.time_base.__set__() 142 @time_base.setter 143 def time_base(self, value): --> 144 to_avrational(value, cython.address(self._time_base)) File ~\anaconda3\envs\30DayMapChallenge2025\Lib\site-packages\av\utils.py:58, in av.utils.to_avrational() 56 @cython.cfunc 57 def to_avrational(frac: object, input: cython.pointer[lib.AVRational]) -> cython.void: ---> 58 input.num = frac.numerator 59 input.den = frac.denominator AttributeError: 'NoneType' object has no attribute 'numerator'
folder = "frames"
files = sorted([f for f in os.listdir(folder) if f.endswith(".png")])
files[0:5]
['frame_000.png', 'frame_001.png', 'frame_002.png', 'frame_003.png', 'frame_004.png']
images = [iio.imread(os.path.join("frames", f)) for f in files]
print(images[0].shape)
(2000, 2000, 4)
video_writer = iio.imopen("bad.mp4", "w", plugin="pyav")
video_writer.init_video_stream(codec="libx264", fps=20)
# Refer to
# https://github.com/imageio/imageio/issues/1139
video_writer._video_stream.codec_context.time_base = video_writer._video_stream.time_base
l_x, l_y, _ = images[0].shape
l_x = (l_x//2)*2
l_y = (l_y//2)*2
for image in images:
video_writer.write_frame(image[0:l_x,0:l_y,])
#end for
video_writer.close()
--------------------------------------------------------------------------- ValueError Traceback (most recent call last) Cell In[15], line 13 10 l_y = (l_y//2)*2 12 for image in images: ---> 13 video_writer.write_frame(image[0:l_x,0:l_y,]) 14 #end for 15 video_writer.close() File ~\anaconda3\envs\30DayMapChallenge2025\Lib\site-packages\imageio\plugins\pyav.py:961, in PyAVPlugin.write_frame(self, frame, pixel_format) 954 plane_strides += (img_dtype.itemsize,) 956 plane_array = as_strided( 957 np.frombuffer(plane, dtype=img_dtype), 958 shape=plane_shape, 959 strides=plane_strides, 960 ) --> 961 plane_array[...] = frame 963 stream = self._video_stream 964 av_frame.time_base = stream.codec_context.time_base ValueError: could not broadcast input array from shape (2000,2000,4) into shape (2000,2000,3)
Alternatives¶
An alternative to using imageio.v3 would be to use the OpenCV library (import cv2) , with code that looks like:
# Define the codec and create VideoWriter object
# 'mp4v' is a standard codec for mp4 format
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
out = cv2.VideoWriter(output_file, fourcc, fps, size)
...
# Write each frame to the video
for i, file_path in enumerate(image_files):
img = cv2.imread(file_path)
out.write(img)
Reproducability¶
%watermark
Last updated: 2026-04-14T13:39:57.737213+10:00 Python implementation: CPython Python version : 3.11.15 IPython version : 9.10.1 Compiler : MSC v.1944 64 bit (AMD64) OS : Windows Release : 10 Machine : AMD64 Processor : Intel64 Family 6 Model 170 Stepping 4, GenuineIntel CPU cores : 22 Architecture: 64bit
%watermark -h -iv -co
conda environment: 30DayMapChallenge2025 Hostname: INSPIRON16 IPython: 9.10.1 imageio: 2.37.0
import contextlib
import ipynbname
with contextlib.suppress(FileNotFoundError):
print(f"Notebook file name: {ipynbname.name()}")
# end with
Notebook file name: making_movie