#!/usr/bin/python3
# A surveillance camera script based on Picamera2.
# When the camera detects motion, a video is recorded.
# Optionally, a live video stream is accessible over http.
# Picamera2 Manual:
# https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf
# Picamera2 Source:
# https://github.com/raspberrypi/picamera2
# Picamera2 Examples:
# https://github.com/raspberrypi/picamera2/blob/main/examples/capture_motion.py
# https://github.com/raspberrypi/picamera2/blob/main/examples/mjpeg_server_2.py
# To automatically start the surveillance camera script on boot,
# a systemd service can be defined:
# /etc/systemd/system/picam2_surveillance.service
#
# [Unit]
# Description=Picamera2 Surveillance Camera
#
# [Service]
# ExecStart=/usr/local/bin/picam2_surveillance.py
#
# [Install]
# WantedBy=multi-user.target
#
__author__ = "Gernot Walzl"
__date__ = "2023-12-29"
import os
import sys
import signal
import base64
from datetime import datetime
from time import time, sleep
from threading import Thread, Lock, Condition
import subprocess
import io
from configparser import ConfigParser
import logging
from logging.handlers import TimedRotatingFileHandler
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import cv2
import numpy as np
import libcamera
from picamera2 import Picamera2, MappedArray
from picamera2.encoders import H264Encoder, MJPEGEncoder
from picamera2.outputs import FileOutput
def load_config():
config = ConfigParser()
config.add_section('camera')
config.set('camera', 'width', '960')
config.set('camera', 'height', '720')
config.set('camera', 'fps', '15.0')
config.set('camera', 'mode', '1')
config.set('camera', 'hflip', '0')
config.set('camera', 'vflip', '0')
config.set('camera', 'apply_scaler_crop', 'no')
config.set('camera', 'scaler_crop_x', '0')
config.set('camera', 'scaler_crop_y', '2')
config.set('camera', 'scaler_crop_w', '3280')
config.set('camera', 'scaler_crop_h', '2460')
config.add_section('output')
config.set('output', 'path_rec', os.path.expanduser('~/cam/rec/'))
config.set('output', 'path_log', os.path.expanduser('~/cam/log/'))
config.set('output', 'max_used_space', str(32 * 1024**3))
config.set('output', 'min_length_video', '5.0')
config.set('output', 'convert_mp4', 'yes')
config.add_section('server')
config.set('server', 'port', '8000')
config.set('server', 'user', 'user')
config.set('server', 'pass', 'pass')
config.read(os.path.expanduser('~/.config/picam2_surveillance.ini'))
return config
def init_logging(config):
logger = logging.getLogger()
logger.setLevel(logging.INFO)
loghandler = TimedRotatingFileHandler(
config.get('output', 'path_log') + 'picam2_surveillance.log',
'midnight')
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s')
loghandler.setFormatter(formatter)
logger.addHandler(loghandler)
class FfmpegThread(Thread):
def __init__(self, fps, filepath):
super().__init__()
self._fps = fps
self._filepath = filepath
def run(self):
command = ['ffmpeg', '-loglevel', 'error', '-y']
video_input = ['-r', str(self._fps), '-i', self._filepath]
video_codec = ['-c:v', 'copy']
output = ['-movflags', 'faststart', self._filepath[:-4]+'mp4']
command += video_input + video_codec + output
ffmpeg = subprocess.Popen(command)
if ffmpeg.wait() == 0:
os.remove(self._filepath)
class StreamingOutput(io.BufferedIOBase):
def __init__(self):
super().__init__()
self.frame = None
self.condition = Condition()
def write(self, buf):
with self.condition:
self.frame = buf
self.condition.notify_all()
class SurveillanceCamera:
def __init__(self, config):
self._logger = logging.getLogger(self.__class__.__name__)
self._width = config.getint('camera', 'width')
self._height = config.getint('camera', 'height')
self._fps = config.getfloat('camera', 'fps')
self._sensor_mode = config.getint('camera', 'mode')
self._hflip = config.getint('camera', 'hflip')
self._vflip = config.getint('camera', 'vflip')
self._scaler_crop = None
if config.getboolean('camera', 'apply_scaler_crop'):
self._scaler_crop = (
config.getint('camera', 'scaler_crop_x'),
config.getint('camera', 'scaler_crop_y'),
config.getint('camera', 'scaler_crop_w'),
config.getint('camera', 'scaler_crop_h'))
self._path_output = config.get('output', 'path_rec')
self._min_length_video = config.getfloat('output', 'min_length_video')
self._convert_mp4 = config.getboolean('output', 'convert_mp4')
self._picam2 = Picamera2()
self._capturing = False
self._encoder_recording = None
self._filepath_recording = None
self._encoder_serving = None
self._output = None
self._lock_serving = Lock()
self._counter_serving = 0
@staticmethod
def _annotate_timestamp(request):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
position = (8, 16)
font = cv2.FONT_HERSHEY_PLAIN
color = (255, 255, 255)
with MappedArray(request, "main") as frame:
cv2.putText(frame.array, timestamp, position, font, 1.0, color)
def _has_frame_changed(self, frame_1, frame_2, min_changed_pixels):
frame_diff = np.abs(np.subtract(frame_1.astype(int), frame_2))
num_changed_pixels = (frame_diff > 32).sum()
return num_changed_pixels > min_changed_pixels
def start_recording(self):
dt_now = datetime.now()
dirname = dt_now.strftime('%Y-%m-%d')
basename = dt_now.strftime('%Y-%m-%d_%H%M%S')
if not os.path.exists(self._path_output + dirname):
os.makedirs(self._path_output + dirname)
self._filepath_recording = (
self._path_output + dirname + '/' + basename + '.h264')
self._logger.info("Start recording to %s", self._filepath_recording)
self._encoder_recording = H264Encoder()
self._encoder_recording.framerate = self._fps
# The FfmpegOutput creates a jitter because the timestamps of the
# video frames are not forwarded. Ffmpeg stamps the video frames
# as it gets them. Unfortunately, ffmpeg takes a second to start.
# output = FfmpegOutput(filepath)
output = FileOutput(self._filepath_recording)
self._picam2.start_encoder(self._encoder_recording, output)
def stop_recording(self):
self._picam2.stop_encoder(self._encoder_recording)
self._encoder_recording = None
self._logger.info("Recording stopped")
if self._convert_mp4:
self._logger.info("Converting %s", self._filepath_recording)
FfmpegThread(self._fps, self._filepath_recording).start()
def start_capturing(self):
self._logger.info(
"Selected sensor mode: %s",
self._picam2.sensor_modes[self._sensor_mode])
config = self._picam2.create_video_configuration(
# Picamera2 Manual - Appendix A: Pixel and image formats
main={
'format': 'BGR888',
'size': (self._width, self._height)
},
lores={
'format': 'YUV420',
'size': (int(self._width/4), int(self._height/4))
},
# Although the IDs of the sensor modes are different in picamera,
# there is good documentation to get an idea about sensor modes:
# https://picamera.readthedocs.io/en/latest/fov.html#sensor-modes
raw=self._picam2.sensor_modes[self._sensor_mode],
# Picamera2 Manual - Appendix B: Camera configuration parameters
transform=libcamera.Transform(hflip=self._hflip, vflip=self._vflip),
# Picamera2 Manual - Appendix C: Camera controls
controls={
'FrameDurationLimits': (int(1e6/self._fps), int(1e6/self._fps))
}
)
if self._scaler_crop is not None:
config['controls']['ScalerCrop'] = self._scaler_crop
self._logger.info("Used config: %s", config)
self._picam2.configure(config)
self._picam2.post_callback = SurveillanceCamera._annotate_timestamp
self._logger.info("Start capturing")
self._capturing = True
self._picam2.start()
frame_curr = None
frame_prev = None
time_last_change = None
while self._capturing:
(lores_w, lores_h) = self._picam2.video_configuration.lores.size
frame_curr = self._picam2.capture_buffer('lores')[:lores_w*lores_h]
frame_curr = frame_curr.reshape(lores_h, lores_w)
if frame_prev is not None:
if self._encoder_recording is None:
if self._has_frame_changed(frame_prev, frame_curr, 32):
time_last_change = time()
self.start_recording()
else:
if self._has_frame_changed(frame_prev, frame_curr, 8):
time_last_change = time()
elif time_last_change + self._min_length_video < time():
self.stop_recording()
frame_prev = frame_curr
if self._encoder_recording is not None:
self.stop_recording()
self._picam2.stop()
self._logger.info("Capturing stopped")
def stop_capturing(self):
self._capturing = False
def start_serving(self):
self._logger.info("Start serving")
with self._lock_serving:
if self._counter_serving == 0:
self._logger.info("Start MJPEG encoder")
self._encoder_serving = MJPEGEncoder()
self._encoder_serving.framerate = self._fps
self._output = StreamingOutput()
self._picam2.start_encoder(
self._encoder_serving, FileOutput(self._output))
self._counter_serving += 1
return self._output
def stop_serving(self):
with self._lock_serving:
self._counter_serving -= 1
if self._counter_serving == 0:
self._picam2.stop_encoder(self._encoder_serving)
self._encoder_serving = None
self._output = None
self._logger.info("MJPEG encoder stopped")
self._logger.info("Serving stopped")
class CameraHTTPRequestHandler(BaseHTTPRequestHandler):
def logger(self):
return logging.getLogger('HTTPRequestHandler')
def check_auth(self):
result = False
if (self.server.auth is None or
self.server.auth == self.headers.get('authorization')):
result = True
else:
self.send_response(401)
self.send_header('WWW-Authenticate', 'Basic')
self.end_headers()
return result
def send_jpeg(self, output):
with output.condition:
output.condition.wait()
self.send_header('Content-Type', 'image/jpeg')
self.send_header('Content-Length', len(output.frame))
self.end_headers()
self.wfile.write(output.frame)
def do_GET(self):
if self.path == "/cam.jpg":
if self.check_auth():
output = self.server.survcam.start_serving()
try:
self.send_response(200)
self.send_jpeg(output)
except Exception as err:
self.logger().warning(
"Exception while serving client %s: %s",
self.client_address, str(err))
finally:
self.server.survcam.stop_serving()
output = None
elif self.path == "/cam.mjpg":
if self.check_auth():
output = self.server.survcam.start_serving()
try:
self.send_response(200)
self.send_header(
'Content-Type',
'multipart/x-mixed-replace; boundary=FRAME')
self.end_headers()
while not self.wfile.closed:
self.wfile.write(b"--FRAME\r\n")
self.send_jpeg(output)
self.wfile.write(b"\r\n")
self.wfile.flush()
except Exception as err:
self.logger().warning(
"Exception while serving client %s: %s",
self.client_address, str(err))
finally:
self.server.survcam.stop_serving()
output = None
else:
self.send_error(404)
def log_error(self, format, *args):
self.logger().error("%s - %s" % (self.address_string(), format % args))
def log_message(self, format, *args):
self.logger().info("%s - %s" % (self.address_string(), format % args))
class HTTPServerThread(Thread):
def __init__(self, config, survcam):
super().__init__()
self.logger = logging.getLogger(self.__class__.__name__)
self.server = ThreadingHTTPServer(
('', config.getint('server', 'port')),
CameraHTTPRequestHandler)
self.server.survcam = survcam
self.server.auth = None
if config.get('server', 'user') and config.get('server', 'pass'):
str_auth = config.get('server', 'user') + ':' + config.get('server', 'pass')
self.server.auth = 'Basic ' + base64.b64encode(str_auth.encode()).decode()
def run(self):
self.logger.info(
"Starting HTTP server on port %s", self.server.server_port)
self.server.serve_forever()
def stop_serving(self):
self.logger.info("Stopping HTTP server")
self.server.shutdown()
class StorageCleaner:
def __init__(self, path, max_used_space):
self._logger = logging.getLogger(self.__class__.__name__)
self._path = path
self._max_used_space = max_used_space
def _get_filelist(self):
filelist = []
for dirpath, _, filenames in os.walk(self._path):
for filename in filenames:
filepath = os.path.join(dirpath, filename)
filemtime = os.path.getmtime(filepath)
filesize = os.path.getsize(filepath)
fileinfo = (filemtime, filesize, filepath)
filelist.append(fileinfo)
filelist.sort()
return filelist
def remove_oldest_files(self):
filelist = self._get_filelist()
sumfilesizes = sum(filesize for _, filesize, _ in filelist)
for fileinfo in filelist:
if sumfilesizes > self._max_used_space:
filepath = fileinfo[2]
self._logger.info("Removing %s", filepath)
os.remove(filepath)
sumfilesizes -= fileinfo[1]
else:
break
def remove_empty_dirs(self):
for dirpath, dirnames, filenames in os.walk(self._path):
if (not dirnames) and (not filenames):
self._logger.info("Removing empty dir %s", dirpath)
os.rmdir(dirpath)
class StorageCleanerThread(Thread):
def __init__(self, config):
super().__init__()
self._cleaning = False
self._cleaner = StorageCleaner(
config.get('output', 'path_rec'),
config.getint('output', 'max_used_space'))
def run(self):
self._cleaning = True
while self._cleaning:
self._cleaner.remove_oldest_files()
self._cleaner.remove_empty_dirs()
for _ in range(60):
sleep(1)
if not self._cleaning:
break
def stop_cleaning(self):
self._cleaning = False
class SignalHandler:
def __init__(self, survcam):
self._survcam = survcam
self._logger = logging.getLogger(self.__class__.__name__)
def _handle_signal(self, signum, frame):
self._logger.info("Handling signal %s", signum)
self._survcam.stop_capturing()
def register_signal_handlers(self):
signal.signal(signal.SIGINT, self._handle_signal) # Ctrl-C
signal.signal(signal.SIGTERM, self._handle_signal) # kill
def main():
config = load_config()
if not os.path.exists(config.get('output', 'path_rec')):
os.makedirs(config.get('output', 'path_rec'))
if not os.path.exists(config.get('output', 'path_log')):
os.makedirs(config.get('output', 'path_log'))
init_logging(config)
logging.info("Logging initialized")
survcam = SurveillanceCamera(config)
httpthread = None
if config.getint('server', 'port') > 0:
httpthread = HTTPServerThread(config, survcam)
httpthread.start()
storagecleanerthread = None
if config.getint('output', 'max_used_space') > 0:
storagecleanerthread = StorageCleanerThread(config)
storagecleanerthread.start()
sighandler = SignalHandler(survcam)
sighandler.register_signal_handlers()
survcam.start_capturing()
if storagecleanerthread:
storagecleanerthread.stop_cleaning()
if httpthread:
httpthread.stop_serving()
if __name__ == '__main__':
sys.exit(main())