Maker.io main logo

Video Feedback with Raspberry Pi

52

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?

photos_1

photos_2

photos_3

photos_4

photos_5

photos_6

photos_7

photos_8

photos_9

photos_10

photos_11

photos_12

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.

  • Raspberry Pi 5 - 4 GB RAM

Or

Install

install_13

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.

lesson1_14

Adafruit's Raspberry Pi Lesson 1. Preparing an SD Card for your Raspberry Pi

By Simon Monk

Raspberry Pi Imager

View Guide

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.

Copy Code
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.

Copy Code
# 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.

Download Project Files

Copy Code
# 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.

Copy Code
# 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.

Copy Code
* 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.

Copy Code
# 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.

Copy Code
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.

Download File

Copy Code
[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

View on GitHub

Copy the modified config file into /etc/systemd/system, then enable and start the service using the following commands.

Copy Code
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.

Copy Code
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.

Copy Code
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.

Copy Code
journalctl -t glitch_cam_browser -f

Use

use_15

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.

raspberry_pi_colorful_metro_robot

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.

galleries_16

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.

filters_17

filters_18

filters_19

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

color_20

color_21

color_22

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

effects_23

effects_24

effects_25

effects_26

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.

Download Project Bundle

Copy Code
# 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

Mfr Part # SC0005
HDMI M/F ADAPTER
Raspberry Pi
More Info
View More Details
Mfr Part # SC1431
SBC 2.4GHZ 4 CORE 4GB PI 5
Raspberry Pi
$110.00
View More Details
Mfr Part # SC0194(9)
SBC 1.5GHZ 4 CORE 4GB PI 4 MOD B
Raspberry Pi
$100.00
View More Details
Mfr Part # SC0445
AC/DC WALL MNT ADAPTER 5.1V 15W
Raspberry Pi
Add all DigiKey Parts to Cart
Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.