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
streamlit-webrtc.Prerequisites
pip install streamlit streamlit-webrtc opencv-python numpy pillowStep 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.pyAllow 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
localhost or HTTPS; a LAN IP will fail silently, same as the audio recorder.Image.fromarray.np.clip or values overflow uint8 and wrap around.Key Concepts
What to Try Next
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.