Compare commits

..

5 Commits

227
main.py
View File

@@ -1,15 +1,21 @@
from datetime 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,13 @@ class Config:
@dataclass
class AppState:
stopEvent = Event()
cameraStopComplete = Event()
stopRecording = Event()
diskEventQueue: Queue[DiskEvent] = Queue()
diskState: DiskState = "disconnected"
config = Config()
failedUmount: bool = False
cameraState = "off"
def saveConfig(config: Config, path: Path) -> None:
@@ -103,12 +113,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 +142,22 @@ 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()
blueCtr += 1
state.stopEvent.wait(0.25)
@@ -152,10 +180,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(
[
@@ -169,12 +194,16 @@ def mountDisk(state: AppState) -> None:
)
sleep(1)
except subprocess.CalledProcessError:
state.diskState = "disconnected"
pass
else:
state.diskState = "connected"
def unmountDisk(state: AppState) -> None:
state.cameraState = "armed"
state.stopRecording.wait()
state.stopRecording.clear()
if not state.failedUmount:
try:
subprocess.run(
@@ -205,6 +234,178 @@ 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) or (
recording and state.cameraState == "armed"
):
setGlobalFlag = False
if recording and state.cameraState == "armed":
setGlobalFlag = True
circular_output.stop()
state.cameraState = "armed"
recording = False
elapsed = now - recording_trigger_time
recording_path = None
if setGlobalFlag:
state.stopRecording.set()
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()
@@ -229,6 +430,15 @@ def main() -> None:
ledThread.start()
cameraThread = Thread(
target=cameraWorker,
name="camera_thread",
args=(state,),
daemon=True,
)
cameraThread.start()
if state.config.diskSerial != "":
mountDisk(state)
@@ -257,6 +467,7 @@ def main() -> None:
finally:
state.stopEvent.set()
state.cameraStopComplete.wait()
if state.diskState == "connected":
unmountDisk(state)
if state.failedUmount: