import base64 import json import os from typing import Dict import cv2 import matplotlib.pyplot as plt import numpy as np import voluptuous as vol import yaml from frigate.const import RECORD_DIR, CLIPS_DIR, CACHE_DIR DETECTORS_SCHEMA = vol.Schema( { vol.Required(str): { vol.Required('type', default='edgetpu'): vol.In(['cpu', 'edgetpu']), vol.Optional('device', default='usb'): str, vol.Optional('num_threads', default=3): int } } ) DEFAULT_DETECTORS = { 'coral': { 'type': 'edgetpu', 'device': 'usb' } } MQTT_SCHEMA = vol.Schema( { vol.Required('host'): str, vol.Optional('port', default=1883): int, vol.Optional('topic_prefix', default='frigate'): str, vol.Optional('client_id', default='frigate'): str, 'user': str, 'password': str } ) SAVE_CLIPS_RETAIN_SCHEMA = vol.Schema( { vol.Required('default',default=10): int, 'objects': { str: int } } ) SAVE_CLIPS_SCHEMA = vol.Schema( { vol.Optional('max_seconds', default=300): int, 'tmpfs_cache_size': str, vol.Optional('retain', default={}): SAVE_CLIPS_RETAIN_SCHEMA } ) FFMPEG_GLOBAL_ARGS_DEFAULT = ['-hide_banner','-loglevel','fatal'] FFMPEG_INPUT_ARGS_DEFAULT = ['-avoid_negative_ts', 'make_zero', '-fflags', '+genpts+discardcorrupt', '-rtsp_transport', 'tcp', '-stimeout', '5000000', '-use_wallclock_as_timestamps', '1'] DETECT_FFMPEG_OUTPUT_ARGS_DEFAULT = ['-f', 'rawvideo', '-pix_fmt', 'yuv420p'] RTMP_FFMPEG_OUTPUT_ARGS_DEFAULT = ["-c", "copy", "-f", "flv"] SAVE_CLIPS_FFMPEG_OUTPUT_ARGS_DEFAULT = ["-f", "segment", "-segment_time", "10", "-segment_format", "mp4", "-reset_timestamps", "1", "-strftime", "1", "-c", "copy", "-an"] RECORD_FFMPEG_OUTPUT_ARGS_DEFAULT = ["-f", "segment", "-segment_time", "60", "-segment_format", "mp4", "-reset_timestamps", "1", "-strftime", "1", "-c", "copy", "-an"] GLOBAL_FFMPEG_SCHEMA = vol.Schema( { vol.Optional('global_args', default=FFMPEG_GLOBAL_ARGS_DEFAULT): vol.Any(str, [str]), vol.Optional('hwaccel_args', default=[]): vol.Any(str, [str]), vol.Optional('input_args', default=FFMPEG_INPUT_ARGS_DEFAULT): vol.Any(str, [str]), vol.Optional('output_args', default={}): { vol.Optional('detect', default=DETECT_FFMPEG_OUTPUT_ARGS_DEFAULT): vol.Any(str, [str]), vol.Optional('record', default=RECORD_FFMPEG_OUTPUT_ARGS_DEFAULT): vol.Any(str, [str]), vol.Optional('clips', default=SAVE_CLIPS_FFMPEG_OUTPUT_ARGS_DEFAULT): vol.Any(str, [str]), vol.Optional('rtmp', default=RTMP_FFMPEG_OUTPUT_ARGS_DEFAULT): vol.Any(str, [str]), } } ) MOTION_SCHEMA = vol.Schema( { 'threshold': vol.Range(min=1, max=255), 'contour_area': int, 'delta_alpha': float, 'frame_alpha': float, 'frame_height': int } ) DETECT_SCHEMA = vol.Schema( { 'max_disappeared': int } ) FILTER_SCHEMA = vol.Schema( { str: { vol.Optional('min_area', default=0): int, vol.Optional('max_area', default=24000000): int, vol.Optional('threshold', default=0.7): float } } ) def filters_for_all_tracked_objects(object_config): for tracked_object in object_config.get('track', ['person']): if not 'filters' in object_config: object_config['filters'] = {} if not tracked_object in object_config['filters']: object_config['filters'][tracked_object] = {} return object_config OBJECTS_SCHEMA = vol.Schema(vol.All(filters_for_all_tracked_objects, { vol.Optional('track', default=['person']): [str], vol.Optional('filters', default = {}): FILTER_SCHEMA.extend({ str: {vol.Optional('min_score', default=0.5): float}}) } )) def each_role_used_once(inputs): roles = [role for i in inputs for role in i['roles']] roles_set = set(roles) if len(roles) > len(roles_set): raise ValueError return inputs CAMERA_FFMPEG_SCHEMA = vol.Schema( { vol.Required('inputs'): vol.All([{ vol.Required('path'): str, vol.Required('roles'): ['detect', 'clips', 'record', 'rtmp'], 'global_args': vol.Any(str, [str]), 'hwaccel_args': vol.Any(str, [str]), 'input_args': vol.Any(str, [str]), }], vol.Msg(each_role_used_once, msg="Each input role may only be used once")), 'output_args': { vol.Optional('detect', default=DETECT_FFMPEG_OUTPUT_ARGS_DEFAULT): vol.Any(str, [str]), vol.Optional('record', default=RECORD_FFMPEG_OUTPUT_ARGS_DEFAULT): vol.Any(str, [str]), vol.Optional('clips', default=SAVE_CLIPS_FFMPEG_OUTPUT_ARGS_DEFAULT): vol.Any(str, [str]), vol.Optional('rtmp', default=RTMP_FFMPEG_OUTPUT_ARGS_DEFAULT): vol.Any(str, [str]), } } ) def ensure_zones_and_cameras_have_different_names(cameras): zones = [zone for camera in cameras.values() for zone in camera['zones'].keys()] for zone in zones: if zone in cameras.keys(): raise ValueError return cameras CAMERAS_SCHEMA = vol.Schema(vol.All( { str: { vol.Required('ffmpeg'): CAMERA_FFMPEG_SCHEMA, vol.Required('height'): int, vol.Required('width'): int, 'fps': int, 'mask': vol.Any(str, [str]), vol.Optional('best_image_timeout', default=60): int, vol.Optional('zones', default={}): { str: { vol.Required('coordinates'): vol.Any(str, [str]), vol.Optional('filters', default={}): FILTER_SCHEMA } }, vol.Optional('save_clips', default={}): { vol.Optional('enabled', default=False): bool, vol.Optional('pre_capture', default=5): int, vol.Optional('post_capture', default=5): int, 'objects': [str], vol.Optional('retain', default={}): SAVE_CLIPS_RETAIN_SCHEMA, }, vol.Optional('record', default={}): { 'enabled': bool, 'retain_days': int, }, vol.Optional('rtmp', default={}): { vol.Required('enabled', default=True): bool, }, vol.Optional('snapshots', default={}): { vol.Optional('show_timestamp', default=True): bool, vol.Optional('draw_zones', default=False): bool, vol.Optional('draw_bounding_boxes', default=True): bool, vol.Optional('crop_to_region', default=True): bool, vol.Optional('height', default=175): int }, 'objects': OBJECTS_SCHEMA, vol.Optional('motion', default={}): MOTION_SCHEMA, vol.Optional('detect', default={}): DETECT_SCHEMA } }, vol.Msg(ensure_zones_and_cameras_have_different_names, msg='Zones cannot share names with cameras')) ) FRIGATE_CONFIG_SCHEMA = vol.Schema( { vol.Optional('database', default={}): { vol.Optional('path', default=os.path.join(CLIPS_DIR, 'frigate.db')): str }, vol.Optional('model', default={'width': 320, 'height': 320}): { vol.Required('width'): int, vol.Required('height'): int }, vol.Optional('detectors', default=DEFAULT_DETECTORS): DETECTORS_SCHEMA, 'mqtt': MQTT_SCHEMA, vol.Optional('logger', default={'default': 'info', 'logs': {}}): { vol.Optional('default', default='info'): vol.In(['info', 'debug', 'warning', 'error', 'critical']), vol.Optional('logs', default={}): {str: vol.In(['info', 'debug', 'warning', 'error', 'critical']) } }, vol.Optional('save_clips', default={}): SAVE_CLIPS_SCHEMA, vol.Optional('record', default={}): { vol.Optional('enabled', default=False): bool, vol.Optional('retain_days', default=30): int, }, vol.Optional('ffmpeg', default={}): GLOBAL_FFMPEG_SCHEMA, vol.Optional('objects', default={}): OBJECTS_SCHEMA, vol.Optional('motion', default={}): MOTION_SCHEMA, vol.Optional('detect', default={}): DETECT_SCHEMA, vol.Required('cameras', default={}): CAMERAS_SCHEMA } ) class DatabaseConfig(): def __init__(self, config): self._path = config['path'] @property def path(self): return self._path def to_dict(self): return { 'path': self.path } class ModelConfig(): def __init__(self, config): self._width = config['width'] self._height = config['height'] @property def width(self): return self._width @property def height(self): return self._height def to_dict(self): return { 'width': self.width, 'height': self.height } class DetectorConfig(): def __init__(self, config): self._type = config['type'] self._device = config['device'] self._num_threads = config['num_threads'] @property def type(self): return self._type @property def device(self): return self._device @property def num_threads(self): return self._num_threads def to_dict(self): return { 'type': self.type, 'device': self.device, 'num_threads': self.num_threads } class LoggerConfig(): def __init__(self, config): self._default = config['default'].upper() self._logs = {k: v.upper() for k, v in config['logs'].items()} @property def default(self): return self._default @property def logs(self): return self._logs def to_dict(self): return { 'default': self.default, 'logs': self.logs } class MqttConfig(): def __init__(self, config): self._host = config['host'] self._port = config['port'] self._topic_prefix = config['topic_prefix'] self._client_id = config['client_id'] self._user = config.get('user') self._password = config.get('password') @property def host(self): return self._host @property def port(self): return self._port @property def topic_prefix(self): return self._topic_prefix @property def client_id(self): return self._client_id @property def user(self): return self._user @property def password(self): return self._password def to_dict(self): return { 'host': self.host, 'port': self.port, 'topic_prefix': self.topic_prefix, 'client_id': self.client_id, 'user': self.user } class CameraInput(): def __init__(self, global_config, ffmpeg_input): self._path = ffmpeg_input['path'] self._roles = ffmpeg_input['roles'] self._global_args = ffmpeg_input.get('global_args', global_config['global_args']) self._hwaccel_args = ffmpeg_input.get('hwaccel_args', global_config['hwaccel_args']) self._input_args = ffmpeg_input.get('input_args', global_config['input_args']) @property def path(self): return self._path @property def roles(self): return self._roles @property def global_args(self): return self._global_args if isinstance(self._global_args, list) else self._global_args.split(' ') @property def hwaccel_args(self): return self._hwaccel_args if isinstance(self._hwaccel_args, list) else self._hwaccel_args.split(' ') @property def input_args(self): return self._input_args if isinstance(self._input_args, list) else self._input_args.split(' ') class CameraFfmpegConfig(): def __init__(self, global_config, config): self._inputs = [CameraInput(global_config, i) for i in config['inputs']] self._output_args = config.get('output_args', global_config['output_args']) @property def inputs(self): return self._inputs @property def output_args(self): return {k: v if isinstance(v, list) else v.split(' ') for k, v in self._output_args.items()} class SaveClipsRetainConfig(): def __init__(self, global_config, config): self._default = config.get('default', global_config.get('default')) self._objects = config.get('objects', global_config.get('objects', {})) @property def default(self): return self._default @property def objects(self): return self._objects def to_dict(self): return { 'default': self.default, 'objects': self.objects } class SaveClipsConfig(): def __init__(self, config): self._max_seconds = config['max_seconds'] self._tmpfs_cache_size = config.get('tmpfs_cache_size', '').strip() self._retain = SaveClipsRetainConfig(config['retain'], config['retain']) @property def max_seconds(self): return self._max_seconds @property def tmpfs_cache_size(self): return self._tmpfs_cache_size @property def retain(self): return self._retain def to_dict(self): return { 'max_seconds': self.max_seconds, 'tmpfs_cache_size': self.tmpfs_cache_size, 'retain': self.retain.to_dict() } class RecordConfig(): def __init__(self, global_config, config): self._enabled = config.get('enabled', global_config['enabled']) self._retain_days = config.get('retain_days', global_config['retain_days']) @property def enabled(self): return self._enabled @property def retain_days(self): return self._retain_days def to_dict(self): return { 'enabled': self.enabled, 'retain_days': self.retain_days, } class FilterConfig(): def __init__(self, config): self._min_area = config['min_area'] self._max_area = config['max_area'] self._threshold = config['threshold'] self._min_score = config.get('min_score') @property def min_area(self): return self._min_area @property def max_area(self): return self._max_area @property def threshold(self): return self._threshold @property def min_score(self): return self._min_score def to_dict(self): return { 'min_area': self.min_area, 'max_area': self.max_area, 'threshold': self.threshold, 'min_score': self.min_score } class ObjectConfig(): def __init__(self, global_config, config): self._track = config.get('track', global_config['track']) if 'filters' in config: self._filters = { name: FilterConfig(c) for name, c in config['filters'].items() } else: self._filters = { name: FilterConfig(c) for name, c in global_config['filters'].items() } @property def track(self): return self._track @property def filters(self) -> Dict[str, FilterConfig]: return self._filters def to_dict(self): return { 'track': self.track, 'filters': { k: f.to_dict() for k, f in self.filters.items() } } class CameraSnapshotsConfig(): def __init__(self, config): self._show_timestamp = config['show_timestamp'] self._draw_zones = config['draw_zones'] self._draw_bounding_boxes = config['draw_bounding_boxes'] self._crop_to_region = config['crop_to_region'] self._height = config.get('height') @property def show_timestamp(self): return self._show_timestamp @property def draw_zones(self): return self._draw_zones @property def draw_bounding_boxes(self): return self._draw_bounding_boxes @property def crop_to_region(self): return self._crop_to_region @property def height(self): return self._height def to_dict(self): return { 'show_timestamp': self.show_timestamp, 'draw_zones': self.draw_zones, 'draw_bounding_boxes': self.draw_bounding_boxes, 'crop_to_region': self.crop_to_region, 'height': self.height } class CameraSaveClipsConfig(): def __init__(self, global_config, config): self._enabled = config['enabled'] self._pre_capture = config['pre_capture'] self._post_capture = config['post_capture'] self._objects = config.get('objects', global_config['objects']['track']) self._retain = SaveClipsRetainConfig(global_config['save_clips']['retain'], config['retain']) @property def enabled(self): return self._enabled @property def pre_capture(self): return self._pre_capture @property def post_capture(self): return self._post_capture @property def objects(self): return self._objects @property def retain(self): return self._retain def to_dict(self): return { 'enabled': self.enabled, 'pre_capture': self.pre_capture, 'post_capture': self.post_capture, 'objects': self.objects, 'retain': self.retain.to_dict() } class CameraRtmpConfig(): def __init__(self, global_config, config): self._enabled = config['enabled'] @property def enabled(self): return self._enabled def to_dict(self): return { 'enabled': self.enabled, } class MotionConfig(): def __init__(self, global_config, config, camera_height: int): self._threshold = config.get('threshold', global_config.get('threshold', 25)) self._contour_area = config.get('contour_area', global_config.get('contour_area', 100)) self._delta_alpha = config.get('delta_alpha', global_config.get('delta_alpha', 0.2)) self._frame_alpha = config.get('frame_alpha', global_config.get('frame_alpha', 0.2)) self._frame_height = config.get('frame_height', global_config.get('frame_height', camera_height//6)) @property def threshold(self): return self._threshold @property def contour_area(self): return self._contour_area @property def delta_alpha(self): return self._delta_alpha @property def frame_alpha(self): return self._frame_alpha @property def frame_height(self): return self._frame_height def to_dict(self): return { 'threshold': self.threshold, 'contour_area': self.contour_area, 'delta_alpha': self.delta_alpha, 'frame_alpha': self.frame_alpha, 'frame_height': self.frame_height, } class DetectConfig(): def __init__(self, global_config, config, camera_fps): self._max_disappeared = config.get('max_disappeared', global_config.get('max_disappeared', camera_fps*2)) @property def max_disappeared(self): return self._max_disappeared def to_dict(self): return { 'max_disappeared': self._max_disappeared, } class ZoneConfig(): def __init__(self, name, config): self._coordinates = config['coordinates'] self._filters = { name: FilterConfig(c) for name, c in config['filters'].items() } if isinstance(self._coordinates, list): self._contour = np.array([[int(p.split(',')[0]), int(p.split(',')[1])] for p in self._coordinates]) elif isinstance(self._coordinates, str): points = self._coordinates.split(',') self._contour = np.array([[int(points[i]), int(points[i+1])] for i in range(0, len(points), 2)]) else: print(f"Unable to parse zone coordinates for {name}") self._contour = np.array([]) self._color = (0,0,0) @property def coordinates(self): return self._coordinates @property def contour(self): return self._contour @contour.setter def contour(self, val): self._contour = val @property def color(self): return self._color @color.setter def color(self, val): self._color = val @property def filters(self): return self._filters def to_dict(self): return { 'filters': {k: f.to_dict() for k, f in self.filters.items()} } class CameraConfig(): def __init__(self, name, config, global_config): self._name = name self._ffmpeg = CameraFfmpegConfig(global_config['ffmpeg'], config['ffmpeg']) self._height = config.get('height') self._width = config.get('width') self._frame_shape = (self._height, self._width) self._frame_shape_yuv = (self._frame_shape[0]*3//2, self._frame_shape[1]) self._fps = config.get('fps') self._mask = self._create_mask(config.get('mask')) self._best_image_timeout = config['best_image_timeout'] self._zones = { name: ZoneConfig(name, z) for name, z in config['zones'].items() } self._save_clips = CameraSaveClipsConfig(global_config, config['save_clips']) self._record = RecordConfig(global_config['record'], config['record']) self._rtmp = CameraRtmpConfig(global_config, config['rtmp']) self._snapshots = CameraSnapshotsConfig(config['snapshots']) self._objects = ObjectConfig(global_config['objects'], config.get('objects', {})) self._motion = MotionConfig(global_config['motion'], config['motion'], self._height) self._detect = DetectConfig(global_config['detect'], config['detect'], config.get('fps', 5)) self._ffmpeg_cmds = [] for ffmpeg_input in self._ffmpeg.inputs: ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input) if ffmpeg_cmd is None: continue self._ffmpeg_cmds.append({ 'roles': ffmpeg_input.roles, 'cmd': ffmpeg_cmd }) self._set_zone_colors(self._zones) def _create_mask(self, mask): mask_img = np.zeros(self.frame_shape, np.uint8) mask_img[:] = 255 if isinstance(mask, list): for m in mask: self._add_mask(m, mask_img) elif isinstance(mask, str): self._add_mask(mask, mask_img) return mask_img def _add_mask(self, mask, mask_img): if mask.startswith('poly,'): points = mask.split(',')[1:] contour = np.array([[int(points[i]), int(points[i+1])] for i in range(0, len(points), 2)]) cv2.fillPoly(mask_img, pts=[contour], color=(0)) else: mask_file = cv2.imread(f"/config/{mask}", cv2.IMREAD_GRAYSCALE) if not mask_file.size == 0: mask_img[np.where(mask_file==[0])] = [0] def _get_ffmpeg_cmd(self, ffmpeg_input): ffmpeg_output_args = [] if 'detect' in ffmpeg_input.roles: ffmpeg_output_args = self.ffmpeg.output_args['detect'] + ffmpeg_output_args + ['pipe:'] if self.fps: ffmpeg_output_args = ["-r", str(self.fps)] + ffmpeg_output_args if 'rtmp' in ffmpeg_input.roles and self.rtmp.enabled: ffmpeg_output_args = self.ffmpeg.output_args['rtmp'] + [ f"rtmp://127.0.0.1/live/{self.name}" ] + ffmpeg_output_args if 'clips' in ffmpeg_input.roles and self.save_clips.enabled: ffmpeg_output_args = self.ffmpeg.output_args['clips'] + [ f"{os.path.join(CACHE_DIR, self.name)}-%Y%m%d%H%M%S.mp4" ] + ffmpeg_output_args if 'record' in ffmpeg_input.roles and self.record.enabled: ffmpeg_output_args = self.ffmpeg.output_args['record'] + [ f"{os.path.join(RECORD_DIR, self.name)}-%Y%m%d%H%M%S.mp4" ] + ffmpeg_output_args # if there arent any outputs enabled for this input if len(ffmpeg_output_args) == 0: return None cmd = (['ffmpeg'] + ffmpeg_input.global_args + ffmpeg_input.hwaccel_args + ffmpeg_input.input_args + ['-i', ffmpeg_input.path] + ffmpeg_output_args) return [part for part in cmd if part != ''] def _set_zone_colors(self, zones: Dict[str, ZoneConfig]): # set colors for zones all_zone_names = zones.keys() zone_colors = {} colors = plt.cm.get_cmap('tab10', len(all_zone_names)) for i, zone in enumerate(all_zone_names): zone_colors[zone] = tuple(int(round(255 * c)) for c in colors(i)[:3]) for name, zone in zones.items(): zone.color = zone_colors[name] @property def name(self): return self._name @property def ffmpeg(self): return self._ffmpeg @property def height(self): return self._height @property def width(self): return self._width @property def fps(self): return self._fps @property def mask(self): return self._mask @property def best_image_timeout(self): return self._best_image_timeout @property def zones(self)-> Dict[str, ZoneConfig]: return self._zones @property def save_clips(self): return self._save_clips @property def record(self): return self._record @property def rtmp(self): return self._rtmp @property def snapshots(self): return self._snapshots @property def objects(self): return self._objects @property def motion(self): return self._motion @property def detect(self): return self._detect @property def frame_shape(self): return self._frame_shape @property def frame_shape_yuv(self): return self._frame_shape_yuv @property def ffmpeg_cmds(self): return self._ffmpeg_cmds def to_dict(self): return { 'name': self.name, 'height': self.height, 'width': self.width, 'fps': self.fps, 'best_image_timeout': self.best_image_timeout, 'zones': {k: z.to_dict() for k, z in self.zones.items()}, 'save_clips': self.save_clips.to_dict(), 'record': self.record.to_dict(), 'rtmp': self.rtmp.to_dict(), 'snapshots': self.snapshots.to_dict(), 'objects': self.objects.to_dict(), 'motion': self.motion.to_dict(), 'detect': self.detect.to_dict(), 'frame_shape': self.frame_shape, 'ffmpeg_cmds': [{'roles': c['roles'], 'cmd': ' '.join(c['cmd'])} for c in self.ffmpeg_cmds], } class FrigateConfig(): def __init__(self, config_file=None, config=None): if config is None and config_file is None: raise ValueError('config or config_file must be defined') elif not config_file is None: config = self._load_file(config_file) config = FRIGATE_CONFIG_SCHEMA(config) config = self._sub_env_vars(config) self._database = DatabaseConfig(config['database']) self._model = ModelConfig(config['model']) self._detectors = { name: DetectorConfig(d) for name, d in config['detectors'].items() } self._mqtt = MqttConfig(config['mqtt']) self._save_clips = SaveClipsConfig(config['save_clips']) self._cameras = { name: CameraConfig(name, c, config) for name, c in config['cameras'].items() } self._logger = LoggerConfig(config['logger']) def _sub_env_vars(self, config): frigate_env_vars = {k: v for k, v in os.environ.items() if k.startswith('FRIGATE_')} if 'password' in config['mqtt']: config['mqtt']['password'] = config['mqtt']['password'].format(**frigate_env_vars) for camera in config['cameras'].values(): for i in camera['ffmpeg']['inputs']: i['path'] = i['path'].format(**frigate_env_vars) return config def _load_file(self, config_file): with open(config_file) as f: raw_config = f.read() if config_file.endswith(".yml"): config = yaml.safe_load(raw_config) elif config_file.endswith(".json"): config = json.loads(raw_config) return config def to_dict(self): return { 'database': self.database.to_dict(), 'model': self.model.to_dict(), 'detectors': {k: d.to_dict() for k, d in self.detectors.items()}, 'mqtt': self.mqtt.to_dict(), 'save_clips': self.save_clips.to_dict(), 'cameras': {k: c.to_dict() for k, c in self.cameras.items()}, 'logger': self.logger.to_dict() } @property def database(self): return self._database @property def model(self): return self._model @property def detectors(self) -> Dict[str, DetectorConfig]: return self._detectors @property def logger(self): return self._logger @property def mqtt(self): return self._mqtt @property def save_clips(self): return self._save_clips @property def cameras(self) -> Dict[str, CameraConfig]: return self._cameras