Video Feedback with Raspberry Pi
2026-08-25 | By Adafruit Industries
License: See Original Project Displays LCD / TFT Wireless HDMI Raspberry Pi SBC
Courtesy of Adafruit
Guide by Tim C
Overview
Video feedback can cause visual flashing effects. Skip this project if you are sensitive to flashing lights and patterns.
Video feedback is the recursive effect produced by pointing a camera at a live preview of its own output. In the earliest video recording equipment, it was detrimental, potentially leading to issues with pickups or screens. By the 60s it started to get used in psychedelic art. In the time since, it has been used in TV, film, music videos, and other media.
This project allows anyone with a Raspberry Pi, USB webcam, and HDMI monitor to easily experiment with video feedback and other effects to create fun, photos and video.
Learn about the fundamental concepts of video feedback with this blog post: What is Video Feedback Synthesis and How do I get started?
Parts
A camera that is supported by the Raspberry Pi. A USB camera will give the most flexibility with its long cord. I used an old Logitech C270, which can still be purchased online for fairly cheap. One of the Pi camera modules that connects via ribbon cable will work as too, but maneuverability of the camera is limited by the length and stiffness of the ribbon cable.
An HDMI display. There are a few small ones to choose from in the shop. It's easy to find USB C powered 10-15" displays with built in kick stands and HDMI online or at electronics stores. I used this white 15" one, it has mini-HDMI input which requires an additional adapter.
Or
Install
Raspberry Pi OS
Begin by installing the latest full version of Raspberry Pi OS (64-bit) on your system using the steps outlined on this Raspberry Pi Imager learn guide page. If you intend to have the Raspberry Pi installed somewhere out of the way and wish to connect to it remotely, then be sure to enable SSH and configure your public key in the customization menu of the Raspberry Pi Imager. It's also convenient to configure the WiFi network details inside of the Pi Imager app so that the Pi will automatically connect to your WiFi when it boots up.
Adafruit's Raspberry Pi Lesson 1. Preparing an SD Card for your Raspberry Pi
By Simon Monk
After you boot up the Pi for the first time on a fresh Raspberry Pi OS image, connect the Pi to a network and then run these commands to update the software preloaded in the system.
sudo apt update sudo apt upgrade
If these commands fail with network errors, use the WiFi settings in the OS to connect to your network and then try again.
Virtual Environment & Requirements
The main application for this video feedback project is a Python script that has a Flask web server to host the web-based preview and control page. The code uses numpy and opencv for effects and image/video processing. Adafruit Blinka is used for its rainbowio module to make the rainbow effect filter.
Create a Python virtual environment and install the dependencies in it with these commands.
# create virtual environment python -m venv ~/venv_videofeedback # activate virtual environment source ~/venv_videofeedback/bin/activate # install dependencies pip install Flask opencv-python numpy adafruit-blinka
Download Project Files
Click this button to download a zipped copy of the project files. Unzip it and move it to the Raspberry Pi. Or, If you want to do it all from the Pi terminal, use following commands.
# go to home directory cd ~ # download project zip wget https://cdn-learn.adafruit.com/assets/assets/000/144/514/original/Raspberry_Pi_Video_Feedback.zip # decompress zip unzip Raspberry_Pi_Video_Feedback.zip
Run Video Feedback Server
If you just want to experiment with the video feedback rig temporarily and don't mind launching things manually, then use the following commands to start the server.
# go to project folder cd ~/Raspberry_Pi_Video_Feedback # activate virtual environment source ~/venv_videofeedback/bin/activate python camera_filters_web.py
You will see output like this when the server launches.
* Serving Flask app 'camera_filters_web' * Debug mode: off WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. * Running on all addresses (0.0.0.0) * Running on http://127.0.0.1:5000 * Running on http://192.168.1.242:5000
This terminal must remain open and sitting at this "server running" prompt the whole time you use the video feedback app. You can close it with ctrl-c when ready to exit.
Leave it open and launch a new terminal or SSH session for the next part.
Launch Browser
Now launch the browser on the Pi and load the page hosted by the server with address http://localhost:5000/. By default browsers come with the address bar and other UI that will obstruct part of the feedback frame. Unless you're specifically looking for an "infinity browser window" effect, you'll want to launch the browser in kiosk mode to make it show just the rendered page full screen and nothing else.
There is a convenient shell script included in the project files to launch the chromium browser in kiosk mode.
# go to project folder cd ~/Raspberry_Pi_Video_Feedback # run launch browser script ./launch_browser.sh
The script exports the DISPLAY variable configured for the 1st display connected to the Pi. That makes it possible to use this script via SSH as well as run directly on the Pi desktop. It sends logs to the journal logging system under the name glitch_cam_browser. If the browser fails to launch or has other issues check the logs with this command.
journalctl -t glitch_cam_browser
You can use the preview and controls page right from the Pi itself with an attached mouse and keyboard or load it in a browser on a different computer that is on the same network.
Set Up Autostart
If you want to use the Pi as a dedicated video feedback rig, you can set up a few more things to make the app launch automatically upon boot up.
Webserver
The project files include video_feedback_server.service which is a systemd service config file.
Fill in your username to the [username] spots in the config file. Edit it with nano or another text editor.
[Unit] Description=GlitchCam web camera filter server After=network-online.target Wants=network-online.target [Service] Type=simple User=[username] Group=[username] WorkingDirectory=/home/[username]/Raspberry_Pi_Video_Feedback ExecStart=/home/[username]/venv_videofeedback/bin/python /home/[username]/Raspberry_Pi_Video_Feedback/camera_filters_web.py Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target
Copy the modified config file into /etc/systemd/system, then enable and start the service using the following commands.
cp ~/Raspberry_Pi_Video_Feedback/video_feedback_server.service /etc/systemd/system/ sudo systemctl enable video_feedback_server.service sudo systemctl start video_feedback_server.service
To see the logs of the service use the following command. The -f argument is for "follow", it will make the logs continue printing in as they occur until you stop it with ctrl-c.
journalctl -u video_feedback_server.service -f
Browser
To make the browser auto launch when the Pi boots up use the ~/.config/labwc/autostart file. Create it with your editor of choice and put the following inside of it.
sleep 10 && systemd-cat -t glitch_cam_browser chromium --kiosk --disable-infobars --start-maximized http://localhost:5000/ &
The command starts with sleep time to ensure the desktop is fully up and running and ready to launch the browser.
It uses systemd-cat in order to log to the journal system. You can view the logs for the browser with this command. It's only necessary if the browser fails to launch or has some other problem.
journalctl -t glitch_cam_browser -f
Use
It is most convenient to load the control page from another computer on the same network using the Pi's IP address or hostname http://[ip-address-or-hostname]:5000/, e.g. http://192.168.1.141:5000/. That way the preview that is visible on the feedback screen can be full screen, so the control panel is not part of the feedback loop. However, you can also do everything right on the Pi itself with a keyboard and mouse connected. Hide the control panel to prevent it appearing in the final photos and video.
Control Panel
When the page first loads, it will be a full-screen preview of the camera feed that is suitable for showing on the feedback display to make the recursive effect. To open the control panel on the right side of the page click anywhere on the preview, or press F on the keyboard, the full-screen shortcut.
Galleries
In the control panel beneath the Reset all button there are links to galleries to view and download the photos and videos that have been saved.
On the gallery page you can click the image to view it at full size or click the download icon to save a copy of the image file locally. Files are sorted with newest ones appearing at the top left of the grid.
Actions
Save a photo of the current view with the 📷 Photo button or the S keyboard shortcut. Start a video recording with the R keyboard shortcut or ● Record button. Stop the recording with another R key press or the ■ Stop button.
Filters
There are 10 filters available in the camera controls. Enable or disable them with the checkboxes to the left of their name, or using the keyboard shortcuts 1-9 and 0. Each filter also has a intensity percentage. 100% will show the effect fully, lower values will mix a semi-transparent layer of the effect with the original source. The filters are applied in order from top to bottom and stack. Mix and match them to look for interesting combinations!
Edges - blacks out the image and traces the edges with a thin light line. Shortcut: 1 key.
Grayscale - removes colors from the image converting it to shades of gray. Shortcut: 2 key.
Rainbow - adds a semi-transparent rainbow overlay to the image. Direction, strength, spread, and offset can be configured in the options panel. Shortcut: 3 key.
Color Gel - adds a semi-transparent color overlay on the image. Configuration allows positive or negative change of R, G, and B channels individually. The default configuration makes a blue colored "cool" filter effect. Shortcut: 4 key.
Color Matrix - performs matrix multiplication to change the colors of the image. The default values are commonly used constants to convert to sepia. Input must be valid JSON representing a 3x3 list of lists of floats. Shortcut: 5 key.
Invert - inverts the colors in the photo. Due to the feedback loop and the invert function undoing itself when applied multiple times, this effect will create neat back and forth alternating sections of inverted and normal. If you wanted a fully inverted photo, you could take a normal non-filtered photo, then invert it in editor software. Shortcut: 6 key.
Blur - softens the edges in the photo making everything appear blurry. This is the tamest of the included filters. It can be combined with some of the others for interesting effects but is more subtle than the rest. Feel free to dive into the code and swap it out for something more flashy if you want! Shortcut: 7 key.
Cartoon - darkens the edge lines and unifies the colored sections. Gives the photo resemblance to a cartoon drawn on white paper. Shortcut: 8 key.
Glitch - separates the individual color components and translates, scales, and shears them. It creates a colorful, psychedelic, effect that looks like something a glitchy broken camera could produce. There are translate, scale, and shear configurations in the options panel. Shortcut: 9 key.
Rowshift - splits the photo into rows and swaps the left and right sides of each row and a random midway point. This is another effect inspired by a a misbehaving camera, one of my webcams did this during a recent livestream. You can configure the number of rows and re-randomize the midway points inside of the options. Shortcut 0 key.
Code
The Python code that that serves the backend for the preview and control page is embedded below. Inside of it you'll find all of the filter functions that apply the various filters from the control panel. You could add or change the filters to get different visual effects.
The script uses threads to separate the camera handling from the webserver responses. The camera thread continually gets the latest frame and applies the current filters and caches the result. The webserver is always serving those cached frames. This architecture makes it so that many clients can load the preview page without overwhelming the Pi.
The code defines 3 Flask endpoints to serve the main preview/control page as well as the photo and video galleries. The front-end HTML files can be found in the templates/ directory within the project files.
# SPDX-FileCopyrightText: 2026 Tim Cocks, written for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
Live camera preview with stackable, intensity-adjustable filters,
served as a web page over the local network via Flask + MJPEG.
Designed for Raspberry Pi 4/5 with a USB camera, but works on any system with
a webcam.
Setup:
sudo apt update
sudo apt install python3-flask python3-opencv python3-numpy
# Or via pip in a venv:
# pip install flask opencv-python numpy
Run:
python3 camera_filters_web.py
Then open http://<pi-ip>:5000 in any browser on the local network.
(On the Pi itself, http://localhost:5000 also works.)
Controls:
- Toggle each filter on/off with its checkbox.
- Adjust the intensity number input (0-100%) for each filter.
- Filters are applied top-to-bottom in the order shown.
"""
# pylint: disable=too-many-locals
import os
import time
import threading
from typing import List, Optional, TypedDict
import cv2
import numpy as np
import rainbowio
from flask import (
Flask,
Response,
request,
jsonify,
render_template,
send_from_directory,
abort,
)
# config argument unused by some filters but gets passed dynamically
# to all filter functions so the argument needs to exist.
# pylint: disable=unused-argument
# ----------------------
# Filter implementations
# ----------------------
def filter_grayscale(frame, config=None):
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
return cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
_RAINBOW_CACHE = {"key": None, "offsets": None, "index": None}
# Rainbow orientations: which way the color bands run.
_RB_ROW = 0 # horizontal bands, one color per row
_RB_COLUMN = 1 # vertical bands, one color per column
_RB_DIAG_DOWN = 2 # bands run top-left -> bottom-right (constant x - y)
_RB_DIAG_UP = 3 # bands run bottom-left -> top-right (constant x + y)
def filter_rainbow(frame, config=None):
"""Overlay a rainbow pattern by color-shifting each row, column, or
diagonal line.
Like the Color Gel filter, but instead of one offset for the whole
frame, every line (row, column, or 45-degree diagonal) gets its own
color offset taken from rainbowio.colorwheel(), indexed by that line.
The result is a rainbow gradient laid over the image.
Config:
orientation: which way the color bands run.
0 = per row (horizontal bands)
1 = per column (vertical bands)
2 = diagonal down-right (constant x - y)
3 = diagonal up-right (constant x + y)
(the legacy key "vertical" is still accepted as a fallback.)
scale: 0..1 multiplier on the offset strength (default 1.0).
spread: number of lines per color band (default 1). With spread
2, each colorwheel color covers two lines, and so on,
widening the bands.
offset: added to the colorwheel index, shifting where in the
rainbow the pattern starts (default 0).
The colorwheel input wraps every 256 colors, so the rainbow repeats
across the frame.
"""
if config is None:
config = {}
# "orientation" supersedes the old 0/1 "vertical" flag, which still
# works for existing configs.
orientation = int(config.get("orientation", config.get("vertical", 0)))
scale = float(config.get("scale", 1.0))
spread = max(1, int(config.get("spread", 1)))
offset = int(config.get("offset", 0))
h, w = frame.shape[:2]
# How many distinct lines there are depends on the orientation. The
# diagonals span every (x +/- y) value, so there are w + h - 1 of them.
if orientation == _RB_COLUMN:
n = w
elif orientation in (_RB_DIAG_DOWN, _RB_DIAG_UP):
n = w + h - 1
else:
n = h
# Building one offset per line (and, for diagonals, the per-pixel line
# index map) is the slow part, so cache it and only rebuild when the
# frame size, orientation, strength, spread, or offset changes.
key = (h, w, orientation, scale, spread, offset)
if _RAINBOW_CACHE["key"] != key:
offsets = np.empty((n, 3), dtype=np.int16)
for i in range(n):
color = rainbowio.colorwheel((i // spread + offset) % 256)
r = (color >> 16) & 0xFF
g = (color >> 8) & 0xFF
b = color & 0xFF
# OpenCV channel order is BGR.
offsets[i] = (round(b * scale), round(g * scale), round(r * scale))
# For diagonals, precompute the line index of every pixel so we can
# gather offsets with a single fancy-index per frame.
_line_index = None
cols = np.arange(w)
rows = np.arange(h)
if orientation == _RB_DIAG_DOWN:
# constant x - y; shift by (h - 1) so indices start at 0.
_line_index = (cols[np.newaxis, :] - rows[:, np.newaxis]) + (h - 1)
elif orientation == _RB_DIAG_UP:
# constant x + y.
_line_index = cols[np.newaxis, :] + rows[:, np.newaxis]
_RAINBOW_CACHE["key"] = key
_RAINBOW_CACHE["offsets"] = offsets
_RAINBOW_CACHE["index"] = _line_index
offsets = _RAINBOW_CACHE["offsets"]
_line_index = _RAINBOW_CACHE["index"]
shifted = frame.astype(np.int16)
if orientation == _RB_COLUMN:
shifted += offsets[np.newaxis, :, :] # one offset per column
elif _line_index is not None:
shifted += offsets[_line_index] # one offset per diagonal line
else:
shifted += offsets[:, np.newaxis, :] # one offset per row
return np.clip(shifted, 0, 255).astype(np.uint8)
def filter_invert(frame, config=None):
return cv2.bitwise_not(frame)
def filter_blur(frame, config=None):
return cv2.GaussianBlur(frame, (15, 15), 0)
def filter_edges(frame, config=None):
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray, 80, 160)
return cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR)
def filter_cartoon(frame, config=None):
color = cv2.bilateralFilter(frame, 9, 75, 75)
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray = cv2.medianBlur(gray, 5)
edges = cv2.adaptiveThreshold(
gray, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 9, 2
)
edges = cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR)
return cv2.bitwise_and(color, edges)
def filter_colorgel(frame, config=None):
"""Dynamic per-channel color shift.
Config:
red: amount to add to the red channel (-255..255).
green: amount to add to the green channel (-255..255).
blue: amount to add to the blue channel (-255..255).
Negative values subtract from the channel. Channels are clipped to
the valid 0-255 range.
"""
if config is None:
config = {}
r_off = int(config.get("red", -20))
g_off = int(config.get("green", 0))
b_off = int(config.get("blue", 30))
shifted = frame.astype(np.int16)
shifted[..., 0] += b_off # OpenCV channel order is BGR.
shifted[..., 1] += g_off
shifted[..., 2] += r_off
return np.clip(shifted, 0, 255).astype(np.uint8)
def filter_colortransform(frame, config=None):
"""Apply a user-supplied 3x3 color matrix, like the sepia filter.
Config:
matrix: a 3x3 list of floats applied to each pixel via
cv2.transform. Defaults to the classic sepia matrix.
"""
if config is None:
config = {}
matrix = config.get("matrix") or _COLORTRANSFORM_DEFAULTS["matrix"]
kernel = np.array(matrix, dtype=np.float64)
transformed = cv2.transform(frame, kernel)
return np.clip(transformed, 0, 255).astype(np.uint8)
_ROWSHIFT_SPLITS_TYPE = TypedDict("_ROWSHIFT_SPLITS_TYPE", {
"key": Optional[tuple],
"splits": Optional[List[int]]
})
_ROWSHIFT_SPLITS: _ROWSHIFT_SPLITS_TYPE = {"key": None, "splits": None}
def filter_rowshift(frame, config=None):
"""Split the frame into N horizontal rows; within each row, swap two
parts at a fixed random split point.
Config:
num_rows: number of horizontal rows to split into (default 4).
random_seed: any changing token; bumping it re-rolls the split points.
"""
if config is None:
config = {}
h, w = frame.shape[:2]
num_rows = max(1, int(config.get("num_rows", 8)))
random_seed = config.get("random_seed", 0)
key = (w, num_rows, random_seed)
if _ROWSHIFT_SPLITS["key"] != key:
rng = np.random.default_rng()
_ROWSHIFT_SPLITS["splits"] = [int(rng.integers(1, w)) for _ in range(num_rows)]
_ROWSHIFT_SPLITS["key"] = key
splits = _ROWSHIFT_SPLITS["splits"]
out = frame.copy()
row_edges = np.linspace(0, h, num_rows + 1, dtype=int)
for i in range(num_rows):
y1, y2 = row_edges[i], row_edges[i + 1]
x = splits[i] # pylint: disable=unsubscriptable-object
row = frame[y1:y2]
out[y1:y2] = np.concatenate((row[:, x:], row[:, :x]), axis=1)
return out
def filter_glitch(frame, config=None):
"""Datamosh / RGB-shift / slice-displacement / noise glitch effect."""
if config is None:
config = {}
h, w = frame.shape[:2]
out = frame.copy()
default_shift = max(4, w // 80)
scale_x = config.get("scale_x", 1)
shear_x = config.get("shear_x", 0)
translate_x = config.get("translate_x", default_shift)
scale_y = config.get("scale_y", 1)
shear_y = config.get("shear_y", 0)
translate_y = config.get("translate_y", 0)
b, g, r = cv2.split(out)
M_r = np.float32([[scale_x, shear_x, translate_x], [shear_y, scale_y, translate_y]])
M_b = np.float32(
[[scale_x, -shear_x, -translate_x], [-shear_y, scale_y, -translate_y]]
)
r = cv2.warpAffine(r, M_r, (w, h), borderMode=cv2.BORDER_REPLICATE)
b = cv2.warpAffine(b, M_b, (w, h), borderMode=cv2.BORDER_REPLICATE)
out = cv2.merge([b, g, r])
rng = np.random.default_rng()
# num_slices = 6
# for _ in range(num_slices):
# y1 = rng.integers(0, h)
# y2 = min(h, y1 + rng.integers(2, max(3, h // 20)))
# dx = int(rng.integers(-w // 12, w // 12))
# if dx == 0:
# continue
# band = out[y1:y2].copy()
# out[y1:y2] = np.roll(band, dx, axis=1)
levels = 5
step = 256 // levels
out = (out // step) * step + step // 2
hsv = cv2.cvtColor(out, cv2.COLOR_BGR2HSV).astype(np.int16)
hsv[..., 1] = np.clip(hsv[..., 1] + 80, 0, 255)
out = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2BGR)
noise = rng.integers(-40, 40, size=out.shape, dtype=np.int16)
out = np.clip(out.astype(np.int16) + noise, 0, 255).astype(np.uint8)
return out
# Registry of (name, function). Order here = order applied in the chain.
FILTERS = [
("Edges", filter_edges),
("Grayscale", filter_grayscale),
("Rainbow", filter_rainbow),
("Color Gel", filter_colorgel),
("Color Matrix", filter_colortransform),
("Invert", filter_invert),
("Blur", filter_blur),
("Cartoon", filter_cartoon),
("Glitch", filter_glitch),
("Rowshift", filter_rowshift),
]
# ---------------------------------------------------------------------------
# Filter chain state (shared between the request handlers and frame loop)
# ---------------------------------------------------------------------------
class FilterState:
"""Plain Python replacement for the Qt FilterRow.
Holds enabled flag + intensity (0.0-1.0) for one filter, and knows how
to apply itself to a frame. A lock guards reads/writes since the frame
loop runs in one thread and HTTP handlers run in others.
"""
def __init__(self, name, fn):
self.name = name
self.fn = fn
self.enabled = False
self.intensity = 1.0
self.extra_config = {}
def is_active(self):
return self.enabled and self.intensity > 0
def apply(self, frame):
if not self.is_active():
return frame
filtered = self.fn(frame, self.extra_config)
amt = self.intensity
if amt >= 1.0:
return filtered
return cv2.addWeighted(frame, 1.0 - amt, filtered, amt, 0)
STATE_LOCK = threading.Lock()
FILTER_STATES = [FilterState(name, fn) for name, fn in FILTERS]
_GLITCH_DEFAULTS = {
"scale_x": 1.0,
"shear_x": 0.0,
"translate_x": 8.0,
"scale_y": 1.0,
"shear_y": 0.0,
"translate_y": 0.0,
}
_ROWSHIFT_DEFAULTS = {"num_rows": 4.0, "random_seed": 0.0}
_COLORGEL_DEFAULTS = {"red": -20.0, "green": 0.0, "blue": 30.0}
_RAINBOW_DEFAULTS = {"orientation": 0.0, "scale": 0.4, "spread": 2, "offset": 0.0}
_COLORTRANSFORM_DEFAULTS = {
"matrix": [[0.272, 0.534, 0.131], [0.349, 0.686, 0.168], [0.393, 0.769, 0.189]]
}
def _default_config_for(name):
"""Return a fresh copy of the default extra_config for a filter, or {}."""
if name == "Glitch":
return dict(_GLITCH_DEFAULTS)
if name == "Rowshift":
return dict(_ROWSHIFT_DEFAULTS)
if name == "Color Gel":
return dict(_COLORGEL_DEFAULTS)
if name == "Rainbow":
return dict(_RAINBOW_DEFAULTS)
if name == "Color Matrix":
return {"matrix": [row[:] for row in _COLORTRANSFORM_DEFAULTS["matrix"]]}
return {}
for _s in FILTER_STATES:
if _s.name == "Rainbow":
_s.intensity = 0.7
_s.extra_config = _default_config_for(_s.name)
# ---------------------------------------------------------------------------
# Camera + frame generator
# ---------------------------------------------------------------------------
class Camera:
"""Wraps cv2.VideoCapture and serializes access with a lock.
cv2.VideoCapture isn't thread-safe, and the frame generator may be
called from multiple Flask worker threads if more than one viewer
connects. We grab one frame at a time under a lock.
"""
def __init__(self, camera_index=0, width=640, height=480):
self.cap = cv2.VideoCapture(camera_index)
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
if not self.cap.isOpened():
raise RuntimeError(
f"Could not open camera at index {camera_index}. "
"Check that it's plugged in and not in use."
)
self.lock = threading.Lock()
def read(self):
with self.lock:
return self.cap.read()
def release(self):
with self.lock:
if self.cap.isOpened():
self.cap.release()
CAMERA = Camera(camera_index=0, width=1280, height=960)
# Where recordings and photos are written, relative to this script.
RECORD_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "recordings")
PHOTO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "photos")
class Processor(threading.Thread):
"""Single background thread that owns the camera read + filter chain.
It reads one frame at a time, applies the active filter chain,
stashes the result as the latest JPEG (for any number of MJPEG viewers)
and, while recording, writes the same filtered frame to a VideoWriter.
Doing the work in one place means recording is independent of whether
(or how many) browsers are connected, and every viewer shares one
consistent frame instead of each re-running the filters.
"""
def __init__(self, camera):
super().__init__(daemon=True)
self.camera = camera
self.running = True
# Latest encoded frame, published to streaming clients.
self.frame_cond = threading.Condition()
self.latest_jpeg = None
self.frame_id = 0
# Size of the most recent frame, needed when opening a VideoWriter.
self.frame_size = None
# Smoothed measured frame rate (EMA), used as the recording FPS so
# playback timing roughly matches real time.
self.fps = 20.0
self._last_t = None
# Recording state, guarded by its own lock.
self.rec_lock = threading.Lock()
self.writer = None
self.rec_path = None
self.rec_start = None
self.rec_frames = 0
def run(self):
while self.running:
ok, frame = self.camera.read()
if not ok:
time.sleep(0.01)
continue
now = time.time()
if self._last_t is not None:
dt = now - self._last_t
if dt > 0:
self.fps = 0.9 * self.fps + 0.1 * (1.0 / dt)
self._last_t = now
# Snapshot filter state under the lock so it can't change mid-chain.
with STATE_LOCK:
active = [(s.fn, s) for s in FILTER_STATES if s.is_active()]
for fn, state in active:
filtered = fn(frame, state.extra_config)
intensity = state.intensity
if intensity >= 1.0:
frame = filtered
else:
frame = cv2.addWeighted(
frame, 1.0 - intensity, filtered, intensity, 0
)
self.frame_size = (frame.shape[1], frame.shape[0])
# Write the filtered frame to disk if we're recording.
with self.rec_lock:
if self.writer is not None:
self.writer.write(frame)
self.rec_frames += 1
ok, jpeg = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
if not ok:
continue
with self.frame_cond:
self.latest_jpeg = jpeg.tobytes()
self.frame_id += 1
self.frame_cond.notify_all()
# -- recording control --------------------------------------------------
def start_recording(self):
"""Open a VideoWriter. Returns the file path, or None on failure /
if already recording."""
with self.rec_lock:
if self.writer is not None:
return None
size = self.frame_size or (640, 480)
fps = max(5.0, min(30.0, self.fps))
os.makedirs(RECORD_DIR, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
# Prefer H.264 ('avc1') in MP4: it's the only combination HTML5
# <video> plays reliably across browsers. Fall back to MPEG-4
# Part 2 ('mp4v') then MJPG/AVI for builds without an H.264
# encoder, those won't play in a browser but at least record.
for ext, fourcc in (("mp4", "avc1"), ("mp4", "mp4v"), ("avi", "MJPG")):
path = os.path.join(RECORD_DIR, f"rec_{ts}.{ext}")
writer = cv2.VideoWriter(
path, cv2.VideoWriter_fourcc(*fourcc), fps, size
)
if writer.isOpened():
self.writer = writer
self.rec_path = path
self.rec_start = time.time()
self.rec_frames = 0
return path
writer.release()
return None
def stop_recording(self):
"""Finalize the current recording. Returns info dict, or None if not
recording."""
with self.rec_lock:
if self.writer is None:
return None
self.writer.release()
info = {
"path": self.rec_path,
"frames": self.rec_frames,
"duration": time.time() - self.rec_start,
}
self.writer = None
self.rec_path = None
self.rec_start = None
self.rec_frames = 0
return info
# -- still photos -------------------------------------------------------
def save_snapshot(self):
"""Write the latest filtered frame to a JPEG. Returns the path, or
None if no frame is available yet."""
with self.frame_cond:
jpeg = self.latest_jpeg
if jpeg is None:
return None
os.makedirs(PHOTO_DIR, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
path = os.path.join(PHOTO_DIR, f"photo_{ts}.jpg")
# Avoid clobbering if several shots land in the same second.
n = 1
while os.path.exists(path):
path = os.path.join(PHOTO_DIR, f"photo_{ts}_{n}.jpg")
n += 1
with open(path, "wb") as f:
f.write(jpeg)
return path
def record_status(self):
with self.rec_lock:
if self.writer is None:
return {"recording": False}
return {
"recording": True,
"elapsed": time.time() - self.rec_start,
"file": os.path.basename(self.rec_path),
}
PROCESSOR = Processor(CAMERA)
def generate_frames():
"""MJPEG generator: yields the processor's latest JPEG frame, blocking
until a new one is available so we don't resend duplicates."""
boundary = b"--frame"
last_id = -1
while True:
with PROCESSOR.frame_cond:
PROCESSOR.frame_cond.wait_for(
lambda: PROCESSOR.frame_id != last_id, timeout=1.0
)
jpeg = PROCESSOR.latest_jpeg
last_id = PROCESSOR.frame_id
if jpeg is None:
continue
yield (
boundary + b"\r\n"
b"Content-Type: image/jpeg\r\n"
b"Content-Length: " + str(len(jpeg)).encode() +
b"\r\n\r\n" + jpeg + b"\r\n"
)
# ---------------------------------------------------------------------------
# Flask app
# ---------------------------------------------------------------------------
app = Flask(__name__)
@app.route("/")
def index():
with STATE_LOCK:
filter_state_snapshot = [
{
"name": s.name,
"enabled": s.enabled,
"intensity": s.intensity,
"extra_config": dict(s.extra_config),
}
for s in FILTER_STATES
]
return render_template("index.html", filters=filter_state_snapshot)
@app.route("/stream")
def stream():
return Response(
generate_frames(), mimetype="multipart/x-mixed-replace; boundary=frame"
)
@app.route("/filter/<int:filter_index>", methods=["POST"])
def update_filter(filter_index):
if not 0 <= filter_index < len(FILTER_STATES):
return jsonify(error="bad index"), 400
data = request.get_json(silent=True) or {}
with STATE_LOCK:
s = FILTER_STATES[filter_index]
if "enabled" in data:
s.enabled = bool(data["enabled"])
if "intensity" in data:
try:
s.intensity = max(0.0, min(1.0, float(data["intensity"])))
except (TypeError, ValueError):
return jsonify(error="bad intensity"), 400
if "config" in data and isinstance(data["config"], dict):
for k, v in data["config"].items():
if k == "matrix":
try:
mat = [[float(x) for x in row] for row in v]
except (TypeError, ValueError):
return jsonify(error="bad matrix value"), 400
if len(mat) != 3 or any(len(row) != 3 for row in mat):
return jsonify(error="matrix must be 3x3"), 400
s.extra_config[k] = mat
continue
try:
s.extra_config[k] = float(v)
except (TypeError, ValueError):
return jsonify(error=f"bad config value for {k}"), 400
return jsonify(ok=True)
@app.route("/reset", methods=["POST"])
def reset():
with STATE_LOCK:
for s in FILTER_STATES:
s.enabled = False
s.intensity = 1.0
s.extra_config = _default_config_for(s.name)
return jsonify(ok=True)
@app.route("/record/start", methods=["POST"])
def record_start():
path = PROCESSOR.start_recording()
if path is None:
status = PROCESSOR.record_status()
if status["recording"]:
# Already recording; report the in-progress file.
return jsonify(ok=True, **status)
return jsonify(error="could not start recording (VideoWriter failed)"), 500
return jsonify(ok=True, recording=True, file=os.path.basename(path))
@app.route("/record/stop", methods=["POST"])
def record_stop():
info = PROCESSOR.stop_recording()
if info is None:
return jsonify(ok=True, recording=False)
return jsonify(
ok=True,
recording=False,
file=os.path.basename(info["path"]),
frames=info["frames"],
duration=round(info["duration"], 1),
)
@app.route("/record/status")
def record_status():
return jsonify(PROCESSOR.record_status())
@app.route("/snapshot", methods=["POST"])
def snapshot():
path = PROCESSOR.save_snapshot()
if path is None:
return jsonify(error="no frame available yet"), 503
return jsonify(ok=True, file=os.path.basename(path))
# ---------------------------------------------------------------------------
# Gallery: browse / view / download saved photos and recordings
# ---------------------------------------------------------------------------
PHOTO_EXTS = (".jpg", ".jpeg", ".png")
VIDEO_EXTS = (".mp4", ".avi", ".mov", ".mkv")
def _list_media(directory, exts):
"""Return media filenames in `directory` matching `exts`, newest first.
Sorted by modification time descending so the most recent capture leads
the gallery. Missing directory yields an empty list.
"""
try:
names = [n for n in os.listdir(directory) if n.lower().endswith(exts)]
except FileNotFoundError:
return []
names.sort(key=lambda n: os.path.getmtime(os.path.join(directory, n)), reverse=True)
return names
@app.route("/photos")
def photos():
files = _list_media(PHOTO_DIR, PHOTO_EXTS)
return render_template("photos.html", files=files)
@app.route("/videos")
def videos():
files = _list_media(RECORD_DIR, VIDEO_EXTS)
return render_template("videos.html", files=files)
@app.route("/media/photos/<path:filename>")
def media_photo(filename):
if not filename.lower().endswith(PHOTO_EXTS):
abort(404)
return send_from_directory(
PHOTO_DIR, filename, as_attachment="download" in request.args
)
@app.route("/media/videos/<path:filename>")
def media_video(filename):
if not filename.lower().endswith(VIDEO_EXTS):
abort(404)
return send_from_directory(
RECORD_DIR, filename, as_attachment="download" in request.args
)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
try:
PROCESSOR.start()
# threaded=True lets the MJPEG stream and control endpoints run
# concurrently. host='0.0.0.0' makes it reachable on the LAN.
app.run(host="0.0.0.0", port=5000, threaded=True, debug=False)
finally:
PROCESSOR.running = False
PROCESSOR.stop_recording()
CAMERA.release()
View on GitHub

