Building a Wi-Fi Security Camera with a Raspberry Pi Zero 2W

Category: Projects / Raspberry Pi / Linux
Difficulty: Intermediate
Platform: Raspberry Pi Zero 2W
Camera: OV5647 CSI Camera
OS: Raspberry Pi OS
Python: Python 3 + virtual environment

I wanted a small Wi-Fi camera that could sit on the network, provide a live browser feed, detect motion, record video locally, and push recordings to Google Drive.

The original project I followed was built around a Pi Zero 2W, Picamera2, OpenCV, Flask, PyDrive, and IFTTT. The basic design was exactly what I wanted, but Raspberry Pi OS and Python packaging have changed enough that following the old commands verbatim quickly runs into problems.

Most notably:

sudo pip3 install numpy datetime pydrive requests flask picamera2

doesn’t play nicely with current Raspberry Pi OS.

So this became less of a “follow the instructions” project and more of a “modernize it as I go” project.

The Goal

The finished camera should:

  • Capture video from a CSI camera
  • Provide a live MJPEG stream over HTTP
  • Detect movement using OpenCV
  • Record motion events to MP4
  • Store recordings locally
  • Upload recordings to Google Drive
  • Eventually send notifications when motion occurs
  • Start automatically when the Pi boots

The Pi Zero 2W is plenty capable for this at modest resolutions, and the whole thing can run headless.

Hardware

My setup uses:

Raspberry Pi Zero 2W
OV5647 CSI camera
microSD card
5V power supply
Wi-Fi network

After connecting the camera, I verified that Raspberry Pi OS could see it:

rpicam-hello --list-cameras

The camera showed up as:

0 : ov5647 [2592x1944 10-bit GBRG]

Modes:
640x480    62.50 fps
1296x972   46.34 fps
1920x1080  32.81 fps
2592x1944  15.63 fps

For this project I started with 640×480. It keeps CPU usage reasonable and is more than enough for motion detection and testing.

A quick capture test:

rpicam-still -o test.jpg

confirmed that the camera hardware and modern Raspberry Pi camera stack were working.

The First Problem: PEP 668

The original instructions use sudo pip3 to install Python packages globally. Current Raspberry Pi OS protects its system-managed Python environment through PEP 668.

Trying the old approach resulted in:

This environment is externally managed

Rather than defeating the protection with:

--break-system-packages

I installed the Raspberry Pi and Debian-provided components through APT:

sudo apt update

sudo apt install -y \
    python3-full \
    python3-pip \
    python3-venv \
    python3-opencv \
    python3-numpy \
    python3-requests \
    python3-flask \
    python3-picamera2

One other correction from the old instructions: datetime doesn’t need to be installed.

It’s part of Python:

import datetime
from datetime import date

Building a Virtual Environment

For packages outside the Raspberry Pi packages, I created a virtual environment while allowing it to access the system packages:

python3 -m venv --system-site-packages ~/camera-venv

Activate it:

source ~/camera-venv/bin/activate

Then install PyDrive:

pip install PyDrive

The --system-site-packages option is important here because Picamera2 and some of its dependencies are being provided by Raspberry Pi OS.

I tested the entire dependency stack before going any further:

python3 - <<'PY'
import cv2
import numpy
import requests
import flask
import datetime
from picamera2 import Picamera2
from pydrive.drive import GoogleDrive
from pydrive.auth import GoogleAuth

print("All imports OK")
PY

Result:

All imports OK

That gave me a known-good Python environment before introducing Google OAuth or the camera application itself.

Google Drive Authentication

The next goal was to have motion recordings uploaded automatically.

I created a Google Cloud project for the camera and enabled:

Google Drive API

Then created an OAuth client:

Application type: Desktop app

I kept the OAuth application in Testing during development and added the Google account being used for the camera as an authorized test user.

The OAuth configuration uses the restricted Drive scope:

oauth_scope:
  - https://www.googleapis.com/auth/drive.file

Rather than giving the application broad access to everything in Drive, drive.file limits its access to files associated with the application.

Because the Pi is headless, I performed the initial OAuth flow on a Windows workstation and generated:

drive_credentials.json

I then copied that credential file to the Pi:

scp .\drive_credentials.json USERNAME@PI-IP:/home/USERNAME/

On the Pi:

ls -l ~/drive_credentials.json

confirmed it was in place.

Do not publish drive_credentials.json, client_secrets.json, OAuth client secrets, refresh tokens, or IFTTT keys in GitHub or a public web post.

Testing Google Drive Before Building the Camera

Rather than debugging Google Drive from inside a motion-detection application, I tested it separately.

from pydrive.auth import GoogleAuth
from pydrive.drive import GoogleDrive

gauth = GoogleAuth(settings_file="/home/USERNAME/settings.yml")
gauth.LoadCredentialsFile("/home/USERNAME/drive_credentials.json")

if gauth.credentials is None:
    raise RuntimeError("No Google Drive credentials found")

if gauth.access_token_expired:
    gauth.Refresh()
else:
    gauth.Authorize()

gauth.SaveCredentialsFile("/home/USERNAME/drive_credentials.json")

drive = GoogleDrive(gauth)

f = drive.CreateFile({"title": "gdrive_test.txt"})
f.SetContentFile("/home/USERNAME/gdrive_test.txt")
f.Upload()

print("Upload complete")

The first attempt returned:

HttpError 403
Google Drive API has not been used ... or it is disabled

OAuth was working; the actual Drive API hadn’t been enabled for the project.

After enabling Google Drive API, I reran the test:

