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.
