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:
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
localhostor 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.clipor 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.