Animation of sparks on the back of my childhood fireplace
When I was a child, we had a log fire in the front room. The back of the fireplace angled forwards in a way that caused it to get covered in a thin layer of black soot. Every so often a spark would rise from the fire and land on the black soot, causing a chain reaction of particles of soot glowing red-orange. The spark grew like a tree on the back of the fireplace, and the effect never ceased to fascinate me.
I got my first computer around the age of eleven, and I had started to learn about recursion and fractal patterns. I saw those mathematical patterns appearing like magic on the back of the fireplace. The fact that these patterns are an integral part of nature was literally written in stone in the fireplace — the fireplace was made of limestone, and contained what I now know to be crystal dendrites, which mirrored the patterns I was seeing in the glowing carbon.
Manganese dendrites on a limestone bedding plane from Solnhofen, Germany. Source: Wikipedia.
This is how I imagine ideas traveling through the brain, signals passing from one neuron to another. New optogenetic techniques allow us to actually see this happening. Here’s a time-lapse of neural activity in a living zebrafish:
Creation of the animation
I created the animation with the help of GPT-o1. Since I want to do more of this kind of animation in the future, firstly I got it to create a control script which I can use to generate animations by writing a series of frames which I can then stitch together using FFmpeg:
vidgen.py
#!/usr/bin/env python3
import os
import importlib
from pathlib import Path
import click
from rich.progress import Progress
@click.command()
@click.argument("animation_script", type=str)
@click.argument("num_frames", type=int)
@click.argument("output_dir", type=str)
def main(animation_script, num_frames, output_dir):
"""
Generate a series of PNG frames (1920x1080) using a specified animation script.
\b
Arguments:
ANIMATION_SCRIPT: The name of the sub-script (e.g. 'conway')
NUM_FRAMES: The number of frames to generate
OUTPUT_DIR: The directory to save the frames
"""
# 1. Dynamically import the sub-script
try:
= importlib.import_module(animation_script)
module except ModuleNotFoundError:
f"Error: Could not find module '{animation_script}'. Is it in your PYTHONPATH?")
click.echo(raise
# 2. Create the output directory if it doesn't exist
= Path(output_dir)
out_path =True, exist_ok=True)
out_path.mkdir(parents
# 3. Initialize the animation
# This function should return an initial 'state' (could be None if not needed)
f"Initializing animation module '{animation_script}'...")
click.echo(= module.init_animation(num_frames=num_frames, width=1920, height=1080)
state
# 4. Generate the frames in a loop
f"Generating {num_frames} frames in '{output_dir}'...")
click.echo(with Progress() as progress:
= progress.add_task("Rendering frames...", total=num_frames)
task for frame_idx in range(num_frames):
# 4a. Call the animation’s function to update state and render a frame
= module.create_frame(frame_idx, state)
state, image
# 4b. Save the image to disk with zero-padded filename
= out_path / f"frame_{frame_idx:05d}.png"
filename
image.save(filename)
=1)
progress.update(task, advance
"All frames generated successfully!")
click.echo(f"Use an FFmpeg command like:\n\n"
click.echo(f" ffmpeg -framerate 30 -i {output_dir}/frame_%05d.png "
f"-c:v libx264 -pix_fmt yuv420p output.mp4\n")
if __name__ == "__main__":
main()
As part of this process I got it to create a template for generating the individual frames:
animation_template.py
"""
Template for a Python animation script to be used with vidgen.py.
Your 'vidgen.py' main script expects two functions:
1) init_animation(num_frames, width, height) -> state
2) create_frame(frame_idx, state) -> (new_state, image)
Where:
- 'state' is any Python object or dict containing animation data.
- 'frame_idx' is the integer index of the frame (0 to num_frames-1).
- 'image' should be a Pillow Image of size (width x height).
"""
from PIL import Image, ImageDraw
def init_animation(num_frames: int, width: int, height: int):
"""
Initialize and return the animation's state.
Parameters:
-----------
num_frames : int
The total number of frames that will be rendered for this animation.
width : int
The width (in pixels) of the output video or frames (e.g., 1920).
height : int
The height (in pixels) of the output video or frames (e.g., 1080).
Returns:
--------
state : dict (or any object)
A Python object containing all necessary data to render each frame.
This might include:
- Simulation grids
- Position/velocity of objects
- Color/gradient parameters
- Any other animation-specific variables
"""
# Example: We'll store some placeholders in a dict
= {
state "width": width,
"height": height,
"current_value": 0, # placeholder variable for demonstration
# Add any other variables you need...
}
# Return the initial state
return state
def create_frame(frame_idx: int, state):
"""
Generate and return the frame (as a Pillow Image) for the given frame index,
plus any updated state.
Parameters:
-----------
frame_idx : int
The index of the frame being generated (0-based).
state : dict (or any object)
The current state of the animation, as returned by init_animation or
by the previous create_frame call.
Returns:
--------
(new_state, image) : (dict, PIL.Image)
A tuple containing:
1) The updated state (which can be used to render subsequent frames).
2) A Pillow Image object of size (state["width"], state["height"])
representing the newly rendered frame.
"""
# 1) Update animation state for this frame
# (Example: increment a placeholder variable)
= state["current_value"] + 1
new_value
# 2) Create a new blank image of the correct size
= state["width"]
width = state["height"]
height = Image.new("RGB", (width, height), "black")
image
# 3) Draw something on the image (for now, just a white circle that grows)
= ImageDraw.Draw(image)
draw = 10 + new_value * 5 # arbitrary formula to illustrate motion
radius = (width // 2, height // 2)
center = [
bounding_box 0] - radius, center[1] - radius,
center[0] + radius, center[1] + radius
center[
]="white")
draw.ellipse(bounding_box, fill
# 4) Construct the new state object
= {
new_state "width": width,
"height": height,
"current_value": new_value,
# Copy over any other needed info from 'state'
}
# Return the updated state and the generated image
return new_state, image
Once I had done that, I got it to generate the specific script for this simulation.