python3 ~/gdrive_upload_test.py

and got:

Upload complete

Now the entire Pi → OAuth → Google Drive path was confirmed independently.

Building the Camera Application

I created a directory for recordings:

mkdir -p ~/output_vids

The application combines four pieces:

Picamera2
    ↓
OpenCV
    ├── Motion Detection
    └── MP4 Recording
    ↓
Flask
    └── Live MJPEG Feed

Recorded MP4
    ↓
Google Drive

Camera Configuration

from picamera2 import Picamera2

FRAME_WIDTH = 640
FRAME_HEIGHT = 480

camera = Picamera2()

camera_config = camera.create_video_configuration(
    main={
        "size": (FRAME_WIDTH, FRAME_HEIGHT),
        "format": "RGB888"
    }
)

camera.configure(camera_config)
camera.start()

This uses the current Picamera2 stack rather than the legacy Raspberry Pi camera configuration.

Motion Detection

For the first version, motion is detected by comparing grayscale frames using mean squared error.

def mse(frame1, frame2):
    diff = cv2.absdiff(frame1, frame2)
    err = np.mean(diff.astype("float") ** 2)
    return err

During capture:

gray = cv2.cvtColor(captured, cv2.COLOR_RGB2GRAY)

if previous_gray is not None:
    error = mse(previous_gray, gray)

    if error >= 20.0:
        print("Motion detected")

The threshold is deliberately configurable. A camera pointed at a quiet indoor room will need different tuning than one pointed outside at trees, headlights, clouds, and changing sunlight.

Recording Motion

When motion is detected, recording happens in a separate thread so the live stream doesn’t stop while the video is being written.

def record_motion_video():
    global recording

    if recording:
        return

    recording = True

    now = datetime.datetime.now()

    filename = (
        f"video_{date.today()}_"
        f"{now.hour:02d}-{now.minute:02d}-{now.second:02d}.mp4"
    )

    filepath = os.path.join(VIDEO_DIR, filename)

    writer = cv2.VideoWriter(
        filepath,
        fourcc,
        20,
        (640, 480)
    )

    for _ in range(200):
        with frame_lock:
            if frame is None:
                continue

            current_frame = frame.copy()

        writer.write(current_frame)

    writer.release()

The timestamped filenames make events easy to correlate:

video_2026-07-31_15-42-17.mp4

Uploading the Recording

Once recording finishes:

upload = drive.CreateFile({"title": filename})
upload.SetContentFile(filepath)
upload.Upload()

print("Upload complete")

The file remains locally under:

/home/USERNAME/output_vids/

while another copy is uploaded to Google Drive.

That gives me both local storage and off-device storage.

Flask Live Feed

Flask provides the browser interface.

Each frame is JPEG encoded:

success, buffer = cv2.imencode(".jpg", current_frame)

jpeg = buffer.tobytes()

yield (
    b"--frame\r\n"
    b"Content-Type: image/jpeg\r\n\r\n"
    + jpeg +
    b"\r\n"
)

and exposed as an MJPEG stream:

@app.route("/livefeed")
def live_feed():
    return Response(
        web_frames(),
        mimetype="multipart/x-mixed-replace; boundary=frame"
    )

The server listens on:

app.run(
    host="0.0.0.0",
    port=5000,
    debug=False
)

From another system on the LAN:

http://PI-IP:5000/

The live camera:

http://PI-IP:5000/livefeed

Camera Controls

I also kept the original project’s idea of simple HTTP controls.

@app.route("/enablemotion")
def enable_motion():
    global motion_detection
    motion_detection = True
    return "Motion detection enabled"

and:

@app.route("/disablemotion")
def disable_motion():
    global motion_detection
    motion_detection = False
    return "Motion detection disabled"

So motion detection can be toggled from a browser without restarting the camera.

Current Status

At the end of today’s build:

[✓] Raspberry Pi Zero 2W online
[✓] OV5647 camera detected
[✓] Picamera2 operational
[✓] OpenCV operational
[✓] Python virtual environment
[✓] Flask web server
[✓] Browser live feed
[✓] Google OAuth
[✓] Google Drive upload test
[✓] Motion detection application running

The live feed is working.

Not bad for something that started with:

This environment is externally managed

What I Would Change Next

The project works, but I don’t consider it finished yet.

The next version will replace the original guide’s @reboot cron approach with a proper systemd service. That gives me service supervision, automatic restart, predictable environment handling, and logging through:

journalctl -u pi-camera.service

I also want to add:

IFTTT motion notifications
systemd startup
recording retention/cleanup
motion threshold tuning
authentication for the Flask interface
HTTPS or VPN-only remote access
health monitoring

I would not port-forward TCP/5000 directly to this Pi. The Flask development server and unauthenticated camera-control endpoints are appropriate for a trusted LAN prototype, not an Internet-facing camera. Remote access will go through a VPN or authenticated reverse proxy instead.

Final Thoughts

This project ended up being a good example of why old Raspberry Pi projects are still useful even when their commands aren’t.

The architecture was sound. The surrounding ecosystem changed.

The biggest updates were moving away from global sudo pip3 installs, using a virtual environment alongside Raspberry Pi OS packages, sticking with the current Picamera2 camera stack, and handling Google OAuth without expecting a headless Pi to behave like a desktop.

And now a Pi Zero 2W that fits in the palm of my hand is capturing video, processing frames, serving a live feed, detecting motion, recording events, and talking to Google Drive.

That’s a pretty productive afternoon.

Similar Posts