#!/usr/bin/python3
# A surveillance camera script for the raspberry pi camera.
# When the camera detects motion, a video is recorded.
# Optionally, a live video stream is accessible over http.
# On a default Raspian installation, the package 'python3-opencv' is required:
# apt-get install python3-opencv
# Known Issues
#
# 2019-09-24 (stretch)
# The automatic white balance (awb) algorithm was updated in firmware
# with kernel 4.19. This caused spontaneous purple distortions
# when recording dark scenes. The old algorithm is still available and
# can be activated with the following command:
# vcdbg set awb_mode 0
# Alternatively, the latest 4.14 kernel can be used:
# BRANCH='stable' rpi-update 45a2e771e7272781e62ae92322734c2b90e0268a
#
# 2019-05-24 (stretch)
# self.video_writer.write(frame) has a memory leak.
# This script will exit after config['output']['max_frames_written']
# has been reached.
#
# 2017-05-24 (jessie)
# There is an issue with cv2.VideoWriter (or the MJPG codec).
# At some point, all newly created files have size zero after a frame
# should have been written.
#
# As a workaround for the issues above,
# the following shell script can be used to restart this python script:
#
# vcdbg set awb_mode 0
# RESTART=1
# while [ "$RESTART" -ne 0 ]; do
# ./cam_surveillance.py
# RESTART=$?
# done
#
__author__ = "Gernot Walzl"
__date__ = "2021-07-04"
import os
import sys
import signal
import base64
import logging
from logging.handlers import TimedRotatingFileHandler
from time import time, sleep
from datetime import datetime
from threading import Thread
from picamera import PiCamera
from picamera.array import PiRGBArray
import cv2
if sys.version_info.major == 2:
from ConfigParser import ConfigParser
from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
else:
from configparser import ConfigParser
from http.server import BaseHTTPRequestHandler, HTTPServer
def load_config():
config = ConfigParser()
config.add_section('camera')
config.set('camera', 'width', '960')
config.set('camera', 'height', '720')
config.set('camera', 'fps', '5')
config.set('camera', 'mode', '0')
config.set('camera', 'rotation', '0')
config.set('camera', 'zoom_x', '0.0')
config.set('camera', 'zoom_y', '0.0')
config.set('camera', 'zoom_w', '1.0')
config.set('camera', 'zoom_h', '1.0')
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', 'max_frames_written', '9000')
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/cam_surveillance.ini'))
return config
def init_logging(config):
logger = logging.getLogger()
logger.setLevel(logging.INFO)
loghandler = TimedRotatingFileHandler(
config.get('output', 'path_log') + 'cam_surveillance.log', 'midnight')
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s')
loghandler.setFormatter(formatter)
logger.addHandler(loghandler)
class SurveillanceCamera:
def __init__(self, config):
self.config = config
self.logger = logging.getLogger(self.__class__.__name__)
self.path_output = config.get('output', 'path_rec')
self.min_length_video = config.getfloat('output', 'min_length_video')
self.max_frames_written = config.getint('output', 'max_frames_written')
self.camera = None
self.capturing = False
self.capturing_retval = 0
self.frame_curr_bgr = None
self.frame_curr_annotated = None
self.frame_curr_gray = None
self.frame_prev_bgr = None
self.frame_prev_annotated = None
self.frame_prev_gray = None
self.time_last_change = 0.0
self.video_writer = None
self.recording = False
self.filenumber = 0
self.num_frames_written = 0
def has_frame_changed(self, frame_gray_1, frame_gray_2, min_changed_pixels):
frame_diff_gray = cv2.absdiff(frame_gray_1, frame_gray_2)
frame_thresh = cv2.threshold(
frame_diff_gray, 32, 255, cv2.THRESH_BINARY)[1]
num_diff_pixels = frame_thresh.sum() / 255
return num_diff_pixels >= min_changed_pixels
def annotate_frame(self, frame_bgr):
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
frame = frame_bgr.copy()
position = (8, 16)
font = cv2.FONT_HERSHEY_PLAIN
color = (255, 255, 255)
cv2.putText(frame, timestamp, position, font, 1.0, color)
return frame
def write_frame(self, frame_bgr):
self.video_writer.write(frame_bgr)
self.num_frames_written += 1
def fourcc(self, c1, c2, c3, c4):
if hasattr(cv2, 'cv'):
return cv2.cv.FOURCC(c1, c2, c3, c4)
else:
return cv2.VideoWriter_fourcc(c1, c2, c3, c4)
def start_recording(self):
self.filenumber += 1
dt_now = datetime.now()
dirname = dt_now.strftime('%Y-%m-%d')
basename = dt_now.strftime('%Y-%m-%d_%H%M%S')
basename += '_' + str(self.filenumber).zfill(4)
filepath = self.path_output + dirname + '/' + basename + '.avi'
fourcc = self.fourcc('M', 'J', 'P', 'G')
if not os.path.exists(self.path_output + dirname):
os.makedirs(self.path_output + dirname)
self.logger.info("Start recording to %s", filepath)
self.recording = True
self.logger.debug(
"fourcc=%s, fps=%s, res=%s",
fourcc, self.camera.framerate, self.camera.resolution)
self.video_writer = cv2.VideoWriter(
filepath, fourcc, float(self.camera.framerate),
self.camera.resolution)
self.logger.debug(
"video_writer=%s isOpened=%s",
self.video_writer, self.video_writer.isOpened())
self.logger.debug("frame_shape=%s", self.frame_prev_bgr.shape)
self.write_frame(self.frame_prev_annotated)
# self.camera.start_recording(filepath)
# if os.path.getsize(filepath) == 0:
# self.logger.error(
# "Unable to encode frames into file (filesize=0)")
# self.stop_capturing(1)
def stop_recording(self):
if self.video_writer:
self.video_writer.release()
self.video_writer = None
# self.camera.stop_recording()
self.recording = False
self.logger.info("Recording stopped")
def handle_frame(self, frame):
self.frame_curr_bgr = frame
self.frame_curr_annotated = self.annotate_frame(frame)
self.frame_curr_gray = cv2.cvtColor(
self.frame_curr_bgr, cv2.COLOR_BGR2GRAY)
# resizing is faster than cv2.GaussianBlur(frame_curr_gray, (33, 33), 0)
self.frame_curr_gray = cv2.resize(self.frame_curr_gray, (160, 120))
if self.frame_prev_gray is not None:
if self.recording:
if self.has_frame_changed(
self.frame_prev_gray, self.frame_curr_gray, 8):
self.time_last_change = time()
else:
if self.has_frame_changed(
self.frame_prev_gray, self.frame_curr_gray, 32):
self.time_last_change = time()
self.start_recording()
if self.recording:
self.write_frame(self.frame_curr_annotated)
if self.time_last_change + self.min_length_video < time():
self.stop_recording()
self.frame_prev_bgr = self.frame_curr_bgr
self.frame_prev_annotated = self.frame_curr_annotated
self.frame_prev_gray = self.frame_curr_gray
def start_capturing(self):
try:
with PiCamera(
resolution=(
self.config.getint('camera', 'width'),
self.config.getint('camera', 'height')),
framerate=self.config.getint('camera', 'fps'),
sensor_mode=self.config.getint('camera', 'mode')) as self.camera:
self.camera.rotation = self.config.getint('camera', 'rotation')
self.camera.zoom = (
self.config.getfloat('camera', 'zoom_x'),
self.config.getfloat('camera', 'zoom_y'),
self.config.getfloat('camera', 'zoom_w'),
self.config.getfloat('camera', 'zoom_h'))
with PiRGBArray(self.camera) as output:
self.logger.info("Start capturing")
self.capturing = True
for _ in self.camera.capture_continuous(
output, 'bgr', use_video_port=True):
self.handle_frame(output.array)
output.seek(0)
output.truncate()
if not self.capturing:
break
if not self.recording:
if self.num_frames_written >= self.max_frames_written:
self.logger.info(
"Maximum number of frames written " +
"(num_frames_written=%s)",
self.num_frames_written)
self.stop_capturing(1)
except Exception as err:
self.logger.error(err)
finally:
if self.recording:
self.stop_recording()
self.logger.info(
"Capturing stopped (retval=%s)", self.capturing_retval)
return self.capturing_retval
def stop_capturing(self, retval=0):
self.capturing_retval = retval
self.capturing = False
def get_frame_annotated(self):
result = None
if self.capturing:
result = self.frame_curr_annotated
return result
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_jpg(self):
result = False
frame = self.server.survcam.get_frame_annotated()
if frame is None:
self.logger().warning("Did not get a frame to send")
else:
ret, jpg = cv2.imencode('.jpg', frame)
self.send_header('Content-type', 'image/jpeg')
self.send_header('Content-length', str(jpg.size))
self.end_headers()
self.wfile.write(jpg.tostring())
result = True
return result
def do_GET(self):
if self.path == "/cam.jpg":
if self.check_auth():
self.send_response(200)
self.send_jpg()
elif self.path == "/cam.mjpg":
if self.check_auth():
self.send_response(200)
self.send_header(
'Content-type',
'multipart/x-mixed-replace; boundary=jpgboundary')
self.end_headers()
while not self.wfile.closed:
self.wfile.write(b"--jpgboundary\n")
if not self.send_jpg():
break
self.wfile.write(b"\n")
self.wfile.flush()
sleep(0.2)
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 CameraHTTPServer(HTTPServer):
def handle_error(self, request, client_address):
logging.getLogger('HTTPServer').warning(
"Exception happened during processing of request from %s",
client_address)
class HTTPServerThread(Thread):
def __init__(self, config, survcam):
Thread.__init__(self)
self.logger = logging.getLogger(self.__class__.__name__)
self.server = CameraHTTPServer(
('', 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 = list()
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):
Thread.__init__(self)
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__)
signal.signal(signal.SIGINT, self.handle_signal) # Ctrl-C
signal.signal(signal.SIGTERM, self.handle_signal) # kill
def handle_signal(self, signum, frame):
self.logger.info("Handling signal %s", signum)
self.survcam.stop_capturing()
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)
exit_status = survcam.start_capturing()
if storagecleanerthread:
storagecleanerthread.stop_cleaning()
if httpthread:
httpthread.stop_serving()
logging.info("Exit with status %s", exit_status)
return exit_status
if __name__ == '__main__':
sys.exit(main())