Animation of sparks on the back of my childhood fireplace

animation
highlight
I created an animation of sparks on the back of my childhood fireplace
Published

February 9, 2025

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:
        module = importlib.import_module(animation_script)
    except ModuleNotFoundError:
        click.echo(f"Error: Could not find module '{animation_script}'. Is it in your PYTHONPATH?")
        raise

    # 2. Create the output directory if it doesn't exist
    out_path = Path(output_dir)
    out_path.mkdir(parents=True, exist_ok=True)

    # 3. Initialize the animation
    #    This function should return an initial 'state' (could be None if not needed)
    click.echo(f"Initializing animation module '{animation_script}'...")
    state = module.init_animation(num_frames=num_frames, width=1920, height=1080)

    # 4. Generate the frames in a loop
    click.echo(f"Generating {num_frames} frames in '{output_dir}'...")
    with Progress() as progress:
        task = progress.add_task("Rendering frames...", total=num_frames)
        for frame_idx in range(num_frames):
            # 4a. Call the animation’s function to update state and render a frame
            state, image = module.create_frame(frame_idx, state)

            # 4b. Save the image to disk with zero-padded filename
            filename = out_path / f"frame_{frame_idx:05d}.png"
            image.save(filename)

            progress.update(task, advance=1)

    click.echo("All frames generated successfully!")
    click.echo(f"Use an FFmpeg command like:\n\n"
               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)
    new_value = state["current_value"] + 1

    # 2) Create a new blank image of the correct size
    width = state["width"]
    height = state["height"]
    image = Image.new("RGB", (width, height), "black")

    # 3) Draw something on the image (for now, just a white circle that grows)
    draw = ImageDraw.Draw(image)
    radius = 10 + new_value * 5  # arbitrary formula to illustrate motion
    center = (width // 2, height // 2)
    bounding_box = [
        center[0] - radius, center[1] - radius,
        center[0] + radius, center[1] + radius
    ]
    draw.ellipse(bounding_box, fill="white")

    # 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.