diff --git a/main.py b/main.py index 65a9620..c2ade46 100644 --- a/main.py +++ b/main.py @@ -1,15 +1,21 @@ +import datetime from functools import partial import json from os import getuid from pathlib import Path from queue import Empty, Queue import subprocess -from time import sleep +from time import monotonic, sleep from typing import Literal import pyudev from gpiozero import LED, Button from threading import Thread, Event from dataclasses import asdict, dataclass +import numpy as np +from libcamera import controls +from picamera2 import Picamera2 +from picamera2.encoders import H264Encoder +from picamera2.outputs import CircularOutput red = LED(17) yellow = LED(27) @@ -25,6 +31,7 @@ class DiskEvent: DiskState = Literal["disconnected", "pairing", "connecting", "connected"] +CameraState = Literal["off", "armed", "recording"] @dataclass @@ -35,10 +42,12 @@ class Config: @dataclass class AppState: stopEvent = Event() + cameraStopComplete = Event() diskEventQueue: Queue[DiskEvent] = Queue() diskState: DiskState = "disconnected" config = Config() failedUmount: bool = False + cameraState = "off" def saveConfig(config: Config, path: Path) -> None: @@ -103,12 +112,14 @@ def diskPollWorker(state) -> None: def ledWorker(state: AppState) -> None: yellowCtr = 0 - previousState: DiskState | None = None + blueCtr = 0 + previousDiskState: DiskState | None = None + previousCameraState: CameraState | None = None while not state.stopEvent.is_set(): - if state.diskState != previousState: + if state.diskState != previousDiskState: yellowCtr = 0 - previousState = state.diskState + previousDiskState = state.diskState if state.diskState == "disconnected": yellow.off() @@ -130,6 +141,20 @@ def ledWorker(state: AppState) -> None: yellowCtr += 1 + if state.cameraState != previousCameraState: + blueCtr = 0 + previousCameraState = state.cameraState + + if state.cameraState == "off": + blue.off() + elif state.cameraState == "armed": + blue.on() + elif state.cameraState == "recording": + if blueCtr % 4 < 2: + blue.on() + else: + blue.off() + state.stopEvent.wait(0.25) @@ -152,10 +177,7 @@ def mountDisk(state: AppState) -> None: state.diskState = "connecting" node = findDiskNode(state.config.diskSerial) try: - subprocess.run( - ["mkdir", "-p", f"/mnt/{state.config.diskSerial}"], - check=True, - ) + Path(f"/mnt/{state.config.diskSerial}").mkdir(parents=True, exist_ok=True) sleep(1) subprocess.run( [ @@ -205,6 +227,170 @@ def unmountDisk(state: AppState) -> None: state.failedUmount = False +def cameraWorker(state: AppState) -> None: + VIDEO_SIZE = (1280, 720) + ANALYSIS_SIZE = (320, 240) + + FRAME_RATE = 15 + BITRATE = 2_000_000 + + QUIET_SECONDS = 10.0 + + PRE_RECORD_SECONDS = 2.0 + + WARMUP_SECONDS = 3.0 + + PIXEL_CHANGE_THRESHOLD = 25 + + MOTION_AREA_THRESHOLD = 0.015 + + ANALYSIS_SAMPLE_STEP = 2 + + OUTPUT_DIRECTORY = Path(f"/mnt/{state.config.diskSerial}/cctv") + + camera = Picamera2() + + configuration = camera.create_video_configuration( + main={ + "size": VIDEO_SIZE, + "format": "YUV420", + }, + lores={ + "size": ANALYSIS_SIZE, + "format": "YUV420", + }, + controls={ + "FrameRate": FRAME_RATE, + }, + buffer_count=6, + ) + + camera.configure(configuration) + + camera.set_controls( + { + "AfMode": controls.AfModeEnum.Continuous, + } + ) + + encoder = H264Encoder( + bitrate=BITRATE, + repeat=True, + iperiod=FRAME_RATE, + ) + + prebuffer_frames = max( + 1, + round(FRAME_RATE * PRE_RECORD_SECONDS), + ) + + circular_output = CircularOutput( + buffersize=prebuffer_frames, + ) + + encoder.output = circular_output + + recording = False + recording_path: Path | None = None + recording_trigger_time = 0.0 + last_motion_time = 0.0 + + camera_started = False + encoder_started = False + + try: + camera.start() + camera_started = True + + camera.start_encoder(encoder) + encoder_started = True + + sleep(WARMUP_SECONDS) + + analysis_width, analysis_height = ANALYSIS_SIZE + + first_frame = camera.capture_array("lores") + previous_frame = first_frame[:analysis_height, :analysis_width] + previous_frame = previous_frame[ + ::ANALYSIS_SAMPLE_STEP, + ::ANALYSIS_SAMPLE_STEP, + ].astype(np.int16) + + while not state.stopEvent.is_set(): + if state.diskState != "connected": + state.cameraState = "off" + state.stopEvent.wait(1) + continue + if state.cameraState == "off": + state.cameraState = "armed" + frame = camera.capture_array("lores") + + # YUV420 starts with the full-resolution greyscale Y plane. + current_frame = frame[:analysis_height, :analysis_width] + current_frame = current_frame[ + ::ANALYSIS_SAMPLE_STEP, + ::ANALYSIS_SAMPLE_STEP, + ].astype(np.int16) + + difference = np.abs(current_frame - previous_frame) + + changed_pixels = np.count_nonzero(difference >= PIXEL_CHANGE_THRESHOLD) + + changed_fraction = changed_pixels / difference.size + motion_detected = changed_fraction >= MOTION_AREA_THRESHOLD + + now = monotonic() + + if motion_detected: + last_motion_time = now + + if not recording: + if not OUTPUT_DIRECTORY.exists(): + OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True) + + filename_timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + + recording_path = OUTPUT_DIRECTORY / f"{filename_timestamp}.h264" + + # Start writing the circular pre-buffer and all subsequent + # encoded H.264 packets directly to the file. + circular_output.fileoutput = str(recording_path) + circular_output.start() + + recording = True + state.cameraState = "recording" + recording_trigger_time = now + + elif recording and now - last_motion_time >= QUIET_SECONDS: + circular_output.stop() + state.cameraState = "armed" + recording = False + + elapsed = now - recording_trigger_time + + recording_path = None + + previous_frame = current_frame + finally: + if recording: + circular_output.stop() + + if encoder_started: + try: + camera.stop_encoder() + except Exception: + pass + + if camera_started: + try: + camera.stop() + except Exception: + pass + + camera.close() + state.cameraStopComplete.set() + + def main() -> None: try: state = AppState() @@ -257,6 +443,7 @@ def main() -> None: finally: state.stopEvent.set() + state.cameraStopComplete.wait() if state.diskState == "connected": unmountDisk(state) if state.failedUmount: