Raspberry Pi – Snap and Stitch controller – HD Camera

   IMORTANT – Bug Alert- I recently discovered a bug in Trixie, Bookworm and possibly bullseye that the camera stack is half complete in Trixie and Bookworm and this script may only work in Bullseye!!!! Picamera2 and a host of other issues mean that libcamera  more specifically will return file or folder not found!!! you can check if libcamera is working and installed by typing this into a terminal window :  /usr/bin/libcamera-hello   

  if you get a constant stream of 30fps then libcamera is working, if you get folder not found then STOP!!! this code won’t work for you…..ggrrrr

 One of my early experiments as a youngster was timelapse photography. I had donned an 8mm cine‑camera and attempted to take stills using the single‑frame button; however, I was young and very short on patience and discipline… needless to say, the videos are just 5 seconds long and lacked any professionalism. Today, in the world of digital photography, we have better storage, better tripods, better code, and simplistic controls to assist us.

   My favourite is a seasonal trip to the supermarket or garden centre to buy a £1 daffodil, for example. Set up a timelapse camera and snap away for a month. The winter is just turning into spring, and I have my 2026 sample plant in the wings waiting for my latest code to unleash its capabilities.

   So please find below a program I’m calling “Raspberry Pi – Snap and Stitch.”

   The Python script allows you to have a preview to focus the camera, adjust the time delay between captures, set the duration (up to a month), stitch the video together with the ffmpeg compiler, embed timestamps in the corner of the image, and also has a neat calculator for telling you—estimated—how many images will be taken for how many seconds or minutes over what duration. Then a handy little prompt will tell you if the capture is unsafe or safe to run. Example: snapping at 120 seconds over a duration of 1 month equates to 21,600 images or a whopping 27.63 GB of images. Ouch! So it is important that we get a general idea of what’s actually required for each type of timelapse.

   A video of a plant growing won’t yield good results and will have your “plant‑watching audience” hitting the snooze button pretty sharpish if you set the time for 60 seconds. You require around 24 frames (images) for 1 second of footage for the animation to look smooth and controlled. Now, the daffodils I have grow seriously rapidly in the daytime— as much as an inch or two per day, or up to 50 mm per day. So we need a rate that will capture a good amount of footage and show growth. Naturally, slower plants will require longer frames if you wish to keep your audience glued rather than snoozed if you get this wrong.

   Five minutes, or 300 seconds, is 12 frames per hour, or 2 hours of footage, and 24 frames for 1 second! So we have missed quite a bit of growing, and the plant will rocket up in a very small amount of time. In a week, and taking into account that plants do not grow that much at night (my experiments have shown moonlight in full‑moon conditions does yield small amounts of growth), that’s 12 seconds per day including nighttime. So we can say watching a week’s growth will be 84 seconds, or just over a minute. Not bad, but a lot of work for a minute‑long video. So we can start to see why adjusting our video makes more sense in the long run, as these plants tend to hit full bloom in around 2 weeks with little growth beyond that. They don’t die; they just don’t grow too much and tend to sway around as the sunlight passes overhead.

   Now we can make a slightly longer video with beautiful backing music and enjoy a month’s growth over 5 minutes. That’s a very fine line between captivating your audience and sending them to sleep. So you must take into consideration the growth of your selected plant and the duration of your overall video, and then calculate your time between frames accordingly. Without this, you will make videos that have plants either creeping up in femtoseconds or shooting off the screen in the blink of an eye, and an equilibrium is needed to get this right.

   OK, armed and ready for the two scripts. The first is named snap.py and carries all the hard work for capture and adjustments; the second is stitch.py and is needed to stitch all the captured frames into a complete MP4 video. snap.py and stitch.py must be placed in the same folder! For me, that’s /home/tony/timelapse. As I am using another project’s Raspberry Pi while I wait for parts—and that’s coming soon (the swimming pool monitor).

   You will need to install Tkinter and PiCamera2 for this to work, and also have a camera attached to the Pi’s CSI camera port. I have used an HD camera, and the framing size is 4056 × 3040 using the Sony IMX477 image sensor.

   Lastly, BE PATIENT! Compressing multiple images into a single MP4 is very resource‑hungry. I am using a Raspberry Pi 4 (4 GB), and just compressing around 100 images took 4 minutes! So a Pi 3B+ might take double this, and compressing 10,000 images will take significantly longer. A progress bar bounces up and down to show you the app has not crashed, but you must let the Pi do its work or process the images with another PC or platform.

 

snap.py

***********************************************************************************************

 

