Building a DIY “Bullet Time” Rig with Networked Raspberry Pis

Ever since The Matrix, the “bullet time” effect has been an iconic piece of cinematography. It’s that mesmerizing shot where time freezes, and the camera glides around the subject. Traditionally, this required dozens of expensive cameras on a custom, million-dollar rig.

Today? We can build a surprisingly capable version ourselves for a fraction of the cost using a cluster of Raspberry Pis and their camera modules.

This post is a technical deep-dive into the architecture, software, and synchronization challenges of building a networked Raspberry Pi “camera array.” We’ll cover the master/slave architecture, the critical problem of synchronization, and provide example code using Python, ZeroMQ, and Flask to trigger and collect images simultaneously.

 

🏛️ The Core Architecture: Master/Slave

A bullet time rig is a classic distributed system. You can’t just have 50 Pis randomly taking pictures; you need a central conductor. This gives us a Master/Slave (or Controller/Worker) architecture.

  • Master Node (1x Pi): This Pi acts as the “brain.” It doesn’t have a camera. Its jobs are:
    1. To provide a user interface (e.g., a web page) to send the “CAPTURE” command.
    2. To broadcast the trigger signal to all slaves simultaneously.
    3. To act as a central server to receive and store the images from all slaves.
  • Slave Nodes (N x Pis): These are your “camera” Pis. Each one is identical. Its jobs are:
    1. To listen for the trigger command from the master.
    2. Upon receiving the trigger, to capture a single high-resolution image immediately.
    3. To send that captured image back to the master for processing.

This architecture looks simple, but it hides the single most difficult part of this project: synchronization.


 

⏱️ The Synchronization Problem (The Hard Part)

If you have a subject in motion (a person jumping, water splashing), “simultaneously” doesn’t mean “within the same second.” It means “within the same millisecond.”

Let’s say your network has 20ms of jitter (variance in latency). If Pi #1 captures at T=0ms and Pi #2 captures at T=20ms, your jumping subject will have moved. When you stitch the photos together, the subject will appear to jitter or “ghost,” and the effect will be ruined.

Here are a few ways to solve this, from bad to good:

  1. Bad: HTTP POST / SSH Loop: The master loops through a list of slave IPs and sends an ssh command or HTTP request. This is a sequential trigger. By the time it reaches the last Pi, seconds may have passed. This is useless for motion.
  2. Okay: Simple UDP Broadcast: The master sends a single UDP packet to the network’s broadcast address. This is much faster and parallel. However, UDP is “fire-and-forget”—packets can be lost, and network switches can treat broadcast traffic differently, re-introducing jitter.
  3. Good: Network Time Protocol (NTP) + Scheduled Trigger: Set up the master Pi as a local NTP server. Have all slaves sync their clocks obsessively to this master. The trigger command then becomes: “Capture a photo at timestamp T+500ms.” This way, even if the command arrives at slightly different times, all Pis are aligned to their (now-synced) internal clocks. This is a very robust software-only approach.
  4. Better: ZeroMQ Pub/Sub: This is our chosen method. We use ZeroMQ (ZMQ), a high-performance messaging library. The master acts as a PUB (publisher), and all slaves SUB (subscribe). ZMQ handles the networking complexities to provide a very low-latency, reliable one-to-many broadcast. It’s significantly faster and more reliable than raw sockets.
  5. Best: GPIO (Hardware) Trigger: The “professional” DIY solution. You run a physical wire from a GPIO pin on the master to a GPIO pin on every single slave. When the master sets its pin to HIGH, all slaves see the signal at (literally) the speed of light. This is the most precise, but it’s a wiring nightmare for 50+ cameras.

For this guide, we’ll use Method 4 (ZeroMQ) as it provides the best balance of performance and setup simplicity. We’ll combine it with Method 3 (NTP) for robust time-keeping.


 

🛠️ System Setup & Configuration

