I bought a Raspberry Pi 3 a few years ago to enter an art competition. I never opened the box.
The competition passed. The box sat on a shelf. Later I needed something small to run my home automation, so I finally opened it and put Home Assistant on it. It did that quietly for a long time. When I moved Home Assistant to a PC, the Pi was free again, and I decided to finally use it for what I bought it for. This is that project.
The plan
Station 202 is a small camera pointed at my desk that renders what it sees as a coarse field of green pixels in a web browser. It looks like a surveillance monitor from an old test rig that everyone forgot about.
The part I care about most is technical, not visual: no real image ever leaves the Pi. The camera frame is reduced on the device to a tiny grid of brightness levels, and only that grid is sent out. You cannot rebuild a face, a screen, or a document from it. The thing that makes it look like degraded art is the same thing that makes it safe to put on the public internet.
I work in IT security by day, where the whole job is deciding who is allowed to see what. The other half of my time goes to film and code. This sits exactly on the line between the two: a camera that is always on, watched by no one in particular, that is built so it physically cannot leak a usable picture.
.
How it works
--------------------------- ----------------- -------------------
capture -> grayscale -> relay (Flask): HTML5 canvas:
downscale to 100x56 -> keeps newest grid, grid -> green pixels
quantise to levels 0-9 -> serves it over SSE (or ASCII characters)
push the grid only, outbound one-way out recorded "tapes" when
nobody is at the desk
The Pi never opens an inbound port. It only pushes outbound. The web server can be fully compromised and the worst anyone gets is a screen of green blocks.
How I built it, step by step
1. The Pi and the broken camera
The camera module is damaged. For normal photography it is useless - soft, low contrast, partly out of focus. For this it is perfect, and the defects are part of the look. Lesson one of the project: a broken sensor is still a working sensor if your output is 5,600 pixels of green.
2. Capture and reduce on the device
Capture with picamera2, convert to grayscale and downscale with OpenCV, then quantise every pixel to one of ten brightness levels. That last step is the whole privacy model. The output is a string of digits, nothing else.

import cv2, numpy as np
from picamera2 import Picamera2
COLS, ROWS = 100, 56
picam2 = Picamera2()
picam2.configure(picam2.create_preview_configuration(
main={"size": (320, 240), "format": "RGB888"}))
picam2.start()
frame = picam2.capture_array()
gray = cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY)
small = cv2.resize(gray, (COLS, ROWS), interpolation=cv2.INTER_AREA)
# the only thing that ever leaves the Pi: 5,600 digits, 0-9
levels = (small.astype(np.uint16) * 9 // 255).clip(0, 9).astype(np.uint8)
grid = "".join(map(str, levels.flatten().tolist()))
3. The browser renderer
The page reads the grid and draws one filled rectangle per cell, brightness scaled to green. The same data can be drawn as ASCII characters instead - that was the original look, but at this resolution the characters turn to mush, so blocky pixels are the default and ASCII is an option in a "tune" menu.
// grid is a string of digits; draw it as green blocks
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const lv = grid.charCodeAt(y * cols + x) - 48; // '0'..'9'
if (lv === 0) continue;
const s = lv / 9;
ctx.fillStyle = `rgb(0, ${(255 * s) | 0}, ${(70 * s) | 0})`;
ctx.fillRect(x * cw, y * ch, cw + 1, ch + 1);
}
}
4. Recorded "tapes" for when I am away
Instead of video files, the recorded material is stored in the same grid format - a small JSON file of frames that the browser replays through the exact same renderer. So a recording and the live feed look identical, because they are the same kind of data. The files are almost all repeated digits, so they compress to very little.
5. The relay and the presence gate
A tiny Flask service on the web server keeps only the newest grid and streams it to browsers over Server-Sent Events. SSE because it reconnects itself and survives a tab left open for days.
@app.get("/feed")
def feed():
def stream():
last = -1
while True:
if state["version"] != last and fresh():
last = state["version"]
yield f"data: {state['frame']}\n\n"
else:
yield ": keepalive\n\n"
time.sleep(0.25)
return Response(stream(), mimetype="text/event-stream")
The Pi only pushes when it sees motion during set hours, so the live feed means I am actually at the desk. Otherwise the relay goes stale and the browser falls back to a recording on its own.

Problems I ran into
Per-character drawing was slow. The first ASCII version called the canvas text function thousands of times per frame. I capped the frame rate to about 12 fps (a degraded monitor should be slow anyway) and later moved the default to filled rectangles, which are much cheaper than glyphs.
Opening the page from a file did nothing. Browsers treat file:// as a unique origin and block the page from fetching the word list and recordings. Serving the folder over a normal web server (python3 -m http.server while testing) fixed it.
The canvas went blank when media came from another domain. Reading pixels back from a canvas that has loaded a cross-origin video taints it and throws. Hosting everything on one origin avoids it entirely.
SSE stuttered behind nginx. The reverse proxy was buffering the stream. Turning off proxy_buffering for that one location made it real-time again.
Proving the privacy claim. I had to be sure nothing recoverable leaves the device. The answer is in the pipeline: the only data on the wire is 5,600 digits between 0 and 9. There is no frame buffer to leak because it never exists outside the Pi.
Current status
Working: live capture and reduction on the Pi, the browser renderer in both pixel and ASCII modes, recorded tape playback, the relay over SSE, motion-gated live switching, and a "tune" panel for display settings. It runs continuously and is reachable on the public internet through a reverse proxy, with the Pi joined over Tailscale.
Not finished: the live presence detection still needs tuning so brief stillness does not drop the feed; autoplay of recorded material is fussy on some mobile browsers; and the damaged camera occasionally needs a restart, which I want to make automatic with a watchdog.
It started as a Pi I bought for a competition and never opened. It is now entering one.
Top Comments