import os
import time
import subprocess
from datetime import datetime
from threading import Thread, Event
from picamera2 import Picamera2
from PIL import Image, ImageDraw, ImageFont, ImageTk
import tkinter as tk
from tkinter import filedialog, ttk
class TimeLapseGUI:
    def __init__(self, root):
        self.root = root
        self.root.title(“G5STO’s – Raspberry Pi Timelapse Controller”)
        # Camera setup
        self.picam = Picamera2()
        # Use a more robust preview configuration with a dedicated low-res stream
        # for the UI preview. This is more stable for long-running applications
        # and more efficient than capturing full-resolution frames for the preview.
        config = self.picam.create_preview_configuration(
            # Use RGB888 for the main stream to avoid RGBA conversion issues when saving JPEGs.
            main={“size”: (4056, 3040), “format”: “RGB888”},
            lores={“size”: (640, 480), “format”: “YUV420”}
        )
        self.picam.configure(config)
        self.picam.start()
        # State
        self.running = Event()
        self.save_dir = os.path.expanduser(“~/timelapse”)
        os.makedirs(self.save_dir, exist_ok=True)
        self.output_dir = os.path.expanduser(“~/timelapse_videos”)
        os.makedirs(self.output_dir, exist_ok=True)
        # Font for timestamp
        self.font = ImageFont.truetype(
            “/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf”, 24
        )
        # — GUI Layout —
        # Folder selector
        tk.Button(root, text=”Select Image Folder”, command=self.choose_folder).pack(pady=5)
        self.folder_label = tk.Label(root, text=f”Images: {self.save_dir}”)
        self.folder_label.pack()
        # Output folder selector
        tk.Button(root, text=”Select Output Folder”, command=self.choose_output_folder).pack(pady=5)
        self.output_label = tk.Label(root, text=f”Output: {self.output_dir}”)
        self.output_label.pack()
        # Interval Spinbox
        tk.Label(root, text=”Delay between shots (seconds)”).pack()
        self.interval = tk.Spinbox(root, from_=1, to=86400, width=8)
        self.interval.delete(0, tk.END)
        self.interval.insert(0, “10”)
        self.interval.pack(pady=5)
        # Bind interval changes
        self.interval.bind(“<KeyRelease>”, self.on_interval_change)
        self.interval.bind(“<<Increment>>”, self.on_interval_change)
        self.interval.bind(“<<Decrement>>”, self.on_interval_change)
        # Duration presets
        tk.Label(root, text=”Timelapse Duration”).pack()
        self.duration_box = ttk.Combobox(
            root,
            values=[
                “1 hour”,
                “6 hours”,
                “12 hours”,
                “1 day”,
                “3 days”,
                “1 week”,
                “2 weeks”,
                “3 weeks”,
                “1 month”
            ],
            state=”readonly”
        )
        self.duration_box.current(5)
        self.duration_box.pack(pady=5)
        self.duration_box.bind(“<<ComboboxSelected>>”, self.on_duration_change)
        # Timestamp toggle
        self.timestamp_var = tk.BooleanVar(value=True)
        tk.Checkbutton(
            root,
            text=”Embed UK timestamp on images”,
            variable=self.timestamp_var
        ).pack(pady=5)
        # Live preview
        self.preview_label = tk.Label(root)
        self.preview_label.pack(pady=10)
        # Storage calculator
        self.calc_label = tk.Label(root, text=”Storage estimate: –“)
        self.calc_label.pack(pady=5)
        self.free_label = tk.Label(root, text=”Free space: –“)
        self.free_label.pack(pady=5)
        self.safe_label = tk.Label(root, text=””, fg=”green”)
        self.safe_label.pack(pady=5)
        # Start / Stop buttons
        tk.Button(root, text=”Start Timelapse”, command=self.start).pack(pady=10)
        tk.Button(root, text=”Stop Timelapse”, command=self.stop).pack()
        # Create Video Now button
        self.video_button = tk.Button(root, text=”Create Video Now”, command=self.create_video)
        self.video_button.pack(pady=10)
        # Progress bar
        self.progress = ttk.Progressbar(root, orient=”horizontal”, mode=”indeterminate”, length=300)
        self.progress.pack(pady=5)
        # Status label
        self.status = tk.Label(root, text=”Idle”, fg=”blue”)
        self.status.pack(pady=10)
        # Start preview + calculator loops
        self.update_preview()
        self.update_calculator()
    # — Folder selection —
    def choose_folder(self):
        folder = filedialog.askdirectory()
        if folder:
            self.save_dir = folder
            self.folder_label.config(text=f”Images: {self.save_dir}”)
            self.update_calculator()
    def choose_output_folder(self):
        folder = filedialog.askdirectory()
        if folder:
            self.output_dir = folder
            self.output_label.config(text=f”Output: {self.output_dir}”)
    # — Start/Stop —
    def start(self):
        if not self.running.is_set():
            self.running.set()
            Thread(target=self.capture_loop, daemon=True).start()
            self.status.config(text=”Running…”, fg=”green”)
    def stop(self):
        self.running.clear()
        self.status.config(text=”Stopped”, fg=”red”)
    # — Live Preview —
    def update_preview(self):
        try:
            # Capture from the efficient low-resolution stream for the preview
            frame = self.picam.capture_array(“lores”)
            img = Image.fromarray(frame)
            img = img.resize((400, 300), Image.LANCZOS)
            tk_img = ImageTk.PhotoImage(img)
            self.preview_label.configure(image=tk_img)
            self.preview_label.image = tk_img
        except Exception as e:
            print(f”Error in preview update: {e}”)
        self.root.after(100, self.update_preview)
    # — Storage Calculator —
    def get_duration_seconds(self):
        mapping = {
            “1 hour”: 3600,
            “6 hours”: 6 * 3600,
            “12 hours”: 12 * 3600,
            “1 day”: 24 * 3600,
            “3 days”: 3 * 24 * 3600,
            “1 week”: 7 * 24 * 3600,
            “2 weeks”: 14 * 24 * 3600,
            “3 weeks”: 21 * 24 * 3600,
            “1 month”: 30 * 24 * 3600
        }
        return mapping.get(self.duration_box.get(), 7 * 24 * 3600)
    def get_actual_avg_size(self):
        files = sorted(
            [f for f in os.listdir(self.save_dir) if f.endswith(“.jpg”)],
            reverse=True
        )[:10]
        if not files:
            return 0.15  # fallback 150 KB
        sizes = [
            os.path.getsize(os.path.join(self.save_dir, f)) / (1024 * 1024)
            for f in files
        ]
        return sum(sizes) / len(sizes)
    def update_calculator(self):
        try:
            interval = max(1, int(self.interval.get()))
        except ValueError:
            interval = 1
        duration = self.get_duration_seconds()
        avg_size_mb = self.get_actual_avg_size()
        frames = duration // interval
        total_mb = frames * avg_size_mb
        total_gb = total_mb / 1024
        self.calc_label.config(
            text=f”Estimated: {frames} images ≈ {total_gb:.2f} GB”
        )
        stat = os.statvfs(self.save_dir)
        free_gb = (stat.f_bavail * stat.f_frsize) / (1024**3)
        self.free_label.config(text=f”Free space: {free_gb:.2f} GB”)
        if free_gb > total_gb:
            self.safe_label.config(text=”Safe to run”, fg=”green”)
        else:
            self.safe_label.config(text=”Not enough space!”, fg=”red”)
        # The calculator is updated on any relevant user input change.
        # Calling it on a fast timer with file I/O can block the UI thread
        # and potentially contribute to camera timeouts. Disabling this.
        # self.root.after(500, self.update_calculator)
    def on_interval_change(self, event=None):
        self.update_calculator()
    def on_duration_change(self, event=None):
        self.update_calculator()
    # — Create Video Now —
    def create_video(self):
        self.video_button.config(state=”disabled”)
        self.progress.start(10)
        self.status.config(text=”Creating video…”, fg=”blue”)
        Thread(target=self.run_stitch, daemon=True).start()
    def run_stitch(self):
        try:
            output_file = os.path.join(
                self.output_dir,
                f”timelapse_{datetime.now().strftime(‘%Y%m%d_%H%M%S’)}.mp4″
            )
            subprocess.run([
                “python3”, “stitch.py”,
                “–input”, self.save_dir,
                “–output”, output_file,
                “–fps”, “24”
            ])
            self.status.config(text=f”Video saved: {output_file}”, fg=”green”)
        except Exception as e:
            self.status.config(text=f”Video creation failed: {e}”, fg=”red”)
        finally:
            self.progress.stop()
            self.video_button.config(state=”normal”)
    # — Capture Loop —
    def capture_loop(self):
        while self.running.is_set():
            timestamp_raw = datetime.now()
            timestamp_text = timestamp_raw.strftime(“%d/%m/%Y %H:%M:%S”)
            filename_safe = timestamp_raw.strftime(“%Y%m%d_%H%M%S”)
            filepath = os.path.join(self.save_dir, f”img_{filename_safe}.jpg”)
            # Capture from the high-resolution main stream for the saved image
            frame = self.picam.capture_array(“main”)
            img = Image.fromarray(frame)
            if self.timestamp_var.get():
                draw = ImageDraw.Draw(img)
                draw.text((10, img.height – 35), timestamp_text, font=self.font, fill=(255, 255, 255))
            img.save(filepath, “JPEG”)
            self.status.config(text=f”Captured: {filepath}”, fg=”green”)
            try:
                delay = max(1, int(self.interval.get()))
            except ValueError:
                delay = 1
            time.sleep(delay)
root = tk.Tk()
app = TimeLapseGUI(root)
root.mainloop()

**************************************************************************************************************************

stitch.py

***************************************************************************************************************************

import os
import argparse
import subprocess

parser = argparse.ArgumentParser()
parser.add_argument(“–input”, required=True, help=”Folder containing timelapse images”)
parser.add_argument(“–output”, required=True, help=”Output MP4 file path”)
parser.add_argument(“–fps”, default=”24″, help=”Frames per second”)
parser.add_argument(“–resolution”, help=”WxH output resolution”, default=None)
args = parser.parse_args()

image_folder = args.input
output_video = args.output
fps = args.fps

os.chdir(image_folder)

cmd = [
“ffmpeg”,
“-y”,
“-framerate”, fps,
“-pattern_type”, “glob”,
“-i”, “img_*.jpg”
]

if args.resolution:
cmd.extend([“-vf”, f”scale={args.resolution}”])

cmd.extend([
“-c:v”, “libx264”,
“-pix_fmt”, “yuv420p”,
output_video
])

print(“Running ffmpeg…”)
subprocess.run(cmd)
print(“Done! Video saved as:”, output_video)

************************************************************************************************************