Before the code, let’s set up the environment.

  1. Hardware:
    • Master: 1x Raspberry Pi (Pi 4/5 recommended) with Ethernet.
    • Slaves: N x Raspberry Pis (Pi Zero 2 W is a great, cheap choice) with Pi Camera Modules (v2 or v3).
    • Network: A dedicated Ethernet switch. Avoid Wi-Fi. Wi-Fi latency and jitter are far too high for this.
    • Power: A very good powered USB hub or multi-port power supply. 50 Pis draw a lot of current.
    • Rig: A physical mount. This can be 3D printed, built from wood, or made with aluminum extrusion.
  2. OS & Networking:
    • Flash all SD cards with Raspberry Pi OS Lite (64-bit).
    • Set up static IPs for every Pi. This is critical.
      • Master: 192.168.1.100
      • Slave 1: 192.168.1.101
      • Slave 2: 192.168.1.102
      • …etc.
    • Enable the camera on all slaves: sudo raspi-config -> Interface Options -> Legacy Camera.
    • Set up SSH keys for passwordless access from the master to the slaves.
  3. Time Synchronization (NTP):
    • On the master (192.168.1.100), install and configure an NTP server:

      Bash

      sudo apt-get update
      sudo apt-get install chrony -y
      
    • Edit /etc/chrony/chrony.conf on the master and add this line to allow slaves to connect:

      Code snippet

      allow 192.168.1.0/24
      
    • Restart the service: sudo systemctl restart chrony
    • On all slaves, edit /etc/chrony/chrony.conf and point it to the master:

      Code snippet

      # Comment out the default 'pool' lines
      # pool 2.debian.pool.ntp.org iburst
      
      # Add your master
      server 192.168.1.100 iburst
      
    • Restart chrony on the slaves. You can check the sync status with chronyc sources.
  4. Software Dependencies (on all Pis):

    Bash

    sudo apt-get install python3-pip python3-picamera2 -y # Use picamera2
    pip3 install flask pyzmq requests werkzeug
    

 

🐍 The Code: Master & Slave

We need two main scripts. The master.py script runs a Flask web server and a ZMQ publisher. The slave.py script runs a ZMQ subscriber and a camera controller.

 

1. The Slave (slave.py)

This script runs on every camera Pi. It connects to the master’s ZMQ publisher and waits. When it receives a message (which we’ll use as a “job ID”), it immediately takes a picture and then uploads it to the master’s Flask server.

Important: You must manually edit CAMERA_ID on each slave to be unique (e.g., cam_01, cam_02) so the final images are sorted correctly.

Python

# slave.py
# Run this on every single camera Pi

import zmq
import time
import os
import requests
from picamera2 import Picamera2
from libcamera import controls

# --- CONFIGURATION ---
MASTER_IP = "192.168.1.100"  # Static IP of your Master Pi
ZMQ_PORT = 5556             # ZMQ Publisher port
UPLOAD_PORT = 5000          # Flask server port on master
CAMERA_ID = "cam_01"        # !!! SET THIS MANUALLY ON EACH PI (e.g., cam_01, cam_02)
# --- END CONFIGURATION ---

def initialize_camera():
    """Configures and warms up the camera."""
    picam2 = Picamera2()
    
    # Configure for high-res still capture
    config = picam2.create_still_configuration()
    picam2.configure(config)
    
    # Set AF mode to continuous
    picam2.set_controls({"AfMode": controls.AfModeEnum.Continuous})
    
    picam2.start()
    
    # Give camera 2s to warm up and focus
    print("Camera warming up...")
    time.sleep(2)
    print("Camera ready.")
    return picam2

def connect_to_master():
    """Connects to the master ZMQ publisher."""
    context = zmq.Context()
    socket = context.socket(zmq.SUB)
    socket.connect(f"tcp://{MASTER_IP}:{ZMQ_PORT}")
    # Subscribe to all messages (empty topic)
    socket.setsockopt(zmq.SUBSCRIBE, b"")
    print(f"Connected to master at {MASTER_IP}. Awaiting trigger...")
    return socket

