DevelopmentJune 16, 20253 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

  • Python 3.8+ — from python.org.
  • Dependencies:
  • code
    pip install streamlit streamlit-webrtc opencv-python numpy pillow

    Step 1: Create the Script

    Save as photo_booth.py:

    code
    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

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