feat!: web user interface

This commit is contained in:
Paul Armstrong
2021-01-09 09:26:46 -08:00
committed by Blake Blackshear
parent 5ad4017510
commit c618867941
29 changed files with 9112 additions and 123 deletions

View File

@@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
DETECTORS_SCHEMA = vol.Schema(
{
vol.Required(str): {
vol.Required('type', default='edgetpu'): vol.In(['cpu', 'edgetpu']),
vol.Required('type', default='edgetpu'): vol.In(['cpu', 'edgetpu']),
vol.Optional('device', default='usb'): str,
vol.Optional('num_threads', default=3): int
}
@@ -77,7 +77,7 @@ RECORD_FFMPEG_OUTPUT_ARGS_DEFAULT = ["-f", "segment", "-segment_time",
"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]),
@@ -107,7 +107,7 @@ DETECT_SCHEMA = vol.Schema(
)
FILTER_SCHEMA = vol.Schema(
{
{
str: {
vol.Optional('min_area', default=0): int,
vol.Optional('max_area', default=24000000): int,
@@ -139,14 +139,14 @@ def each_role_used_once(inputs):
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")),
}], 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]),
@@ -252,7 +252,7 @@ class DatabaseConfig():
@property
def path(self):
return self._path
def to_dict(self):
return {
'path': self.path
@@ -262,15 +262,15 @@ 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,
@@ -282,19 +282,19 @@ class DetectorConfig():
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,
@@ -306,15 +306,15 @@ 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,
@@ -334,23 +334,23 @@ class MqttConfig():
@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
@@ -376,19 +376,19 @@ class CameraInput():
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(' ')
@@ -401,11 +401,11 @@ 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()}
@@ -414,15 +414,15 @@ class RetainConfig():
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,
@@ -438,7 +438,7 @@ class ClipsConfig():
@property
def max_seconds(self):
return self._max_seconds
@property
def tmpfs_cache_size(self):
return self._tmpfs_cache_size
@@ -446,7 +446,7 @@ class ClipsConfig():
@property
def retain(self):
return self._retain
def to_dict(self):
return {
'max_seconds': self.max_seconds,
@@ -462,11 +462,11 @@ class RecordConfig():
@property
def enabled(self):
return self._enabled
@property
def retain_days(self):
return self._retain_days
def to_dict(self):
return {
'enabled': self.enabled,
@@ -479,7 +479,7 @@ class FilterConfig():
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
@@ -491,11 +491,11 @@ class FilterConfig():
@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,
@@ -511,11 +511,11 @@ class ObjectConfig():
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
@@ -538,7 +538,7 @@ class CameraSnapshotsConfig():
@property
def enabled(self):
return self._enabled
@property
def timestamp(self):
return self._timestamp
@@ -576,11 +576,11 @@ class CameraMqttConfig():
self._bounding_box = config['bounding_box']
self._crop = config['crop']
self._height = config.get('height')
@property
def enabled(self):
return self._enabled
@property
def timestamp(self):
return self._timestamp
@@ -596,7 +596,7 @@ class CameraMqttConfig():
@property
def height(self):
return self._height
def to_dict(self):
return {
'enabled': self.enabled,
@@ -617,11 +617,11 @@ class CameraClipsConfig():
@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
@@ -629,11 +629,11 @@ class CameraClipsConfig():
@property
def objects(self):
return self._objects
@property
def retain(self):
return self._retain
def to_dict(self):
return {
'enabled': self.enabled,
@@ -646,11 +646,11 @@ class CameraClipsConfig():
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,
@@ -663,7 +663,7 @@ class MotionConfig():
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
@@ -683,7 +683,7 @@ class MotionConfig():
@property
def frame_height(self):
return self._frame_height
def to_dict(self):
return {
'threshold': self.threshold,
@@ -698,11 +698,11 @@ class MotionConfig():
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,
@@ -721,36 +721,37 @@ class ZoneConfig():
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()}
'filters': {k: f.to_dict() for k, f in self.filters.items()},
'coordinates': self._coordinates
}
class CameraConfig():
@@ -763,6 +764,7 @@ class CameraConfig():
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._raw_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._clips = CameraClipsConfig(global_config, config['clips'])
@@ -798,9 +800,9 @@ class CameraConfig():
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:]
@@ -812,7 +814,7 @@ class CameraConfig():
logger.warning(f"Could not read mask file {mask}")
else:
mask_img[np.where(mask_file==[0])] = [0]
def _get_ffmpeg_cmd(self, ffmpeg_input):
ffmpeg_output_args = []
if 'detect' in ffmpeg_input.roles:
@@ -831,7 +833,7 @@ class CameraConfig():
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
@@ -842,9 +844,9 @@ class CameraConfig():
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()
@@ -852,10 +854,10 @@ class CameraConfig():
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
@@ -863,59 +865,59 @@ class CameraConfig():
@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 clips(self):
return self._clips
@property
def record(self):
return self._record
@property
def rtmp(self):
return self._rtmp
@property
def snapshots(self):
return self._snapshots
@property
def mqtt(self):
return self._mqtt
@property
def objects(self):
return self._objects
@property
def motion(self):
return self._motion
@property
def detect(self):
return self._detect
@@ -948,6 +950,7 @@ class CameraConfig():
'objects': self.objects.to_dict(),
'motion': self.motion.to_dict(),
'detect': self.detect.to_dict(),
'mask': self._raw_mask,
'frame_shape': self.frame_shape,
'ffmpeg_cmds': [{'roles': c['roles'], 'cmd': ' '.join(c['cmd'])} for c in self.ffmpeg_cmds],
}
@@ -959,7 +962,7 @@ class FrigateConfig():
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)
@@ -976,25 +979,25 @@ class FrigateConfig():
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)
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"):
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(),
@@ -1005,19 +1008,19 @@ class FrigateConfig():
'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
@@ -1025,7 +1028,7 @@ class FrigateConfig():
@property
def mqtt(self):
return self._mqtt
@property
def clips(self):
return self._clips

View File

@@ -36,7 +36,7 @@ def create_app(frigate_config, database: SqliteDatabase, stats_tracking, detecte
app.frigate_config = frigate_config
app.stats_tracking = stats_tracking
app.detected_frames_processor = detected_frames_processor
app.register_blueprint(bp)
return app
@@ -50,15 +50,15 @@ def events_summary():
groups = (
Event
.select(
Event.camera,
Event.label,
fn.strftime('%Y-%m-%d', fn.datetime(Event.start_time, 'unixepoch', 'localtime')).alias('day'),
Event.camera,
Event.label,
fn.strftime('%Y-%m-%d', fn.datetime(Event.start_time, 'unixepoch', 'localtime')).alias('day'),
Event.zones,
fn.COUNT(Event.id).alias('count')
)
.group_by(
Event.camera,
Event.label,
Event.camera,
Event.label,
fn.strftime('%Y-%m-%d', fn.datetime(Event.start_time, 'unixepoch', 'localtime')),
Event.zones
)
@@ -90,18 +90,18 @@ def event_snapshot(id):
thumbnail_bytes = tracked_obj.get_jpg_bytes()
except:
return "Event not found", 404
if thumbnail_bytes is None:
return "Event not found", 404
# android notifications prefer a 2:1 ratio
if format == 'android':
jpg_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
img = cv2.imdecode(jpg_as_np, flags=1)
thumbnail = cv2.copyMakeBorder(img, 0, 0, int(img.shape[1]*0.5), int(img.shape[1]*0.5), cv2.BORDER_CONSTANT, (0,0,0))
ret, jpg = cv2.imencode('.jpg', thumbnail)
ret, jpg = cv2.imencode('.jpg', thumbnail)
thumbnail_bytes = jpg.tobytes()
response = make_response(thumbnail_bytes)
response.headers['Content-Type'] = 'image/jpg'
return response
@@ -118,19 +118,19 @@ def events():
has_snapshot = request.args.get('has_snapshot', type=int)
clauses = []
if camera:
clauses.append((Event.camera == camera))
if label:
clauses.append((Event.label == label))
if zone:
clauses.append((Event.zones.cast('text') % f"*\"{zone}\"*"))
if after:
clauses.append((Event.start_time >= after))
if before:
clauses.append((Event.start_time <= before))
@@ -172,13 +172,13 @@ def best(camera_name, label):
best_frame = np.zeros((720,1280,3), np.uint8)
else:
best_frame = cv2.cvtColor(best_frame, cv2.COLOR_YUV2BGR_I420)
crop = bool(request.args.get('crop', 0, type=int))
if crop:
box = best_object.get('box', (0,0,300,300))
region = calculate_region(best_frame.shape, box[0], box[1], box[2], box[3], 1.1)
best_frame = best_frame[region[1]:region[3], region[0]:region[2]]
height = int(request.args.get('h', str(best_frame.shape[0])))
width = int(height*best_frame.shape[1]/best_frame.shape[0])
@@ -236,7 +236,7 @@ def latest_frame(camera_name):
return response
else:
return "Camera named {} not found".format(camera_name), 404
def imagestream(detected_frames_processor, camera_name, fps, height, draw_options):
while True:
# max out at specified FPS