Tech With Galvan
Back to Articles
Development6/16/2025 • 3 min read

Webcam Photo Booth using Python, Streamlit, and OpenCV

Snap photos from your webcam with Python, Streamlit, and OpenCV — live filters, a photo strip gallery, and PNG downloads.

Galvan
Galvan

Founder & Creator

Introduction

A photo booth is the most fun you can have with OpenCV’s video capture: snap a frame from your webcam, slap a filter on it, and collect the results in a gallery. Under the hood it teaches the browser-camera-to-Python pipeline — the same one powering every video-call app — using the streamlit-webrtc component for live frames.

This is the visual sibling of the audio recorder: browser hardware, Python processing, instant playback. And the filters reuse the transforms from the Pillow image processing tutorial.

Features

  • Live webcam preview — real-time frames via streamlit-webrtc.
  • One-click capture — freeze the current frame into a photo.
  • Filters — grayscale, sepia, and sketch modes applied on capture.
  • Photo gallery — session shots in a scrollable grid.
  • PNG downloads — save any photo individually.

Prerequisites

pip install streamlit streamlit-webrtc opencv-python numpy pillow

Step 1: Create the Script

Save as photo_booth.py:

import streamlit as st
import numpy as np
import cv2
import io
import time
from PIL import Image, ImageOps, ImageFilter
from streamlit_webrtc import webrtc_streamer
import av

st.set_page_config(page_title="Photo Booth", page_icon="📸")
st.title("📸 Webcam Photo Booth")

filter_mode = st.selectbox("Filter", ["None", "Grayscale", "Sepia", "Sketch"])
latest = {"frame": None}


def callback(frame: av.VideoFrame) -> av.VideoFrame:
    img = frame.to_ndarray(format="bgr24")
    latest["frame"] = img
    return frame


webrtc_streamer(
    key="booth",
    video_frame_callback=callback,
    media_stream_constraints={"video": True, "audio": False},
)

if st.button("📸 Snap photo", type="primary") and latest["frame"] is not None:
    frame = latest["frame"]
    pil = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))

    if filter_mode == "Grayscale":
        pil = ImageOps.grayscale(pil)
    elif filter_mode == "Sepia":
        np_img = np.array(pil.convert("RGB"))
        sep = np_img @ np.array([[0.393, 0.769, 0.189],
                                 [0.349, 0.686, 0.168],
                                 [0.272, 0.534, 0.131]]).T
        pil = Image.fromarray(np.clip(sep, 0, 255).astype(np.uint8))
    elif filter_mode == "Sketch":
        pil = ImageOps.grayscale(pil).filter(ImageFilter.CONTOUR)

    if "gallery" not in st.session_state:
        st.session_state.gallery = []
    st.session_state.gallery.append((pil, time.strftime("%H:%M:%S")))
    st.success("Photo captured!")

if st.session_state.get("gallery"):
    st.subheader(f"Your photos ({len(st.session_state['gallery'])})")
    cols = st.columns(3)
    for i, (photo, ts) in enumerate(reversed(st.session_state.gallery)):
        with cols[i % 3]:
            st.image(photo, caption=ts, use_container_width=True)
            buf = io.BytesIO()
            photo.save(buf, format="PNG")
            st.download_button("⬇️ Save", buf.getvalue(), f"photo_{ts.replace(':', '')}.png", "image/png", key=f"dl{i}")

Step 2: Run the App

streamlit run photo_booth.py

Allow camera access, see yourself live, pick a filter, and snap — photos stack up in the gallery below.

How It Works

streamlit-webrtc bridges the browser’s getUserMedia API to Python. Its video_frame_callback fires for every frame: the frame arrives as an av.VideoFrame, gets converted to a BGR numpy array with to_ndarray, and — crucially for our trick — is stored in a plain dict shared with the main script. The callback runs on a worker thread while the UI thread renders, so the dict is the mailbox between them.

Capture is then trivial: the button grabs the latest stored frame and converts it to a PIL image. Filters are pure Pillow/numpy — sepia is literally a matrix multiply of the RGB pixel array against a fixed 3×3 transformation, clipped to valid values. Grayscale and contour sketch are one-line Pillow ops from the image processing tutorial.

The gallery lives in session state as (image, timestamp) tuples, rendered newest-first in a three-column grid — the same state-as-collection pattern as the to-do list, holding images instead of strings.

Common Errors & Fixes

  • Black preview / no camera prompt — camera access requires localhost or HTTPS; a LAN IP will fail silently, same as the audio recorder.
  • latest["frame"] is always None on snap — the callback never ran; check that the webrtc stream actually connected (watch for the green status dot).
  • Photos look blue/orange — you skipped the BGR→RGB conversion before Image.fromarray.
  • Sepia output is dark or blown out — the matrix rows must sum near 1.0; clip with np.clip or values overflow uint8 and wrap around.

Key Concepts

  • Frame callbacks — per-frame Python functions on a worker thread.
  • Thread-to-UI mailbox — a dict as the bridge between callback and script.
  • Filter matrices — color transforms as 3×3 multiplies.
  • Session-state galleries — collections of rich objects (images) with timestamps.

What to Try Next

  • Add face detection boxes with OpenCV’s Haar cascades on each snap.
  • Build a 4-photo strip layout stitched vertically, like classic booths.
  • Add an emoji sticker overlay placed by clicking on the preview.
  • Record a timelapse — auto-snap every 10 seconds into the gallery.

FAQ

Does this work over the internet?

Only with HTTPS (or localhost for dev). streamlit-webrtc additionally needs STUN/TURN servers for networks with strict NAT — defaults work on most home networks.

Why BGR in OpenCV but RGB everywhere else?

Historical accident from early webcam drivers. OpenCV kept BGR; everyone else standardized on RGB — hence the one-line conversions at every boundary.

Can I record video instead of photos?

Yes, but it’s a bigger jump: you’d accumulate frames in the callback and write them with cv2.VideoWriter. Start with the photo strip — same pipeline, simpler output.