blakeblackshear.frigate/frigate/output.py

530 lines
17 KiB
Python
Raw Normal View History

2021-06-04 14:09:16 +02:00
import datetime
import glob
2021-06-12 02:08:15 +02:00
import logging
2021-06-04 14:09:16 +02:00
import math
2021-05-30 13:45:37 +02:00
import multiprocessing as mp
import os
import queue
2021-05-29 20:33:01 +02:00
import signal
2021-05-30 13:45:37 +02:00
import subprocess as sp
import threading
from wsgiref.simple_server import make_server
2021-06-12 02:08:15 +02:00
import cv2
2021-06-04 14:09:16 +02:00
import numpy as np
2021-05-30 13:45:37 +02:00
from setproctitle import setproctitle
from ws4py.server.wsgirefserver import (
WebSocketWSGIHandler,
WebSocketWSGIRequestHandler,
WSGIServer,
)
from ws4py.server.wsgiutils import WebSocketWSGIApplication
from ws4py.websocket import WebSocket
2021-06-24 07:45:27 +02:00
from frigate.config import BirdseyeModeEnum, FrigateConfig
from frigate.const import BASE_DIR, BIRDSEYE_PIPE
2021-06-12 02:08:15 +02:00
from frigate.util import SharedMemoryFrameManager, copy_yuv_to_position, get_yuv_crop
logger = logging.getLogger(__name__)
2021-05-30 16:50:33 +02:00
class FFMpegConverter:
def __init__(
self,
in_width: int,
in_height: int,
out_width: int,
out_height: int,
quality: int,
birdseye_rtsp: bool = False,
):
if birdseye_rtsp:
if os.path.exists(BIRDSEYE_PIPE):
os.remove(BIRDSEYE_PIPE)
os.mkfifo(BIRDSEYE_PIPE, mode=0o777)
stdin = os.open(BIRDSEYE_PIPE, os.O_RDONLY | os.O_NONBLOCK)
self.bd_pipe = os.open(BIRDSEYE_PIPE, os.O_WRONLY)
os.close(stdin)
else:
self.bd_pipe = None
ffmpeg_cmd = [
"ffmpeg",
"-f",
"rawvideo",
"-pix_fmt",
"yuv420p",
"-video_size",
f"{in_width}x{in_height}",
"-i",
"pipe:",
"-f",
"mpegts",
"-s",
f"{out_width}x{out_height}",
"-codec:v",
"mpeg1video",
"-q",
f"{quality}",
"-bf",
"0",
"pipe:",
]
2021-05-30 13:45:37 +02:00
self.process = sp.Popen(
ffmpeg_cmd,
stdout=sp.PIPE,
stderr=sp.DEVNULL,
stdin=sp.PIPE,
start_new_session=True,
)
def write(self, b) -> None:
2021-05-30 13:45:37 +02:00
self.process.stdin.write(b)
if self.bd_pipe:
try:
os.write(self.bd_pipe, b)
except BrokenPipeError:
# catch error when no one is listening
return
2021-05-30 13:45:37 +02:00
def read(self, length):
2021-06-09 14:41:30 +02:00
try:
return self.process.stdout.read1(length)
except ValueError:
return False
2021-05-30 13:45:37 +02:00
def exit(self):
if self.bd_pipe:
os.close(self.bd_pipe)
2021-05-30 13:45:37 +02:00
self.process.terminate()
try:
self.process.communicate(timeout=30)
except sp.TimeoutExpired:
self.process.kill()
self.process.communicate()
class BroadcastThread(threading.Thread):
2021-05-30 16:50:33 +02:00
def __init__(self, camera, converter, websocket_server):
2021-05-30 13:45:37 +02:00
super(BroadcastThread, self).__init__()
2021-05-30 16:50:33 +02:00
self.camera = camera
2021-05-30 13:45:37 +02:00
self.converter = converter
self.websocket_server = websocket_server
def run(self):
while True:
2021-05-30 14:36:33 +02:00
buf = self.converter.read(65536)
2021-05-30 13:45:37 +02:00
if buf:
2021-06-12 17:18:13 +02:00
manager = self.websocket_server.manager
with manager.lock:
websockets = manager.websockets.copy()
ws_iter = iter(websockets.values())
for ws in ws_iter:
if (
not ws.terminated
and ws.environ["PATH_INFO"] == f"/{self.camera}"
2021-06-12 17:18:13 +02:00
):
try:
ws.send(buf, binary=True)
except:
pass
2021-05-30 13:45:37 +02:00
elif self.converter.process.poll() is not None:
break
class BirdsEyeFrameManager:
def __init__(self, config: FrigateConfig, frame_manager: SharedMemoryFrameManager):
2021-06-09 14:41:30 +02:00
self.config = config
2021-06-12 02:02:43 +02:00
self.mode = config.birdseye.mode
2021-06-09 14:41:30 +02:00
self.frame_manager = frame_manager
2021-06-11 14:50:09 +02:00
width = config.birdseye.width
height = config.birdseye.height
2021-06-04 14:09:16 +02:00
self.frame_shape = (height, width)
self.yuv_shape = (height * 3 // 2, width)
self.frame = np.ndarray(self.yuv_shape, dtype=np.uint8)
# initialize the frame as black and with the frigate logo
2021-06-04 14:09:16 +02:00
self.blank_frame = np.zeros(self.yuv_shape, np.uint8)
self.blank_frame[:] = 128
2021-06-04 14:09:16 +02:00
self.blank_frame[0 : self.frame_shape[0], 0 : self.frame_shape[1]] = 16
# find and copy the logo on the blank frame
birdseye_logo = None
custom_logo_files = glob.glob(f"{BASE_DIR}/custom.png")
if len(custom_logo_files) > 0:
birdseye_logo = cv2.imread(custom_logo_files[0], cv2.IMREAD_UNCHANGED)
if birdseye_logo is None:
logo_files = glob.glob("/opt/frigate/frigate/images/birdseye.png")
if len(logo_files) > 0:
birdseye_logo = cv2.imread(logo_files[0], cv2.IMREAD_UNCHANGED)
if not birdseye_logo is None:
transparent_layer = birdseye_logo[:, :, 3]
y_offset = height // 2 - transparent_layer.shape[0] // 2
x_offset = width // 2 - transparent_layer.shape[1] // 2
self.blank_frame[
y_offset : y_offset + transparent_layer.shape[1],
x_offset : x_offset + transparent_layer.shape[0],
] = transparent_layer
else:
logger.warning("Unable to read frigate logo")
self.frame[:] = self.blank_frame
2021-06-09 14:41:30 +02:00
self.cameras = {}
for camera, settings in self.config.cameras.items():
# precalculate the coordinates for all the channels
y, u1, u2, v1, v2 = get_yuv_crop(
settings.frame_shape_yuv,
(
0,
0,
settings.frame_shape[1],
settings.frame_shape[0],
),
)
self.cameras[camera] = {
"last_active_frame": 0.0,
"current_frame": 0.0,
2021-06-09 14:41:30 +02:00
"layout_frame": 0.0,
"channel_dims": {
"y": y,
"u1": u1,
"u2": u2,
"v1": v1,
"v2": v2,
},
}
2021-06-04 14:09:16 +02:00
self.camera_layout = []
2021-06-09 14:41:30 +02:00
self.active_cameras = set()
self.layout_dim = 0
self.last_output_time = 0.0
2021-06-04 14:09:16 +02:00
def clear_frame(self):
logger.debug(f"Clearing the birdseye frame")
2021-06-04 14:09:16 +02:00
self.frame[:] = self.blank_frame
2021-06-09 14:41:30 +02:00
def copy_to_position(self, position, camera=None, frame_time=None):
if camera is None:
frame = None
channel_dims = None
else:
2021-07-16 14:28:30 +02:00
try:
frame = self.frame_manager.get(
f"{camera}{frame_time}", self.config.cameras[camera].frame_shape_yuv
)
except FileNotFoundError:
# TODO: better frame management would prevent this edge case
logger.warning(
f"Unable to copy frame {camera}{frame_time} to birdseye."
)
return
2021-06-09 14:41:30 +02:00
channel_dims = self.cameras[camera]["channel_dims"]
2021-06-04 14:09:16 +02:00
copy_yuv_to_position(
self.frame,
self.layout_offsets[position],
self.layout_frame_shape,
frame,
channel_dims,
)
2021-06-04 14:09:16 +02:00
def camera_active(self, mode, object_box_count, motion_box_count):
if mode == BirdseyeModeEnum.continuous:
2021-06-12 02:02:43 +02:00
return True
if mode == BirdseyeModeEnum.motion and motion_box_count > 0:
2021-06-12 02:02:43 +02:00
return True
if mode == BirdseyeModeEnum.objects and object_box_count > 0:
2021-06-12 02:02:43 +02:00
return True
2021-06-09 14:41:30 +02:00
def update_frame(self):
2021-06-04 14:09:16 +02:00
# determine how many cameras are tracking objects within the last 30 seconds
2021-06-09 14:41:30 +02:00
active_cameras = set(
[
cam
for cam, cam_data in self.cameras.items()
if cam_data["last_active_frame"] > 0
and cam_data["current_frame"] - cam_data["last_active_frame"] < 30
2021-06-09 14:41:30 +02:00
]
)
2021-06-04 14:09:16 +02:00
2021-06-09 14:41:30 +02:00
# if there are no active cameras
if len(active_cameras) == 0:
# if the layout is already cleared
if len(self.camera_layout) == 0:
return False
# if the layout needs to be cleared
else:
self.camera_layout = []
2021-06-22 01:57:41 +02:00
self.layout_dim = 0
2021-06-09 14:41:30 +02:00
self.clear_frame()
return True
2021-06-04 14:09:16 +02:00
2021-06-09 14:41:30 +02:00
# calculate layout dimensions
layout_dim = math.ceil(math.sqrt(len(active_cameras)))
# reset the layout if it needs to be different
if layout_dim != self.layout_dim:
logger.debug(f"Changing layout size from {self.layout_dim} to {layout_dim}")
2021-06-09 14:41:30 +02:00
self.layout_dim = layout_dim
self.camera_layout = [None] * layout_dim * layout_dim
# calculate resolution of each position in the layout
self.layout_frame_shape = (
self.frame_shape[0] // layout_dim, # height
self.frame_shape[1] // layout_dim, # width
)
2021-06-04 14:09:16 +02:00
self.clear_frame()
2021-06-09 14:41:30 +02:00
for cam_data in self.cameras.values():
cam_data["layout_frame"] = 0.0
self.active_cameras = set()
self.layout_offsets = []
# calculate the x and y offset for each position in the layout
for position in range(0, len(self.camera_layout)):
y_offset = self.layout_frame_shape[0] * math.floor(
position / self.layout_dim
)
x_offset = self.layout_frame_shape[1] * (position % self.layout_dim)
self.layout_offsets.append((y_offset, x_offset))
2021-06-09 14:41:30 +02:00
removed_cameras = self.active_cameras.difference(active_cameras)
added_cameras = active_cameras.difference(self.active_cameras)
self.active_cameras = active_cameras
# update each position in the layout
for position, camera in enumerate(self.camera_layout, start=0):
# if this camera was removed, replace it or clear it
if camera in removed_cameras:
# if replacing this camera with a newly added one
if len(added_cameras) > 0:
added_camera = added_cameras.pop()
self.camera_layout[position] = added_camera
self.copy_to_position(
position,
added_camera,
self.cameras[added_camera]["current_frame"],
2021-06-09 14:41:30 +02:00
)
self.cameras[added_camera]["layout_frame"] = self.cameras[
added_camera
]["current_frame"]
2021-06-09 14:41:30 +02:00
# if removing this camera with no replacement
else:
self.camera_layout[position] = None
self.copy_to_position(position)
removed_cameras.remove(camera)
# if an empty spot and there are cameras to add
elif camera is None and len(added_cameras) > 0:
added_camera = added_cameras.pop()
self.camera_layout[position] = added_camera
self.copy_to_position(
position,
added_camera,
self.cameras[added_camera]["current_frame"],
2021-06-09 14:41:30 +02:00
)
self.cameras[added_camera]["layout_frame"] = self.cameras[added_camera][
"current_frame"
2021-06-09 14:41:30 +02:00
]
# if not an empty spot and the camera has a newer frame, copy it
elif (
not camera is None
and self.cameras[camera]["current_frame"]
2021-06-09 14:41:30 +02:00
!= self.cameras[camera]["layout_frame"]
):
self.copy_to_position(
position, camera, self.cameras[camera]["current_frame"]
2021-06-09 14:41:30 +02:00
)
self.cameras[camera]["layout_frame"] = self.cameras[camera][
"current_frame"
2021-06-09 14:41:30 +02:00
]
2021-06-04 14:09:16 +02:00
return True
2021-06-09 14:41:30 +02:00
def update(self, camera, object_count, motion_count, frame_time, frame) -> bool:
# don't process if birdseye is disabled for this camera
camera_config = self.config.cameras[camera].birdseye
if not camera_config.enabled:
return False
2021-06-09 14:41:30 +02:00
# update the last active frame for the camera
self.cameras[camera]["current_frame"] = frame_time
if self.camera_active(camera_config.mode, object_count, motion_count):
2021-06-09 14:41:30 +02:00
self.cameras[camera]["last_active_frame"] = frame_time
now = datetime.datetime.now().timestamp()
2021-06-10 15:05:06 +02:00
# limit output to 10 fps
if (now - self.last_output_time) < 1 / 10:
2021-06-09 14:41:30 +02:00
return False
# if the frame was updated or the fps is too low, send frame
if self.update_frame() or (now - self.last_output_time) > 1:
self.last_output_time = now
return True
return False
2021-06-09 14:41:30 +02:00
2021-06-11 14:50:09 +02:00
def output_frames(config: FrigateConfig, video_output_queue):
2021-05-30 13:45:37 +02:00
threading.current_thread().name = f"output"
setproctitle(f"frigate.output")
2021-05-29 20:33:01 +02:00
stop_event = mp.Event()
def receiveSignal(signalNumber, frame):
stop_event.set()
signal.signal(signal.SIGTERM, receiveSignal)
signal.signal(signal.SIGINT, receiveSignal)
frame_manager = SharedMemoryFrameManager()
previous_frames = {}
2021-05-30 13:45:37 +02:00
# start a websocket server on 8082
WebSocketWSGIHandler.http_version = "1.1"
websocket_server = make_server(
2021-05-30 14:36:33 +02:00
"127.0.0.1",
2021-05-30 13:45:37 +02:00
8082,
server_class=WSGIServer,
handler_class=WebSocketWSGIRequestHandler,
app=WebSocketWSGIApplication(handler_cls=WebSocket),
)
websocket_server.initialize_websockets_manager()
websocket_thread = threading.Thread(target=websocket_server.serve_forever)
2021-05-30 16:50:33 +02:00
converters = {}
broadcasters = {}
for camera, cam_config in config.cameras.items():
2021-07-21 14:47:11 +02:00
width = int(
Add go2rtc and add restream role / live source (#4082) * Pull go2rtc dependency * Add go2rtc to local services and add to s6 * Add relay controller for go2rtc * Add restream role * Add restream role * Add restream to nginx * Add camera live source config * Disable RTMP by default and use restream * Use go2rtc for camera config * Fix go2rtc move * Start restream on frigate start * Send restream to camera level * Fix restream * Make sure jsmpeg works as expected * Make view rspect live size config * Tweak player options to fit live view * Adjust VideoPlayer to accept live option which disables irrelevant controls * Add multiple options from restream live view * Add base for webrtc option * Setup specific restream modules * Make mp4 the default streaming for now * Expose 8554 for rtsp relay from go2rtc * Formatting * Update docs to suggest new restream method. * Update docs to reflect restream role * Update docs to reflect restream role * Add webrtc player * Improvements to webRTC * Support webrtc * Cleanup * Adjust rtmp test and add restream test * Fix tests * Add restream tests * Add live view docs and show different options * Small docs tweak * Support all stream types * Update to beta 9 of go2rtc * Formatting * Make jsmpeg the default * Support wss if made from https * Support wss if made from https * Use onEffect * Set url outside onEffect * Fix passed deps * Update docs about required host mode * Try memo instead * Close websocket on changing camera * Formatting * Close pc connection * Set video source to null on cleanup * Use full path since go2rtc can't see PATH var * Adjust audio codec to enable browser audio by default * Cleanup stream creation * Add restream tests * Format tests * Mock requests * Adjust paths * Move stream configs to restream * Remove live source * Remove live config * Use live persistence for which view to use on each camera * Fix live sizes * Only use jsmpeg sizes for jsmpeg live * Set max live size * Remove access of live config * Add selector for live view source in web view * Remove RTMP from default list of roles * Update docs * Fix tests * Fix docs for live view modes * make default undefined to avoid race condition * Wait until camera source is loaded to avoid race condition * Fix tests * Add config to go2rtc * Work with config * Set full path for config * Set to use stun * Check for mounted file * Look for frigate-go2rtc * Update docs to reflect webRTC configuration. * Add link to go2rtc config * Update docs to be more clear * Update docs to be more clear * Update format Co-authored-by: Felipe Santos <felipecassiors@gmail.com> * Update live docs * Improve bash startup script * Add option to force audio compatibility * Formatting * Fix mapping * Fix broken link * Update go2rtc version * Get go2rtc webui working * Add support for mse * Remove mp4 option * Undo changes to video player * Update docs for new live view options * Make separate path for mse * Remove unused * Remove mp4 path * Try to get go2rtc proxy working * Try to get go2rtc proxy working * Remove unused callback * Allow websocket on restrea dashboard * Make mse default stream option * Fix mse sizing * don't assume roles is defined * Remove nginx mapping to go2rtc ui Co-authored-by: Felipe Santos <felipecassiors@gmail.com> Co-authored-by: Blake Blackshear <blakeb@blakeshome.com>
2022-11-02 12:36:09 +01:00
cam_config.restream.jsmpeg.height
2021-07-21 14:47:11 +02:00
* (cam_config.frame_shape[1] / cam_config.frame_shape[0])
)
2021-05-30 16:50:33 +02:00
converters[camera] = FFMpegConverter(
cam_config.frame_shape[1],
cam_config.frame_shape[0],
2021-07-21 14:47:11 +02:00
width,
Add go2rtc and add restream role / live source (#4082) * Pull go2rtc dependency * Add go2rtc to local services and add to s6 * Add relay controller for go2rtc * Add restream role * Add restream role * Add restream to nginx * Add camera live source config * Disable RTMP by default and use restream * Use go2rtc for camera config * Fix go2rtc move * Start restream on frigate start * Send restream to camera level * Fix restream * Make sure jsmpeg works as expected * Make view rspect live size config * Tweak player options to fit live view * Adjust VideoPlayer to accept live option which disables irrelevant controls * Add multiple options from restream live view * Add base for webrtc option * Setup specific restream modules * Make mp4 the default streaming for now * Expose 8554 for rtsp relay from go2rtc * Formatting * Update docs to suggest new restream method. * Update docs to reflect restream role * Update docs to reflect restream role * Add webrtc player * Improvements to webRTC * Support webrtc * Cleanup * Adjust rtmp test and add restream test * Fix tests * Add restream tests * Add live view docs and show different options * Small docs tweak * Support all stream types * Update to beta 9 of go2rtc * Formatting * Make jsmpeg the default * Support wss if made from https * Support wss if made from https * Use onEffect * Set url outside onEffect * Fix passed deps * Update docs about required host mode * Try memo instead * Close websocket on changing camera * Formatting * Close pc connection * Set video source to null on cleanup * Use full path since go2rtc can't see PATH var * Adjust audio codec to enable browser audio by default * Cleanup stream creation * Add restream tests * Format tests * Mock requests * Adjust paths * Move stream configs to restream * Remove live source * Remove live config * Use live persistence for which view to use on each camera * Fix live sizes * Only use jsmpeg sizes for jsmpeg live * Set max live size * Remove access of live config * Add selector for live view source in web view * Remove RTMP from default list of roles * Update docs * Fix tests * Fix docs for live view modes * make default undefined to avoid race condition * Wait until camera source is loaded to avoid race condition * Fix tests * Add config to go2rtc * Work with config * Set full path for config * Set to use stun * Check for mounted file * Look for frigate-go2rtc * Update docs to reflect webRTC configuration. * Add link to go2rtc config * Update docs to be more clear * Update docs to be more clear * Update format Co-authored-by: Felipe Santos <felipecassiors@gmail.com> * Update live docs * Improve bash startup script * Add option to force audio compatibility * Formatting * Fix mapping * Fix broken link * Update go2rtc version * Get go2rtc webui working * Add support for mse * Remove mp4 option * Undo changes to video player * Update docs for new live view options * Make separate path for mse * Remove unused * Remove mp4 path * Try to get go2rtc proxy working * Try to get go2rtc proxy working * Remove unused callback * Allow websocket on restrea dashboard * Make mse default stream option * Fix mse sizing * don't assume roles is defined * Remove nginx mapping to go2rtc ui Co-authored-by: Felipe Santos <felipecassiors@gmail.com> Co-authored-by: Blake Blackshear <blakeb@blakeshome.com>
2022-11-02 12:36:09 +01:00
cam_config.restream.jsmpeg.height,
cam_config.restream.jsmpeg.quality,
2021-05-30 16:50:33 +02:00
)
broadcasters[camera] = BroadcastThread(
camera, converters[camera], websocket_server
)
2021-05-30 13:45:37 +02:00
2021-06-11 14:50:09 +02:00
if config.birdseye.enabled:
converters["birdseye"] = FFMpegConverter(
config.birdseye.width,
config.birdseye.height,
config.birdseye.width,
config.birdseye.height,
config.birdseye.quality,
config.restream.birdseye,
2021-06-11 14:50:09 +02:00
)
broadcasters["birdseye"] = BroadcastThread(
"birdseye", converters["birdseye"], websocket_server
)
2021-05-30 13:45:37 +02:00
websocket_thread.start()
2021-05-30 16:50:33 +02:00
for t in broadcasters.values():
t.start()
2021-05-30 13:45:37 +02:00
2021-06-11 14:50:09 +02:00
birdseye_manager = BirdsEyeFrameManager(config, frame_manager)
if config.restream.birdseye:
birdseye_buffer = frame_manager.create(
"birdseye",
birdseye_manager.yuv_shape[0] * birdseye_manager.yuv_shape[1],
)
2021-05-29 20:33:01 +02:00
while not stop_event.is_set():
try:
(
camera,
frame_time,
current_tracked_objects,
motion_boxes,
regions,
) = video_output_queue.get(True, 10)
except queue.Empty:
continue
frame_id = f"{camera}{frame_time}"
frame = frame_manager.get(frame_id, config.cameras[camera].frame_shape_yuv)
2021-05-30 16:50:33 +02:00
# send camera frame to ffmpeg process if websockets are connected
if any(
ws.environ["PATH_INFO"].endswith(camera) for ws in websocket_server.manager
):
# write to the converter for the camera if clients are listening to the specific camera
converters[camera].write(frame.tobytes())
2021-05-30 13:45:37 +02:00
if config.birdseye.enabled and (
config.restream.birdseye
or any(
ws.environ["PATH_INFO"].endswith("birdseye")
for ws in websocket_server.manager
)
):
2021-06-04 14:09:16 +02:00
if birdseye_manager.update(
camera,
len([o for o in current_tracked_objects if not o["stationary"]]),
len(motion_boxes),
frame_time,
frame,
2021-06-04 14:09:16 +02:00
):
frame_bytes = birdseye_manager.frame.tobytes()
if config.restream.birdseye:
birdseye_buffer[:] = frame_bytes
converters["birdseye"].write(frame_bytes)
if camera in previous_frames:
frame_manager.delete(f"{camera}{previous_frames[camera]}")
2021-06-09 14:41:30 +02:00
previous_frames[camera] = frame_time
2021-05-30 13:45:37 +02:00
while not video_output_queue.empty():
(
camera,
frame_time,
current_tracked_objects,
motion_boxes,
regions,
) = video_output_queue.get(True, 10)
frame_id = f"{camera}{frame_time}"
frame = frame_manager.get(frame_id, config.cameras[camera].frame_shape_yuv)
frame_manager.delete(frame_id)
2021-05-30 16:50:33 +02:00
for c in converters.values():
c.exit()
for b in broadcasters.values():
b.join()
2021-05-30 13:45:37 +02:00
websocket_server.manager.close_all()
websocket_server.manager.stop()
websocket_server.manager.join()
websocket_server.shutdown()
websocket_thread.join()
2021-06-12 02:08:15 +02:00
logger.info("exiting output process...")