def main():
    picam2 = initialize_camera()
    socket = connect_to_master()
    upload_url = f"http://{MASTER_IP}:{UPLOAD_PORT}/upload"

    while True:
        try:
            # Block and wait for a trigger message from the master
            # The message content will be our unique Job ID
            job_id = socket.recv_string()
            
            print(f"--- TRIGGER RECEIVED! Job ID: {job_id} ---")
            
            # --- CRITICAL CAPTURE SECTION ---
            # This is the most time-sensitive part.
            start_time = time.time_ns()
            filename = f"{job_id}_{CAMERA_ID}.jpg"
            filepath = f"/tmp/{filename}"
            
            # Capture the image to a file
            picam2.capture_file(filepath)
            
            end_time = time.time_ns()
            print(f"Image captured: {filename} (took {(end_time - start_time) / 1_000_000:.2f} ms)")
            # --- END CRITICAL SECTION ---

            # Offload the upload to a new thread/process?
            # For simplicity, we do it sequentially.
            # A more advanced system would queue this.
            
            print(f"Uploading {filename} to master...")
            with open(filepath, 'rb') as f:
                files = {'file': (filename, f, 'image/jpeg')}
                try:
                    response = requests.post(upload_url, files=files, timeout=10)
                    if response.status_code == 200:
                        print("Upload successful.")
                        os.remove(filepath)  # Clean up tmp file
                    else:
                        print(f"Upload failed: {response.text}")
                except requests.exceptions.RequestException as e:
                    print(f"Upload connection error: {e}")

        except KeyboardInterrupt:
            break
        except Exception as e:
            print(f"An error occurred: {e}")
            time.sleep(1) # Prevent spamming errors

    print("Shutting down...")
    picam2.stop()
    socket.close()

if __name__ == "__main__":
    main()

 

2. The Master (master.py)

This script does two things at once:

  1. ZMQ Publisher: Binds to a port, ready to broadcast a trigger.
  2. Flask Web Server: Serves a simple HTML button and provides an /upload endpoint for the slaves to send their pictures to.

Python

# master.py
# Run this on the single Master Pi

import zmq
import time
from flask import Flask, request, render_template, redirect, url_for
import os
from werkzeug.utils import secure_filename

# --- CONFIGURATION ---
ZMQ_PORT = 5556
UPLOAD_PORT = 5000
UPLOAD_FOLDER = 'captures'
# --- END CONFIGURATION ---

# Ensure upload folder exists
if not os.path.exists(UPLOAD_FOLDER):
    os.makedirs(UPLOAD_FOLDER)

# --- ZeroMQ Setup (Publisher) ---
context = zmq.Context()
zmq_socket = context.socket(zmq.PUB)
zmq_socket.bind(f"tcp://*:{ZMQ_PORT}")
print(f"ZMQ Publisher bound to port {ZMQ_PORT}")

# --- Flask Web Server Setup ---
app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER

@app.route('/')
def index():
    """Serves the main trigger page."""
    # Simple HTML page with a trigger button
    return """
    <html>
        <head>
            <title>Pi Bullet Time Trigger</title>
            <style>
                body { font-family: sans-serif; display: grid; place-items: center; min-height: 80vh; background: #222; color: #eee; }
                button { font-size: 3rem; padding: 2rem; cursor: pointer; border: none; border-radius: 10px; background: #008CBA; color: white; }
                h1 { font-weight: 300; }
            </style>
        </head>
        <body>
            <h1>Pi Bullet Time Control</h1>
            <form action="/trigger" method="POST">
                <button type="submit">!! TRIGGER CAPTURE !!</button>
            </form>
        </body>
    </html>
    """

@app.route('/trigger', methods=['POST'])
def trigger_capture():
    """Sends the ZMQ broadcast to all slaves."""
    try:
        # Use a high-resolution nanosecond timestamp as the unique Job ID
        job_id = f"{time.time_ns()}"
        
        print(f"\n--- TRIGGERING CAPTURE ---")
        print(f"Broadcasting Job ID: {job_id}")

        # Send the job ID as the trigger message
        # We send it a few times just to be safe (overcomes ZMQ's "slow joiner" syndrome)
        for _ in range(3):
            zmq_socket.send_string(job_id)
            time.sleep(0.01) # Small delay between sends

        print(f"Trigger {job_id} sent.")
        # Redirect back to the home page
        return redirect(url_for('index'))
    except Exception as e:
        print(f"Error triggering: {e}")
        return str(e), 500

@app.route('/upload', methods=['POST'])
def upload_file():
    """Receives images from slave nodes."""
    if 'file' not in request.files:
        return 'No file part', 400
    file = request.files['file']
    if file.filename == '':
        return 'No selected file', 400
    if file:
        filename = secure_filename(file.filename)
        
        # We can parse the job_id from the filename
        try:
            job_id = filename.split('_')[0]
            # Create a sub-directory for this capture job
            job_folder = os.path.join(app.config['UPLOAD_FOLDER'], job_id)
            if not os.path.exists(job_folder):
                os.makedirs(job_folder)
            
            save_path = os.path.join(job_folder, filename)
            file.save(save_path)
            print(f"Received file: {filename}")
            return 'Upload successful', 200
        except Exception as e:
            print(f"Error saving file '{filename}': {e}")
            return 'Error saving file', 500

if __name__ == '__main__':
    print(f"Starting Flask server on http://0.0.0.0:{UPLOAD_PORT}")
    # Run the Flask app
    app.run(host='0.0.0.0', port=UPLOAD_PORT, debug=False)


 

🏃 Running the System

  1. On all slave Pis, run the slave script:

    Bash

    cd /path/to/script/
    python3 slave.py
    

    (You should set this up as a systemd service to run on boot). You will see “Connected to master. Awaiting trigger…”

  2. On the master Pi, run the master script:

    Bash

    cd /path/to/script/
    python3 master.py
    

    You will see the ZMQ and Flask servers start.

  3. From any computer on the network, open a web browser and go to the master’s IP: http://192.168.1.100:5000.
  4. Click the “!! TRIGGER CAPTURE !!” button.
  5. You will see an explosion of activity in your terminals:
    • The master will print “TRIGGERING CAPTURE”.
    • All slaves will simultaneously print “TRIGGER RECEIVED!” followed by “Image captured”.
    • The master will then print a “Received file:” log for every slave as the images roll in.
  6. Check the captures/ directory on your master Pi. You’ll find a new folder named after the job_id (a long timestamp), and inside, all your images:
    captures/
    └── 1678886400123456789/
        ├── 1678886400123456789_cam_01.jpg
        ├── 1678886400123456789_cam_02.jpg
        ├── 1678886400123456789_cam_03.jpg
        └── ...
    

 

🎬 Post-Processing with FFmpeg

You now have a folder of sequentially named images. The final step is to stitch them into a video. FFmpeg is the tool for this.

Run this command on your master Pi (or on a more powerful desktop after copying the files) to create the video:

Bash

# Replace with your actual Job ID
JOB_ID="1678886400123456789"
INPUT_DIR="captures/${JOB_ID}"
OUTPUT_FILE="bullet_time_${JOB_ID}.mp4"

# Use -pattern_type glob to grab all JPGs in order
# -framerate 30 sets the output video to 30fps (each image is 1/30th of a sec)
# -c:v libx264 is the video codec
# -pix_fmt yuv420p ensures compatibility
ffmpeg -framerate 30 -pattern_type glob -i "${INPUT_DIR}/*.jpg" -c:v libx264 -pix_fmt yuv420p ${OUTPUT_FILE}

And that’s it! You’ll have an output.mp4 file of your first bullet-time shot.

 

📚 References and Further Reading

 

💡 Next Steps & Challenges

This is a robust foundation, but the rabbit hole goes deeper.

  • Focus & Exposure: All your cameras need to have the exact same focus and exposure settings. You can set these manually in the initialize_camera() function on the slaves.
  • Calibration: The real professional rigs use complex software (like Agisoft Metashape or RealityCapture) to 3D-calibrate the exact position and lens distortion of every camera. This allows for much smoother virtual camera paths.
  • Lighting: Your subject needs to be brightly and evenly lit. This allows for a fast shutter speed, which is essential for freezing motion.

Building a Pi bullet time rig is a fantastic project that combines networking, hardware, and software. Good luck, and share your results!