diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..f8e873695 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,27 @@ +{ + "name": "Frigate Dev", + "dockerComposeFile": "../docker-compose.yml", + "service": "dev", + "workspaceFolder": "/opt/frigate", + "extensions": [ + "ms-python.python", + "visualstudioexptteam.vscodeintellicode", + "mhutchie.git-graph", + "ms-azuretools.vscode-docker", + "streetsidesoftware.code-spell-checker", + "eamodio.gitlens", + "esbenp.prettier-vscode", + "ms-python.vscode-pylance" + ], + "settings": { + "python.pythonPath": "/usr/bin/python3", + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true, + "terminal.integrated.shell.linux": "/bin/bash" + } +} diff --git a/.dockerignore b/.dockerignore index c6f4105f3..b15dbe7b1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,4 +4,5 @@ docs/ debug config/ *.pyc -.git \ No newline at end of file +.git +core \ No newline at end of file diff --git a/.gitignore b/.gitignore index a1dc92a01..85caded13 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store *.pyc +*.swp debug .vscode config/config.yml @@ -10,3 +11,4 @@ frigate/version.py web/build web/node_modules web/coverage +core diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 000000000..bf205daa6 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,588 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-whitelist= + +# Specify a score threshold to be exceeded before program exits with error. +fail-under=10.0 + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=print-statement, + parameter-unpacking, + unpacking-in-except, + old-raise-syntax, + backtick, + long-suffix, + old-ne-operator, + old-octal-literal, + import-star-module-level, + non-ascii-bytes-literal, + raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + apply-builtin, + basestring-builtin, + buffer-builtin, + cmp-builtin, + coerce-builtin, + execfile-builtin, + file-builtin, + long-builtin, + raw_input-builtin, + reduce-builtin, + standarderror-builtin, + unicode-builtin, + xrange-builtin, + coerce-method, + delslice-method, + getslice-method, + setslice-method, + no-absolute-import, + old-division, + dict-iter-method, + dict-view-method, + next-method-called, + metaclass-assignment, + indexing-exception, + raising-string, + reload-builtin, + oct-method, + hex-method, + nonzero-method, + cmp-method, + input-builtin, + round-builtin, + intern-builtin, + unichr-builtin, + map-builtin-not-iterating, + zip-builtin-not-iterating, + range-builtin-not-iterating, + filter-builtin-not-iterating, + using-cmp-argument, + eq-without-hash, + div-method, + idiv-method, + rdiv-method, + exception-message-attribute, + invalid-str-codec, + sys-max-int, + bad-python3-import, + deprecated-string-function, + deprecated-str-translate-call, + deprecated-itertools-function, + deprecated-types-field, + next-method-defined, + dict-items-not-iterating, + dict-keys-not-iterating, + dict-values-not-iterating, + deprecated-operator-function, + deprecated-urllib-function, + xreadlines-attribute, + deprecated-sys-function, + exception-escape, + comprehension-escape + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'error', 'warning', 'refactor', and 'convention' +# which contain the number of messages in each category, as well as 'statement' +# which is the total number of statements analyzed. This score is used by the +# global evaluation report (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it work, +# install the python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +#notes-rgx= + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. +#class-attribute-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. +#variable-rgx= + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=fstr + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[DESIGN] + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules=optparse,tkinter.tix + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled). +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled). +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "BaseException, Exception". +overgeneral-exceptions=BaseException, + Exception diff --git a/Makefile b/Makefile index eb6e9b49a..845024f59 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ default_target: amd64_frigate COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1) version: - echo "VERSION='0.8.4-$(COMMIT_HASH)'" > frigate/version.py + echo "VERSION='0.9.0-$(COMMIT_HASH)'" > frigate/version.py web: docker build --tag frigate-web --file docker/Dockerfile.web web/ @@ -14,8 +14,11 @@ amd64_wheels: amd64_ffmpeg: docker build --tag blakeblackshear/frigate-ffmpeg:1.1.0-amd64 --file docker/Dockerfile.ffmpeg.amd64 . +nginx_frigate: + docker buildx build --push --platform linux/arm/v7,linux/arm64/v8,linux/amd64 --tag blakeblackshear/frigate-nginx:1.0.0 --file docker/Dockerfile.nginx . + amd64_frigate: version web - docker build --tag frigate-base --build-arg ARCH=amd64 --build-arg FFMPEG_VERSION=1.1.0 --build-arg WHEELS_VERSION=1.0.3 --file docker/Dockerfile.base . + docker build --tag frigate-base --build-arg ARCH=amd64 --build-arg FFMPEG_VERSION=1.1.0 --build-arg WHEELS_VERSION=1.0.3 --build-arg NGINX_VERSION=1.0.0 --file docker/Dockerfile.base . docker build --tag frigate --file docker/Dockerfile.amd64 . amd64_all: amd64_wheels amd64_ffmpeg amd64_frigate @@ -27,7 +30,7 @@ amd64nvidia_ffmpeg: docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-amd64nvidia --file docker/Dockerfile.ffmpeg.amd64nvidia . amd64nvidia_frigate: version web - docker build --tag frigate-base --build-arg ARCH=amd64nvidia --build-arg FFMPEG_VERSION=1.0.0 --build-arg WHEELS_VERSION=1.0.3 --file docker/Dockerfile.base . + docker build --tag frigate-base --build-arg ARCH=amd64nvidia --build-arg FFMPEG_VERSION=1.0.0 --build-arg WHEELS_VERSION=1.0.3 --build-arg NGINX_VERSION=1.0.0 --file docker/Dockerfile.base . docker build --tag frigate --file docker/Dockerfile.amd64nvidia . amd64nvidia_all: amd64nvidia_wheels amd64nvidia_ffmpeg amd64nvidia_frigate @@ -39,7 +42,7 @@ aarch64_ffmpeg: docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-aarch64 --file docker/Dockerfile.ffmpeg.aarch64 . aarch64_frigate: version web - docker build --tag frigate-base --build-arg ARCH=aarch64 --build-arg FFMPEG_VERSION=1.0.0 --build-arg WHEELS_VERSION=1.0.3 --file docker/Dockerfile.base . + docker build --tag frigate-base --build-arg ARCH=aarch64 --build-arg FFMPEG_VERSION=1.0.0 --build-arg WHEELS_VERSION=1.0.3 --build-arg NGINX_VERSION=1.0.0 --file docker/Dockerfile.base . docker build --tag frigate --file docker/Dockerfile.aarch64 . armv7_all: armv7_wheels armv7_ffmpeg armv7_frigate @@ -51,7 +54,7 @@ armv7_ffmpeg: docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-armv7 --file docker/Dockerfile.ffmpeg.armv7 . armv7_frigate: version web - docker build --tag frigate-base --build-arg ARCH=armv7 --build-arg FFMPEG_VERSION=1.0.0 --build-arg WHEELS_VERSION=1.0.3 --file docker/Dockerfile.base . + docker build --tag frigate-base --build-arg ARCH=armv7 --build-arg FFMPEG_VERSION=1.0.0 --build-arg WHEELS_VERSION=1.0.3 --build-arg NGINX_VERSION=1.0.0 --file docker/Dockerfile.base . docker build --tag frigate --file docker/Dockerfile.armv7 . armv7_all: armv7_wheels armv7_ffmpeg armv7_frigate diff --git a/README.md b/README.md index 78040656a..012a856af 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ # Frigate - NVR With Realtime Object Detection for IP Cameras -A complete and local NVR designed for HomeAssistant with AI object detection. Uses OpenCV and Tensorflow to perform realtime object detection locally for IP cameras. +A complete and local NVR designed for [Home Assistant](https://www.home-assistant.io) with AI object detection. Uses OpenCV and Tensorflow to perform realtime object detection locally for IP cameras. Use of a [Google Coral Accelerator](https://coral.ai/products/) is optional, but highly recommended. The Coral will outperform even the best CPUs and can process 100+ FPS with very little overhead. -- Tight integration with HomeAssistant via a [custom component](https://github.com/blakeblackshear/frigate-hass-integration) +- Tight integration with Home Assistant via a [custom component](https://github.com/blakeblackshear/frigate-hass-integration) - Designed to minimize resource use and maximize performance by only looking for objects when and where it is necessary - Leverages multiprocessing heavily with an emphasis on realtime over processing every frame - Uses a very low overhead motion detection to determine where to run object detection @@ -26,7 +26,7 @@ View the documentation at https://blakeblackshear.github.io/frigate If you would like to make a donation to support development, please use [Github Sponsors](https://github.com/sponsors/blakeblackshear). ## Screenshots -Integration into HomeAssistant +Integration into Home Assistant
diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..7f1624f1a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +version: "3" +services: + dev: + container_name: frigate-dev + user: vscode + build: + context: . + dockerfile: docker/Dockerfile.dev + devices: + - /dev/bus/usb:/dev/bus/usb + - /dev/dri:/dev/dri # for intel hwaccel, needs to be updated for your hardware + volumes: + - /etc/localtime:/etc/localtime:ro + - .:/opt/frigate:cached + - ./config/config.yml:/config/config.yml:ro + - ./debug:/media/frigate + - type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear + target: /tmp/cache + tmpfs: + size: 1000000000 + ports: + - "1935:1935" + - "5000:5000" + - "5001:5001" + - "8080:8080" + command: /bin/sh -c "sudo /usr/local/nginx/sbin/nginx; while sleep 1000; do :; done" + mqtt: + container_name: mqtt + image: eclipse-mosquitto:1.6 + ports: + - "1883:1883" diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base index 794e81dac..0c8b5ebf4 100644 --- a/docker/Dockerfile.base +++ b/docker/Dockerfile.base @@ -1,8 +1,10 @@ ARG ARCH=amd64 ARG WHEELS_VERSION ARG FFMPEG_VERSION +ARG NGINX_VERSION FROM blakeblackshear/frigate-wheels:${WHEELS_VERSION}-${ARCH} as wheels FROM blakeblackshear/frigate-ffmpeg:${FFMPEG_VERSION}-${ARCH} as ffmpeg +FROM blakeblackshear/frigate-nginx:${NGINX_VERSION} as nginx FROM frigate-web as web FROM ubuntu:20.04 @@ -18,16 +20,13 @@ ENV DEBIAN_FRONTEND=noninteractive # Install packages for apt repo RUN apt-get -qq update \ && apt-get upgrade -y \ - && apt-get -qq install --no-install-recommends -y \ - gnupg wget unzip tzdata nginx libnginx-mod-rtmp \ - && apt-get -qq install --no-install-recommends -y \ - python3-pip \ + && apt-get -qq install --no-install-recommends -y gnupg wget unzip tzdata libxml2 \ + && apt-get -qq install --no-install-recommends -y python3-pip \ && pip3 install -U /wheels/*.whl \ && APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn apt-key adv --fetch-keys https://packages.cloud.google.com/apt/doc/apt-key.gpg \ && echo "deb https://packages.cloud.google.com/apt coral-edgetpu-stable main" > /etc/apt/sources.list.d/coral-edgetpu.list \ && echo "libedgetpu1-max libedgetpu/accepted-eula select true" | debconf-set-selections \ - && apt-get -qq update && apt-get -qq install --no-install-recommends -y \ - libedgetpu1-max=15.0 \ + && apt-get -qq update && apt-get -qq install --no-install-recommends -y libedgetpu1-max=15.0 \ && rm -rf /var/lib/apt/lists/* /wheels \ && (apt-get autoremove -y; apt-get autoclean -y) @@ -39,7 +38,8 @@ RUN pip3 install \ gevent \ gevent-websocket -COPY nginx/nginx.conf /etc/nginx/nginx.conf +COPY --from=nginx /usr/local/nginx/ /usr/local/nginx/ +COPY nginx/nginx.conf /usr/local/nginx/conf/nginx.conf # get model and labels COPY labelmap.txt /labelmap.txt diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev new file mode 100644 index 000000000..54b7c9bc3 --- /dev/null +++ b/docker/Dockerfile.dev @@ -0,0 +1,23 @@ +FROM frigate:latest + +ARG USERNAME=vscode +ARG USER_UID=1000 +ARG USER_GID=$USER_UID + +# Create the user +RUN groupadd --gid $USER_GID $USERNAME \ + && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \ + # + # [Optional] Add sudo support. Omit if you don't need to install software after connecting. + && apt-get update \ + && apt-get install -y sudo \ + && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \ + && chmod 0440 /etc/sudoers.d/$USERNAME + +RUN apt-get install -y git curl vim + +RUN pip3 install pylint black + +# Install Node 14 +RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - \ + && apt-get install -y nodejs diff --git a/docker/Dockerfile.nginx b/docker/Dockerfile.nginx new file mode 100644 index 000000000..bfa7d277a --- /dev/null +++ b/docker/Dockerfile.nginx @@ -0,0 +1,46 @@ +FROM ubuntu:20.04 AS base + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get -yqq update && \ + apt-get install -yq --no-install-recommends ca-certificates expat libgomp1 && \ + apt-get autoremove -y && \ + apt-get clean -y + +FROM base as build + +ARG NGINX_VERSION=1.18.0 +ARG VOD_MODULE_VERSION=1.28 +ARG RTMP_MODULE_VERSION=1.2.1 + +RUN cp /etc/apt/sources.list /etc/apt/sources.list~ \ + && sed -Ei 's/^# deb-src /deb-src /' /etc/apt/sources.list \ + && apt-get update + +RUN apt-get -yqq build-dep nginx + +RUN apt-get -yqq install --no-install-recommends curl \ + && mkdir /tmp/nginx \ + && curl -sL https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz | tar -C /tmp/nginx -zx --strip-components=1 \ + && mkdir /tmp/nginx-vod-module \ + && curl -sL https://github.com/kaltura/nginx-vod-module/archive/refs/tags/${VOD_MODULE_VERSION}.tar.gz | tar -C /tmp/nginx-vod-module -zx --strip-components=1 \ + && mkdir /tmp/nginx-rtmp-module \ + && curl -sL https://github.com/arut/nginx-rtmp-module/archive/refs/tags/v${RTMP_MODULE_VERSION}.tar.gz | tar -C /tmp/nginx-rtmp-module -zx --strip-components=1 + +WORKDIR /tmp/nginx + +RUN ./configure --prefix=/usr/local/nginx \ + --with-file-aio \ + --with-http_sub_module \ + --with-http_ssl_module \ + --with-threads \ + --add-module=../nginx-vod-module \ + --add-module=../nginx-rtmp-module \ + --with-cc-opt="-O3 -Wno-error=implicit-fallthrough" + +RUN make && make install +RUN rm -rf /usr/local/nginx/html /usr/local/nginx/conf/*.default + +FROM base +COPY --from=build /usr/local/nginx /usr/local/nginx +ENTRYPOINT ["/usr/local/nginx/sbin/nginx"] +CMD ["-g", "daemon off;"] \ No newline at end of file diff --git a/docker/Dockerfile.wheels b/docker/Dockerfile.wheels index 278d989e0..d81795673 100644 --- a/docker/Dockerfile.wheels +++ b/docker/Dockerfile.wheels @@ -35,7 +35,7 @@ RUN pip3 wheel --wheel-dir=/wheels \ click \ setproctitle \ peewee \ - gevent + gevent FROM scratch diff --git a/docs/docs/configuration/advanced.md b/docs/docs/configuration/advanced.md index b03b438a4..7efcfb680 100644 --- a/docs/docs/configuration/advanced.md +++ b/docs/docs/configuration/advanced.md @@ -81,7 +81,7 @@ environment_vars: ### `database` -Event and clip information is managed in a sqlite database at `/media/frigate/clips/frigate.db`. If that database is deleted, clips will be orphaned and will need to be cleaned up manually. They also won't show up in the Media Browser within HomeAssistant. +Event and clip information is managed in a sqlite database at `/media/frigate/clips/frigate.db`. If that database is deleted, clips will be orphaned and will need to be cleaned up manually. They also won't show up in the Media Browser within Home Assistant. If you are storing your clips on a network share (SMB, NFS, etc), you may get a `database is locked` error message on startup. You can customize the location of the database in the config if necessary. @@ -99,7 +99,8 @@ detectors: # Required: name of the detector coral: # Required: type of the detector - # Valid values are 'edgetpu' (requires device property below) and 'cpu'. type: edgetpu + # Valid values are 'edgetpu' (requires device property below) and 'cpu'. + type: edgetpu # Optional: device name as defined here: https://coral.ai/docs/edgetpu/multiple-edgetpu/#using-the-tensorflow-lite-python-api device: usb # Optional: num_threads value passed to the tflite.Interpreter (default: shown below) diff --git a/docs/docs/configuration/cameras.md b/docs/docs/configuration/cameras.md index 00b4d0c2e..faf871f07 100644 --- a/docs/docs/configuration/cameras.md +++ b/docs/docs/configuration/cameras.md @@ -62,7 +62,7 @@ Example of a finished row corresponding to the below example image: ```yaml motion: - mask: '0,461,3,0,1919,0,1919,843,1699,492,1344,458,1346,336,973,317,869,375,866,432' + mask: "0,461,3,0,1919,0,1919,843,1699,492,1344,458,1346,336,973,317,869,375,866,432" ``` ![poly](/img/example-mask-poly.png) @@ -131,7 +131,7 @@ objects: Frigate can save video clips without any CPU overhead for encoding by simply copying the stream directly with FFmpeg. It leverages FFmpeg's segment functionality to maintain a cache of video for each camera. The cache files are written to disk at `/tmp/cache` and do not introduce memory overhead. When an object is being tracked, it will extend the cache to ensure it can assemble a clip when the event ends. Once the event ends, it again uses FFmpeg to assemble a clip by combining the video clips without any encoding by the CPU. Assembled clips are are saved to `/media/frigate/clips`. Clips are retained according to the retention settings defined on the config for each object type. -These clips will not be playable in the web UI or in HomeAssistant's media browser unless your camera sends video as h264. +These clips will not be playable in the web UI or in Home Assistant's media browser unless your camera sends video as h264. :::caution Previous versions of frigate included `-vsync drop` in input parameters. This is not compatible with FFmpeg's segment feature and must be removed from your input parameters if you have overrides set. @@ -191,7 +191,7 @@ snapshots: ## 24/7 Recordings -24/7 recordings can be enabled and are stored at `/media/frigate/recordings`. The folder structure for the recordings is `YYYY-MM/DD/HH//MM.SS.mp4`. These recordings are written directly from your camera stream without re-encoding and are available in HomeAssistant's media browser. Each camera supports a configurable retention policy in the config. +24/7 recordings can be enabled and are stored at `/media/frigate/recordings`. The folder structure for the recordings is `YYYY-MM/DD/HH//MM.SS.mp4`. These recordings are written directly from your camera stream without re-encoding and are available in Home Assistant's media browser. Each camera supports a configurable retention policy in the config. :::caution Previous versions of frigate included `-vsync drop` in input parameters. This is not compatible with FFmpeg's segment feature and must be removed from your input parameters if you have overrides set. @@ -208,7 +208,7 @@ record: ## RTMP streams -Frigate can re-stream your video feed as a RTMP feed for other applications such as HomeAssistant to utilize it at `rtmp:///live/`. Port 1935 must be open. This allows you to use a video feed for detection in frigate and HomeAssistant live view at the same time without having to make two separate connections to the camera. The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate. +Frigate can re-stream your video feed as a RTMP feed for other applications such as Home Assistant to utilize it at `rtmp:///live/`. Port 1935 must be open. This allows you to use a video feed for detection in frigate and Home Assistant live view at the same time without having to make two separate connections to the camera. The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate. Some video feeds are not compatible with RTMP. If you are experiencing issues, check to make sure your camera feed is h264 with AAC audio. If your camera doesn't support a compatible format for RTMP, you can use the ffmpeg args to re-encode it on the fly at the expense of increased CPU utilization. @@ -388,6 +388,37 @@ cameras: ## Camera specific configuration +### MJPEG Cameras + +The input and output parameters need to be adjusted for MJPEG cameras + +```yaml +input_args: + - -avoid_negative_ts + - make_zero + - -fflags + - nobuffer + - -flags + - low_delay + - -strict + - experimental + - -fflags + - +genpts+discardcorrupt + - -r + - "3" # <---- adjust depending on your desired frame rate from the mjpeg image + - -use_wallclock_as_timestamps + - "1" +``` + +Note that mjpeg cameras require encoding the video into h264 for clips, recording, and rtmp roles. This will use significantly more CPU than if the cameras supported h264 feeds directly. + +```yaml +output_args: + record: -f segment -segment_time 60 -segment_format mp4 -reset_timestamps 1 -strftime 1 -c:v libx264 -an + clips: -f segment -segment_time 10 -segment_format mp4 -reset_timestamps 1 -strftime 1 -c:v libx264 -an + rtmp: -c:v libx264 -an -f flv +``` + ### RTMP Cameras The input parameters need to be adjusted for RTMP cameras @@ -406,7 +437,7 @@ ffmpeg: - -fflags - +genpts+discardcorrupt - -use_wallclock_as_timestamps - - '1' + - "1" ``` ### Reolink 410/520 (possibly others) @@ -427,9 +458,9 @@ ffmpeg: - -fflags - +genpts+discardcorrupt - -rw_timeout - - '5000000' + - "5000000" - -use_wallclock_as_timestamps - - '1' + - "1" ``` ### Blue Iris RTSP Cameras @@ -450,7 +481,7 @@ ffmpeg: - -rtsp_transport - tcp - -stimeout - - '5000000' + - "5000000" - -use_wallclock_as_timestamps - - '1' + - "1" ``` diff --git a/docs/docs/configuration/detectors.md b/docs/docs/configuration/detectors.md index 3e59e6792..a7bbdab79 100644 --- a/docs/docs/configuration/detectors.md +++ b/docs/docs/configuration/detectors.md @@ -30,6 +30,18 @@ detectors: device: usb:1 ``` +Multiple PCIE/M.2 Corals: + +```yaml +detectors: + coral1: + type: edgetpu + device: pci:0 + coral2: + type: edgetpu + device: pci:1 +``` + Mixing Corals: ```yaml diff --git a/docs/docs/configuration/index.md b/docs/docs/configuration/index.md index 2ac79597e..6e4e93b61 100644 --- a/docs/docs/configuration/index.md +++ b/docs/docs/configuration/index.md @@ -3,7 +3,9 @@ id: index title: Configuration --- -HassOS users can manage their configuration directly in the addon Configuration tab. For other installations, the default location for the config file is `/config/config.yml`. This can be overridden with the `CONFIG_FILE` environment variable. Camera specific ffmpeg parameters are documented [here](cameras.md). +For HassOS installations, the default location for the config file is `/config/frigate.yml`. + +For all other installations, the default location for the config file is '/config/config.yml'. This can be overridden with the `CONFIG_FILE` environment variable. Camera specific ffmpeg parameters are documented [here](cameras.md). It is recommended to start with a minimal configuration and add to it: @@ -45,6 +47,17 @@ mqtt: # NOTE: Environment variables that begin with 'FRIGATE_' may be referenced in {}. # eg. password: '{FRIGATE_MQTT_PASSWORD}' password: password + # Optional: tls_ca_certs for enabling TLS using self-signed certs (default: None) + tls_ca_certs: /path/to/ca.crt + # Optional: tls_client_cert and tls_client key in order to use self-signed client + # certificates (default: None) + # NOTE: certificate must not be password-protected + # do not set user and password when using a client certificate + tls_client_cert: /path/to/client.crt + tls_client_key: /path/to/client.key + # Optional: tls_insecure (true/false) for enabling TLS verification of + # the server hostname in the server certificate (default: None) + tls_insecure: false # Optional: interval in seconds for publishing stats (default: shown below) stats_interval: 60 ``` @@ -78,11 +91,6 @@ clips: # NOTE: If an object is being tracked for longer than this amount of time, the cache # will begin to expire and the resulting clip will be the last x seconds of the event. max_seconds: 300 - # Optional: size of tmpfs mount to create for cache files (default: not set) - # mount -t tmpfs -o size={tmpfs_cache_size} tmpfs /tmp/cache - # NOTICE: Addon users must have Protection mode disabled for the addon when using this setting. - # Also, if you have mounted a tmpfs volume through docker, this value should not be set in your config. - tmpfs_cache_size: 256m # Optional: Retention settings for clips (default: shown below) retain: # Required: Default retention days (default: shown below) @@ -136,3 +144,19 @@ objects: # Optional: minimum decimal percentage for tracked object's computed score to be considered a true positive (default: shown below) threshold: 0.7 ``` + +### `record` + +Can be overridden at the camera level. 24/7 recordings can be enabled and are stored at `/media/frigate/recordings`. The folder structure for the recordings is `YYYY-MM/DD/HH//MM.SS.mp4`. These recordings are written directly from your camera stream without re-encoding and are available in Home Assistant's media browser. Each camera supports a configurable retention policy in the config. + +:::caution +Previous versions of frigate included `-vsync drop` in input parameters. This is not compatible with FFmpeg's segment feature and must be removed from your input parameters if you have overrides set. +::: + +```yaml +record: + # Optional: Enable recording + enabled: False + # Optional: Number of days to retain + retain_days: 30 +``` diff --git a/docs/docs/configuration/nvdec.md b/docs/docs/configuration/nvdec.md index 0037495f6..b2ec01a47 100644 --- a/docs/docs/configuration/nvdec.md +++ b/docs/docs/configuration/nvdec.md @@ -55,7 +55,7 @@ A list of supported codecs (you can use `ffmpeg -decoders | grep cuvid` in the c ``` For example, for H265 video (hevc), you'll select `hevc_cuvid`. Add -`-c:v hevc_covid` to your ffmpeg input arguments: +`-c:v hevc_cuvid` to your ffmpeg input arguments: ``` ffmpeg: diff --git a/docs/docs/configuration/optimizing.md b/docs/docs/configuration/optimizing.md index 1ac1aabf9..8700b2e52 100644 --- a/docs/docs/configuration/optimizing.md +++ b/docs/docs/configuration/optimizing.md @@ -3,7 +3,7 @@ id: optimizing title: Optimizing performance --- -- **Google Coral**: It is strongly recommended to use a Google Coral, but Frigate will fall back to CPU in the event one is not found. Offloading TensorFlow to the Google Coral is an order of magnitude faster and will reduce your CPU load dramatically. A $60 device will outperform $2000 CPU. Frigate should work with any supported Coral device from https://coral.ai +- **Google Coral**: It is strongly recommended to use a Google Coral, Frigate will no longer fall back to CPU in the event one is not found. Offloading TensorFlow to the Google Coral is an order of magnitude faster and will reduce your CPU load dramatically. A $60 device will outperform $2000 CPU. Frigate should work with any supported Coral device from https://coral.ai - **Resolution**: For the `detect` input, choose a camera resolution where the smallest object you want to detect barely fits inside a 300x300px square. The model used by Frigate is trained on 300x300px images, so you will get worse performance and no improvement in accuracy by using a larger resolution since Frigate resizes the area where it is looking for objects to 300x300 anyway. - **FPS**: 5 frames per second should be adequate. Higher frame rates will require more CPU usage without improving detections or accuracy. Reducing the frame rate on your camera will have the greatest improvement on system resources. - **Hardware Acceleration**: Make sure you configure the `hwaccel_args` for your hardware. They provide a significant reduction in CPU usage if they are available. diff --git a/docs/docs/contributing.md b/docs/docs/contributing.md index 6d03d8708..651c15231 100644 --- a/docs/docs/contributing.md +++ b/docs/docs/contributing.md @@ -36,6 +36,59 @@ Fork [blakeblackshear/frigate-hass-integration](https://github.com/blakeblackshe - [Frigate source code](#frigate-core-web-and-docs) - GNU make - Docker +- Extra Coral device (optional, but very helpful to simulate real world performance) + +### Setup + +#### 1. Build the docker container locally with the appropriate make command + +For x86 machines, use `make amd64_frigate` + +#### 2. Create a local config file for testing + +Place the file at `config/config.yml` in the root of the repo. + +Here is an example, but modify for your needs: + +```yaml +mqtt: + host: mqtt + +cameras: + test: + ffmpeg: + inputs: + - path: /media/frigate/car-stopping.mp4 + input_args: -re -stream_loop -1 -fflags +genpts + roles: + - detect + - rtmp + - clips + height: 1080 + width: 1920 + fps: 5 +``` + +These input args tell ffmpeg to read the mp4 file in an infinite loop. You can use any valid ffmpeg input here. + +#### 3. Gather some mp4 files for testing + +Create and place these files in a `debug` folder in the root of the repo. This is also where clips and recordings will be created if you enable them in your test config. Update your config from step 2 above to point at the right file. You can check the `docker-compose.yml` file in the repo to see how the volumes are mapped. + +#### 4. Open the repo with Visual Studio Code + +Upon opening, you should be prompted to open the project in a remote container. This will build a container on top of the base frigate container with all the development dependencies installed. This ensures everyone uses a consistent development environment without the need to install any dependencies on your host machine. + +#### 5. Run frigate from the command line + +VSCode will start the docker compose file for you and open a terminal window connected to `frigate-dev`. + +- Run `python3 -m frigate` to start the backend. +- In a separate terminal window inside VS Code, change into the `web` directory and run `npm install && npm start` to start the frontend. + +#### 6. Teardown + +After closing VSCode, you may still have containers running. To close everything down, just run `docker-compose down -v` to cleanup all containers. ## Web Interface diff --git a/docs/docs/hardware.md b/docs/docs/hardware.md index f94a6738d..5cf20df87 100644 --- a/docs/docs/hardware.md +++ b/docs/docs/hardware.md @@ -5,7 +5,7 @@ title: Recommended hardware ## Cameras -Cameras that output H.264 video and AAC audio will offer the most compatibility with all features of Frigate and HomeAssistant. It is also helpful if your camera supports multiple substreams to allow different resolutions to be used for detection, streaming, clips, and recordings without re-encoding. +Cameras that output H.264 video and AAC audio will offer the most compatibility with all features of Frigate and Home Assistant. It is also helpful if your camera supports multiple substreams to allow different resolutions to be used for detection, streaming, clips, and recordings without re-encoding. ## Computer @@ -24,6 +24,6 @@ Cameras that output H.264 video and AAC audio will offer the most compatibility Many people have powerful enough NAS devices or home servers to also run docker. There is a Unraid Community App. To install make sure you have the [community app plugin here](https://forums.unraid.net/topic/38582-plug-in-community-applications/). Then search for "Frigate" in the apps section within Unraid - you can see the online store [here](https://unraid.net/community/apps?q=frigate#r) -| Name | Inference Speed | Notes | -| ----------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| [M2 Coral Edge TPU](http://coral.ai) | 6.2ms | Install the Coral plugin from Unraid Community App Center [info here](https://forums.unraid.net/topic/98064-support-blakeblackshear-frigate/?do=findComment&comment=949789) | +| Name | Inference Speed | Notes | +| ------------------------------------ | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [M2 Coral Edge TPU](http://coral.ai) | 6.2ms | Install the Coral plugin from Unraid Community App Center [info here](https://forums.unraid.net/topic/98064-support-blakeblackshear-frigate/?do=findComment&comment=949789) | diff --git a/docs/docs/index.md b/docs/docs/index.md index e04290c15..0bee0be22 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -5,11 +5,11 @@ sidebar_label: Features slug: / --- -A complete and local NVR designed for HomeAssistant with AI object detection. Uses OpenCV and Tensorflow to perform realtime object detection locally for IP cameras. +A complete and local NVR designed for Home Assistant with AI object detection. Uses OpenCV and Tensorflow to perform realtime object detection locally for IP cameras. Use of a [Google Coral Accelerator](https://coral.ai/products/) is optional, but highly recommended. The Coral will outperform even the best CPUs and can process 100+ FPS with very little overhead. -- Tight integration with HomeAssistant via a [custom component](https://github.com/blakeblackshear/frigate-hass-integration) +- Tight integration with Home Assistant via a [custom component](https://github.com/blakeblackshear/frigate-hass-integration) - Designed to minimize resource use and maximize performance by only looking for objects when and where it is necessary - Leverages multiprocessing heavily with an emphasis on realtime over processing every frame - Uses a very low overhead motion detection to determine where to run object detection diff --git a/docs/docs/installation.md b/docs/docs/installation.md index 65f51021f..c3f916ad1 100644 --- a/docs/docs/installation.md +++ b/docs/docs/installation.md @@ -5,7 +5,7 @@ title: Installation Frigate is a Docker container that can be run on any Docker host including as a [HassOS Addon](https://www.home-assistant.io/addons/). See instructions below for installing the HassOS addon. -For HomeAssistant users, there is also a [custom component (aka integration)](https://github.com/blakeblackshear/frigate-hass-integration). This custom component adds tighter integration with HomeAssistant by automatically setting up camera entities, sensors, media browser for clips and recordings, and a public API to simplify notifications. +For Home Assistant users, there is also a [custom component (aka integration)](https://github.com/blakeblackshear/frigate-hass-integration). This custom component adds tighter integration with Home Assistant by automatically setting up camera entities, sensors, media browser for clips and recordings, and a public API to simplify notifications. Note that HassOS Addons and custom components are different things. If you are already running Frigate with Docker directly, you do not need the Addon since the Addon would run another instance of Frigate. @@ -14,26 +14,27 @@ Note that HassOS Addons and custom components are different things. If you are a HassOS users can install via the addon repository. Frigate requires an MQTT server. 1. Navigate to Supervisor > Add-on Store > Repositories -1. Add https://github.com/blakeblackshear/frigate-hass-addons -1. Setup your configuration in the `Configuration` tab -1. Start the addon container -1. If you are using hardware acceleration for ffmpeg, you will need to disable "Protection mode" +2. Add https://github.com/blakeblackshear/frigate-hass-addons +3. Setup your network configuration in the `Configuration` tab if deisred +4. Create the file `frigate.yml` in your `config` directory with your detailed Frigate configuration +5. Start the addon container +6. If you are using hardware acceleration for ffmpeg, you will need to disable "Protection mode" ## Docker Make sure you choose the right image for your architecture: -|Arch|Image Name| -|-|-| -|amd64|blakeblackshear/frigate:stable-amd64| -|amd64nvidia|blakeblackshear/frigate:stable-amd64nvidia| -|armv7|blakeblackshear/frigate:stable-armv7| -|aarch64|blakeblackshear/frigate:stable-aarch64| +| Arch | Image Name | +| ----------- | ------------------------------------------ | +| amd64 | blakeblackshear/frigate:stable-amd64 | +| amd64nvidia | blakeblackshear/frigate:stable-amd64nvidia | +| armv7 | blakeblackshear/frigate:stable-armv7 | +| aarch64 | blakeblackshear/frigate:stable-aarch64 | It is recommended to run with docker-compose: ```yaml -version: '3.9' +version: "3.9" services: frigate: container_name: frigate @@ -52,10 +53,10 @@ services: tmpfs: size: 1000000000 ports: - - '5000:5000' - - '1935:1935' # RTMP feeds + - "5000:5000" + - "1935:1935" # RTMP feeds environment: - FRIGATE_RTSP_PASSWORD: 'password' + FRIGATE_RTSP_PASSWORD: "password" ``` If you can't use docker compose, you can run the container with something similar to this: @@ -66,7 +67,7 @@ docker run -d \ --restart=unless-stopped \ --mount type=tmpfs,target=/tmp/cache,tmpfs-size=1000000000 \ --device /dev/bus/usb:/dev/bus/usb \ - --device /dev/dri/renderD128 + --device /dev/dri/renderD128 \ -v :/media/frigate \ -v :/config/config.yml:ro \ -v /etc/localtime:/etc/localtime:ro \ @@ -86,7 +87,7 @@ You can calculate the necessary shm-size for each camera with the following form (width * height * 1.5 * 7 + 270480)/1048576 = ``` -The shm size cannot be set per container for HomeAssistant Addons. You must set `default-shm-size` in `/etc/docker/daemon.json` to increase the default shm size. This will increase the shm size for all of your docker containers. This may or may not cause issues with your setup. https://docs.docker.com/engine/reference/commandline/dockerd/#daemon-configuration-file +The shm size cannot be set per container for Home Assistant Addons. You must set `default-shm-size` in `/etc/docker/daemon.json` to increase the default shm size. This will increase the shm size for all of your docker containers. This may or may not cause issues with your setup. https://docs.docker.com/engine/reference/commandline/dockerd/#daemon-configuration-file ## Kubernetes @@ -119,5 +120,5 @@ lxc.cap.drop: ``` ### ESX -For details on running Frigate under ESX, see details [here](https://github.com/blakeblackshear/frigate/issues/305). +For details on running Frigate under ESX, see details [here](https://github.com/blakeblackshear/frigate/issues/305). diff --git a/docs/docs/usage/api.md b/docs/docs/usage/api.md index 25a52abd9..1ad60b6af 100644 --- a/docs/docs/usage/api.md +++ b/docs/docs/usage/api.md @@ -5,7 +5,7 @@ title: HTTP API A web server is available on port 5000 with the following endpoints. -### `/api/` +### `GET /api/` An mjpeg stream for debugging. Keep in mind the mjpeg endpoint is for debugging only and will put additional load on the system when in use. @@ -24,7 +24,7 @@ Accepts the following query string parameters: You can access a higher resolution mjpeg stream by appending `h=height-in-pixels` to the endpoint. For example `http://localhost:5000/api/back?h=1080`. You can also increase the FPS by appending `fps=frame-rate` to the URL such as `http://localhost:5000/api/back?fps=10` or both with `?fps=10&h=1000`. -### `/api///best.jpg[?h=300&crop=1]` +### `GET /api///best.jpg[?h=300&crop=1]` The best snapshot for any object type. It is a full resolution image by default. @@ -33,7 +33,7 @@ Example parameters: - `h=300`: resizes the image to 300 pixes tall - `crop=1`: crops the image to the region of the detection rather than returning the entire image -### `/api//latest.jpg[?h=300]` +### `GET /api//latest.jpg[?h=300]` The most recent frame that frigate has finished processing. It is a full resolution image by default. @@ -53,9 +53,9 @@ Example parameters: - `h=300`: resizes the image to 300 pixes tall -### `/api/stats` +### `GET /api/stats` -Contains some granular debug info that can be used for sensors in HomeAssistant. +Contains some granular debug info that can be used for sensors in Home Assistant. Sample response: @@ -125,40 +125,40 @@ Sample response: "total": 1000, "used": 700, "free": 300, - "mnt_type": "ext4", + "mnt_type": "ext4" }, "/media/frigate/recordings": { "total": 1000, "used": 700, "free": 300, - "mnt_type": "ext4", + "mnt_type": "ext4" }, "/tmp/cache": { "total": 256, "used": 100, "free": 156, - "mnt_type": "tmpfs", + "mnt_type": "tmpfs" }, "/dev/shm": { "total": 256, "used": 100, "free": 156, - "mnt_type": "tmpfs", - }, + "mnt_type": "tmpfs" + } } } } ``` -### `/api/config` +### `GET /api/config` A json representation of your configuration -### `/api/version` +### `GET /api/version` Version info -### `/api/events` +### `GET /api/events` Events from the database. Accepts the following query string parameters: @@ -174,19 +174,23 @@ Events from the database. Accepts the following query string parameters: | `has_clip` | int | Filter to events that have clips (0 or 1) | | `include_thumbnails` | int | Include thumbnails in the response (0 or 1) | -### `/api/events/summary` +### `GET /api/events/summary` -Returns summary data for events in the database. Used by the HomeAssistant integration. +Returns summary data for events in the database. Used by the Home Assistant integration. -### `/api/events/` +### `GET /api/events/` Returns data for a single event. -### `/api/events//thumbnail.jpg` +### `DELETE /api/events/` + +Permanently deletes the event along with any clips/snapshots. + +### `GET /api/events//thumbnail.jpg` Returns a thumbnail for the event id optimized for notifications. Works while the event is in progress and after completion. Passing `?format=android` will convert the thumbnail to 2:1 aspect ratio. -### `/api/events//snapshot.jpg` +### `GET /api/events//snapshot.jpg` Returns the snapshot image for the event id. Works while the event is in progress and after completion. @@ -206,3 +210,7 @@ Video clip for the given camera and event id. ### `/clips/-.jpg` JPG snapshot for the given camera and event id. + +### `/vod/-////master.m3u8` + +HTTP Live Streaming Video on Demand URL for the specified hour and camera. Can be viewed in an application like VLC. diff --git a/docs/docs/usage/home-assistant.md b/docs/docs/usage/home-assistant.md index 09f9fbb26..45764e01a 100644 --- a/docs/docs/usage/home-assistant.md +++ b/docs/docs/usage/home-assistant.md @@ -4,7 +4,7 @@ title: Integration with Home Assistant sidebar_label: Home Assistant --- -The best way to integrate with HomeAssistant is to use the [official integration](https://github.com/blakeblackshear/frigate-hass-integration). When configuring the integration, you will be asked for the `Host` of your frigate instance. This value should be the url you use to access Frigate in the browser and will look like `http://:5000/`. If you are using HassOS with the addon, the host should be `http://ccab4aaf-frigate:5000` (or `http://ccab4aaf-frigate-beta:5000` if your are using the beta version of the addon). HomeAssistant needs access to port 5000 (api) and 1935 (rtmp) for all features. The integration will setup the following entities within HomeAssistant: +The best way to integrate with Home Assistant is to use the [official integration](https://github.com/blakeblackshear/frigate-hass-integration). When configuring the integration, you will be asked for the `Host` of your frigate instance. This value should be the url you use to access Frigate in the browser and will look like `http://:5000/`. If you are using HassOS with the addon, the host should be `http://ccab4aaf-frigate:5000` (or `http://ccab4aaf-frigate-beta:5000` if your are using the beta version of the addon). Home Assistant needs access to port 5000 (api) and 1935 (rtmp) for all features. The integration will setup the following entities within Home Assistant: ## Sensors: @@ -30,17 +30,19 @@ The best way to integrate with HomeAssistant is to use the [official integration Frigate publishes event information in the form of a change feed via MQTT. This allows lots of customization for notifications to meet your needs. Event changes are published with `before` and `after` information as shown [here](#frigateevents). Note that some people may not want to expose frigate to the web, so you can leverage the HA API that frigate custom_integration ties into (which is exposed to the web, and thus can be used for mobile notifications etc): -To load an image taken by frigate from HomeAssistants API see below: -``` +To load an image taken by frigate from Home Assistants API see below: + +``` https://HA_URL/api/frigate/notifications//thumbnail.jpg ``` -To load a video clip taken by frigate from HomeAssistants API : -``` +To load a video clip taken by frigate from Home Assistants API : + +``` https://HA_URL/api/frigate/notifications///clip.mp4 ``` -Here is a simple example of a notification automation of events which will update the existing notification for each change. This means the image you see in the notification will update as frigate finds a "better" image. +Here is a simple example of a notification automation of events which will update the existing notification for each change. This means the image you see in the notification will update as frigate finds a "better" image. ```yaml automation: @@ -57,7 +59,6 @@ automation: tag: '{{trigger.payload_json["after"]["id"]}}' ``` - ```yaml automation: - alias: When a person enters a zone named yard @@ -106,7 +107,7 @@ automation: action: - service: notify.mobile_app_pixel_3 data_template: - message: 'High confidence dog detection.' + message: "High confidence dog detection." data: image: "https://url.com/api/frigate/notifications/{{trigger.payload_json['after']['id']}}/thumbnail.jpg" tag: "{{trigger.payload_json['after']['id']}}" diff --git a/docs/docs/usage/howtos.md b/docs/docs/usage/howtos.md new file mode 100644 index 000000000..9392caf0a --- /dev/null +++ b/docs/docs/usage/howtos.md @@ -0,0 +1,11 @@ +--- +id: howtos +title: Community Guides +sidebar_label: Community Guides +--- + +## Communitiy Guides/How-To's + +- Best Camera AI Person & Object Detection - How to Setup Frigate w/ Home Assistant - digiblurDIY [YouTube](https://youtu.be/V8vGdoYO6-Y) - [Article](https://www.digiblur.com/2021/05/how-to-setup-frigate-home-assistant.html) +- Even More Free Local Object Detection with Home Assistant - Frigate Install - Everything Smart Home [YouTube](https://youtu.be/pqDCEZSVeRk) +- Home Assistant Frigate integration for local image recognition - KPeyanski [YouTube](https://youtu.be/Q2UT78lFQpo) - [Article](https://peyanski.com/home-assistant-frigate-integration/) diff --git a/docs/docs/usage/mqtt.md b/docs/docs/usage/mqtt.md index 76963f7fe..83d92bbc2 100644 --- a/docs/docs/usage/mqtt.md +++ b/docs/docs/usage/mqtt.md @@ -7,17 +7,17 @@ These are the MQTT messages generated by Frigate. The default topic_prefix is `f ### `frigate/available` -Designed to be used as an availability topic with HomeAssistant. Possible message are: +Designed to be used as an availability topic with Home Assistant. Possible message are: "online": published when frigate is running (on startup) "offline": published right before frigate stops ### `frigate//` -Publishes the count of objects for the camera for use as a sensor in HomeAssistant. +Publishes the count of objects for the camera for use as a sensor in Home Assistant. ### `frigate//` -Publishes the count of objects for the zone for use as a sensor in HomeAssistant. +Publishes the count of objects for the zone for use as a sensor in Home Assistant. ### `frigate///snapshot` diff --git a/frigate/__main__.py b/frigate/__main__.py index 2524c621a..0d9a3dbe2 100644 --- a/frigate/__main__.py +++ b/frigate/__main__.py @@ -1,4 +1,6 @@ -import faulthandler; faulthandler.enable() +import faulthandler + +faulthandler.enable() import sys import threading @@ -6,10 +8,10 @@ threading.current_thread().name = "frigate" from frigate.app import FrigateApp -cli = sys.modules['flask.cli'] +cli = sys.modules["flask.cli"] cli.show_server_banner = lambda *x: None -if __name__ == '__main__': +if __name__ == "__main__": frigate_app = FrigateApp() frigate_app.start() diff --git a/frigate/app.py b/frigate/app.py index 976e98a79..9ffae53d4 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -31,7 +31,8 @@ from frigate.zeroconf import broadcast_zeroconf logger = logging.getLogger(__name__) -class FrigateApp(): + +class FrigateApp: def __init__(self): self.stop_event = mp.Event() self.config: FrigateConfig = None @@ -54,62 +55,73 @@ class FrigateApp(): else: logger.debug(f"Skipping directory: {d}") - tmpfs_size = self.config.clips.tmpfs_cache_size - if tmpfs_size: - logger.info(f"Creating tmpfs of size {tmpfs_size}") - rc = os.system(f"mount -t tmpfs -o size={tmpfs_size} tmpfs {CACHE_DIR}") - if rc != 0: - logger.error(f"Failed to create tmpfs, error code: {rc}") - def init_logger(self): - self.log_process = mp.Process(target=log_process, args=(self.log_queue,), name='log_process') + self.log_process = mp.Process( + target=log_process, args=(self.log_queue,), name="log_process" + ) self.log_process.daemon = True self.log_process.start() root_configurer(self.log_queue) - + def init_config(self): - config_file = os.environ.get('CONFIG_FILE', '/config/config.yml') + config_file = os.environ.get("CONFIG_FILE", "/config/config.yml") self.config = FrigateConfig(config_file=config_file) for camera_name in self.config.cameras.keys(): # create camera_metrics self.camera_metrics[camera_name] = { - 'camera_fps': mp.Value('d', 0.0), - 'skipped_fps': mp.Value('d', 0.0), - 'process_fps': mp.Value('d', 0.0), - 'detection_enabled': mp.Value('i', self.config.cameras[camera_name].detect.enabled), - 'detection_fps': mp.Value('d', 0.0), - 'detection_frame': mp.Value('d', 0.0), - 'read_start': mp.Value('d', 0.0), - 'ffmpeg_pid': mp.Value('i', 0), - 'frame_queue': mp.Queue(maxsize=2), + "camera_fps": mp.Value("d", 0.0), + "skipped_fps": mp.Value("d", 0.0), + "process_fps": mp.Value("d", 0.0), + "detection_enabled": mp.Value( + "i", self.config.cameras[camera_name].detect.enabled + ), + "detection_fps": mp.Value("d", 0.0), + "detection_frame": mp.Value("d", 0.0), + "read_start": mp.Value("d", 0.0), + "ffmpeg_pid": mp.Value("i", 0), + "frame_queue": mp.Queue(maxsize=2), } - + def check_config(self): for name, camera in self.config.cameras.items(): - assigned_roles = list(set([r for i in camera.ffmpeg.inputs for r in i.roles])) - if not camera.clips.enabled and 'clips' in assigned_roles: - logger.warning(f"Camera {name} has clips assigned to an input, but clips is not enabled.") - elif camera.clips.enabled and not 'clips' in assigned_roles: - logger.warning(f"Camera {name} has clips enabled, but clips is not assigned to an input.") + assigned_roles = list( + set([r for i in camera.ffmpeg.inputs for r in i.roles]) + ) + if not camera.clips.enabled and "clips" in assigned_roles: + logger.warning( + f"Camera {name} has clips assigned to an input, but clips is not enabled." + ) + elif camera.clips.enabled and not "clips" in assigned_roles: + logger.warning( + f"Camera {name} has clips enabled, but clips is not assigned to an input." + ) - if not camera.record.enabled and 'record' in assigned_roles: - logger.warning(f"Camera {name} has record assigned to an input, but record is not enabled.") - elif camera.record.enabled and not 'record' in assigned_roles: - logger.warning(f"Camera {name} has record enabled, but record is not assigned to an input.") + if not camera.record.enabled and "record" in assigned_roles: + logger.warning( + f"Camera {name} has record assigned to an input, but record is not enabled." + ) + elif camera.record.enabled and not "record" in assigned_roles: + logger.warning( + f"Camera {name} has record enabled, but record is not assigned to an input." + ) + + if not camera.rtmp.enabled and "rtmp" in assigned_roles: + logger.warning( + f"Camera {name} has rtmp assigned to an input, but rtmp is not enabled." + ) + elif camera.rtmp.enabled and not "rtmp" in assigned_roles: + logger.warning( + f"Camera {name} has rtmp enabled, but rtmp is not assigned to an input." + ) - if not camera.rtmp.enabled and 'rtmp' in assigned_roles: - logger.warning(f"Camera {name} has rtmp assigned to an input, but rtmp is not enabled.") - elif camera.rtmp.enabled and not 'rtmp' in assigned_roles: - logger.warning(f"Camera {name} has rtmp enabled, but rtmp is not assigned to an input.") - def set_log_levels(self): logging.getLogger().setLevel(self.config.logger.default) for log, level in self.config.logger.logs.items(): logging.getLogger(log).setLevel(level) - - if not 'geventwebsocket.handler' in self.config.logger.logs: - logging.getLogger('geventwebsocket.handler').setLevel('ERROR') + + if not "geventwebsocket.handler" in self.config.logger.logs: + logging.getLogger("geventwebsocket.handler").setLevel("ERROR") def init_queues(self): # Queues for clip processing @@ -117,13 +129,15 @@ class FrigateApp(): self.event_processed_queue = mp.Queue() # Queue for cameras to push tracked objects to - self.detected_frames_queue = mp.Queue(maxsize=len(self.config.cameras.keys())*2) + self.detected_frames_queue = mp.Queue( + maxsize=len(self.config.cameras.keys()) * 2 + ) def init_database(self): migrate_db = SqliteExtDatabase(self.config.database.path) # Run migrations - del(logging.getLogger('peewee_migrate').handlers[:]) + del logging.getLogger("peewee_migrate").handlers[:] router = Router(migrate_db) router.run() @@ -137,7 +151,13 @@ class FrigateApp(): self.stats_tracking = stats_init(self.camera_metrics, self.detectors) def init_web_server(self): - self.flask_app = create_app(self.config, self.db, self.stats_tracking, self.detected_frames_processor, self.mqtt_client) + self.flask_app = create_app( + self.config, + self.db, + self.stats_tracking, + self.detected_frames_processor, + self.mqtt_client, + ) def init_mqtt(self): self.mqtt_client = create_mqtt_client(self.config, self.camera_metrics) @@ -148,60 +168,115 @@ class FrigateApp(): self.detection_out_events[name] = mp.Event() try: - self.detection_shms.append(mp.shared_memory.SharedMemory(name=name, create=True, size=self.config.model.height*self.config.model.width*3)) + shm_in = mp.shared_memory.SharedMemory( + name=name, + create=True, + size=self.config.model.height*self.config.model.width * 3, + ) except FileExistsError: - self.detection_shms.append(mp.shared_memory.SharedMemory(name=name)) + shm_in = mp.shared_memory.SharedMemory(name=name) try: - self.detection_shms.append(mp.shared_memory.SharedMemory(name=f"out-{name}", create=True, size=20*6*4)) + shm_out = mp.shared_memory.SharedMemory( + name=f"out-{name}", create=True, size=20 * 6 * 4 + ) except FileExistsError: - self.detection_shms.append(mp.shared_memory.SharedMemory(name=f"out-{name}")) + shm_out = mp.shared_memory.SharedMemory(name=f"out-{name}") + + self.detection_shms.append(shm_in) + self.detection_shms.append(shm_out) for name, detector in self.config.detectors.items(): - if detector.type == 'cpu': - self.detectors[name] = EdgeTPUProcess(name, self.detection_queue, self.detection_out_events, model_shape, 'cpu', detector.num_threads) - if detector.type == 'edgetpu': - self.detectors[name] = EdgeTPUProcess(name, self.detection_queue, self.detection_out_events, model_shape, detector.device, detector.num_threads) + if detector.type == "cpu": + self.detectors[name] = EdgeTPUProcess( + name, + self.detection_queue, + self.detection_out_events, + model_shape, + "cpu", + detector.num_threads, + ) + if detector.type == "edgetpu": + self.detectors[name] = EdgeTPUProcess( + name, + self.detection_queue, + self.detection_out_events, + model_shape, + detector.device, + detector.num_threads, + ) def start_detected_frames_processor(self): - self.detected_frames_processor = TrackedObjectProcessor(self.config, self.mqtt_client, self.config.mqtt.topic_prefix, - self.detected_frames_queue, self.event_queue, self.event_processed_queue, self.stop_event) + self.detected_frames_processor = TrackedObjectProcessor( + self.config, + self.mqtt_client, + self.config.mqtt.topic_prefix, + self.detected_frames_queue, + self.event_queue, + self.event_processed_queue, + self.stop_event, + ) self.detected_frames_processor.start() def start_camera_processors(self): model_shape = (self.config.model.height, self.config.model.width) for name, config in self.config.cameras.items(): - camera_process = mp.Process(target=track_camera, name=f"camera_processor:{name}", args=(name, config, model_shape, - self.detection_queue, self.detection_out_events[name], self.detected_frames_queue, - self.camera_metrics[name])) + camera_process = mp.Process( + target=track_camera, + name=f"camera_processor:{name}", + args=( + name, + config, + model_shape, + self.detection_queue, + self.detection_out_events[name], + self.detected_frames_queue, + self.camera_metrics[name], + ), + ) camera_process.daemon = True - self.camera_metrics[name]['process'] = camera_process + self.camera_metrics[name]["process"] = camera_process camera_process.start() logger.info(f"Camera processor started for {name}: {camera_process.pid}") def start_camera_capture_processes(self): for name, config in self.config.cameras.items(): - capture_process = mp.Process(target=capture_camera, name=f"camera_capture:{name}", args=(name, config, - self.camera_metrics[name])) + capture_process = mp.Process( + target=capture_camera, + name=f"camera_capture:{name}", + args=(name, config, self.camera_metrics[name]), + ) capture_process.daemon = True - self.camera_metrics[name]['capture_process'] = capture_process + self.camera_metrics[name]["capture_process"] = capture_process capture_process.start() logger.info(f"Capture process started for {name}: {capture_process.pid}") - + def start_event_processor(self): - self.event_processor = EventProcessor(self.config, self.camera_metrics, self.event_queue, self.event_processed_queue, self.stop_event) + self.event_processor = EventProcessor( + self.config, + self.camera_metrics, + self.event_queue, + self.event_processed_queue, + self.stop_event, + ) self.event_processor.start() - + def start_event_cleanup(self): self.event_cleanup = EventCleanup(self.config, self.stop_event) self.event_cleanup.start() - + def start_recording_maintainer(self): self.recording_maintainer = RecordingMaintainer(self.config, self.stop_event) self.recording_maintainer.start() def start_stats_emitter(self): - self.stats_emitter = StatsEmitter(self.config, self.stats_tracking, self.mqtt_client, self.config.mqtt.topic_prefix, self.stop_event) + self.stats_emitter = StatsEmitter( + self.config, + self.stats_tracking, + self.mqtt_client, + self.config.mqtt.topic_prefix, + self.stop_event, + ) self.stats_emitter.start() def start_watchdog(self): @@ -244,14 +319,20 @@ class FrigateApp(): def receiveSignal(signalNumber, frame): self.stop() sys.exit() - + signal.signal(signal.SIGTERM, receiveSignal) - server = pywsgi.WSGIServer(('127.0.0.1', 5001), self.flask_app, handler_class=WebSocketHandler) - server.serve_forever() + server = pywsgi.WSGIServer( + ("127.0.0.1", 5001), self.flask_app, handler_class=WebSocketHandler + ) + + try: + server.serve_forever() + except KeyboardInterrupt: + pass self.stop() - + def stop(self): logger.info(f"Stopping...") self.stop_event.set() diff --git a/frigate/config.py b/frigate/config.py index 0878f3c1f..07655f1c8 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -1,8 +1,11 @@ +from __future__ import annotations + import base64 +import dataclasses import json import logging import os -from typing import Dict +from typing import Any, Dict, List, Optional, Tuple, Union import cv2 import matplotlib.pyplot as plt @@ -15,1013 +18,968 @@ from frigate.util import create_mask logger = logging.getLogger(__name__) -DEFAULT_TRACKED_OBJECTS = ['person'] +DEFAULT_TRACKED_OBJECTS = ["person"] +DEFAULT_DETECTORS = {"coral": {"type": "edgetpu", "device": "usb"}} 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 + 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' - } -} + +@dataclasses.dataclass(frozen=True) +class DetectorConfig: + type: str + device: str + num_threads: int + + @classmethod + def build(cls, config) -> DetectorConfig: + return DetectorConfig(config["type"], config["device"], config["num_threads"]) + + def to_dict(self) -> Dict[str, Any]: + return dataclasses.asdict(self) + 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, - vol.Optional('stats_interval', default=60): int, - 'user': str, - 'password': str + vol.Required("host"): str, + vol.Optional("port", default=1883): int, + vol.Optional("topic_prefix", default="frigate"): str, + vol.Optional("client_id", default="frigate"): str, + vol.Optional("stats_interval", default=60): int, + vol.Inclusive("user", "auth"): str, + vol.Inclusive("password", "auth"): str, + vol.Optional("tls_ca_certs"): str, + vol.Optional("tls_client_cert"): str, + vol.Optional("tls_client_key"): str, + vol.Optional("tls_insecure"): bool, } ) + +@dataclasses.dataclass(frozen=True) +class MqttConfig: + host: str + port: int + topic_prefix: str + client_id: str + stats_interval: int + user: Optional[str] + password: Optional[str] + tls_ca_certs: Optional[str] + tls_client_cert: Optional[str] + tls_client_key: Optional[str] + tls_insecure: Optional[bool] + + @classmethod + def build(cls, config) -> MqttConfig: + return MqttConfig( + config["host"], + config["port"], + config["topic_prefix"], + config["client_id"], + config["stats_interval"], + config.get("user"), + config.get("password"), + config.get("tls_ca_certs"), + config.get("tls_client_cert"), + config.get("tls_client_key"), + config.get("tls_insecure"), + ) + + def to_dict(self) -> Dict[str, Any]: + return dataclasses.asdict(self) + + RETAIN_SCHEMA = vol.Schema( - { - vol.Required('default',default=10): int, - 'objects': { - str: int - } - } + {vol.Required("default", default=10): int, "objects": {str: int}} ) + +@dataclasses.dataclass(frozen=True) +class RetainConfig: + default: int + objects: Dict[str, int] + + @classmethod + def build(cls, config, global_config={}) -> RetainConfig: + return RetainConfig( + config.get("default", global_config.get("default")), + config.get("objects", global_config.get("objects", {})), + ) + + def to_dict(self) -> Dict[str, Any]: + return dataclasses.asdict(self) + + CLIPS_SCHEMA = vol.Schema( { - vol.Optional('max_seconds', default=300): int, - 'tmpfs_cache_size': str, - vol.Optional('retain', default={}): RETAIN_SCHEMA + vol.Optional("max_seconds", default=300): int, + vol.Optional("retain", default={}): RETAIN_SCHEMA, } ) -FFMPEG_GLOBAL_ARGS_DEFAULT = ['-hide_banner','-loglevel','warning'] -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( +@dataclasses.dataclass(frozen=True) +class ClipsConfig: + max_seconds: int + retain: RetainConfig + + @classmethod + def build(cls, config) -> ClipsConfig: + return ClipsConfig( + config["max_seconds"], + RetainConfig.build(config["retain"]), + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "max_seconds": self.max_seconds, + "retain": self.retain.to_dict(), + } + + +MOTION_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]), + "mask": vol.Any(str, [str]), + "threshold": vol.Range(min=1, max=255), + "contour_area": int, + "delta_alpha": float, + "frame_alpha": float, + "frame_height": int, + } +) + + +@dataclasses.dataclass(frozen=True) +class MotionConfig: + raw_mask: Union[str, List[str]] + mask: np.ndarray + threshold: int + contour_area: int + delta_alpha: float + frame_alpha: float + frame_height: int + + @classmethod + def build(cls, config, global_config, frame_shape) -> MotionConfig: + raw_mask = config.get("mask") + if raw_mask: + mask = create_mask(frame_shape, raw_mask) + else: + mask = np.zeros(frame_shape, np.uint8) + mask[:] = 255 + + return MotionConfig( + raw_mask, + mask, + config.get("threshold", global_config.get("threshold", 25)), + config.get("contour_area", global_config.get("contour_area", 100)), + config.get("delta_alpha", global_config.get("delta_alpha", 0.2)), + config.get("frame_alpha", global_config.get("frame_alpha", 0.2)), + config.get( + "frame_height", global_config.get("frame_height", frame_shape[0] // 6) + ), + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "mask": self.raw_mask, + "threshold": self.threshold, + "contour_area": self.contour_area, + "delta_alpha": self.delta_alpha, + "frame_alpha": self.frame_alpha, + "frame_height": self.frame_height, + } + + +GLOBAL_DETECT_SCHEMA = vol.Schema({"max_disappeared": int}) +DETECT_SCHEMA = GLOBAL_DETECT_SCHEMA.extend( + {vol.Optional("enabled", default=True): bool} +) + + +@dataclasses.dataclass +class DetectConfig: + enabled: bool + max_disappeared: int + + @classmethod + def build(cls, config, global_config, camera_fps) -> DetectConfig: + return DetectConfig( + config["enabled"], + config.get( + "max_disappeared", global_config.get("max_disappeared", camera_fps * 5) + ), + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "enabled": self.enabled, + "max_disappeared": self.max_disappeared, + } + + +ZONE_FILTER_SCHEMA = vol.Schema( + { + str: { + "min_area": int, + "max_area": int, + "threshold": float, + } + } +) +FILTER_SCHEMA = ZONE_FILTER_SCHEMA.extend( + { + str: { + "min_score": float, + "mask": vol.Any(str, [str]), } } ) -MOTION_SCHEMA = vol.Schema( - { - 'mask': vol.Any(str, [str]), - '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 - } -) +@dataclasses.dataclass(frozen=True) +class FilterConfig: + min_area: int + max_area: int + threshold: float + min_score: float + mask: Optional[np.ndarray] + raw_mask: Union[str, List[str]] -FILTER_SCHEMA = vol.Schema( - { - str: { - 'min_area': int, - 'max_area': int, - 'threshold': float, - } + @classmethod + def build( + cls, config, global_config={}, global_mask=None, frame_shape=None + ) -> FilterConfig: + raw_mask = [] + if global_mask: + if isinstance(global_mask, list): + raw_mask += global_mask + elif isinstance(global_mask, str): + raw_mask += [global_mask] + + config_mask = config.get("mask") + if config_mask: + if isinstance(config_mask, list): + raw_mask += config_mask + elif isinstance(config_mask, str): + raw_mask += [config_mask] + + mask = create_mask(frame_shape, raw_mask) if raw_mask else None + + return FilterConfig( + min_area=config.get("min_area", global_config.get("min_area", 0)), + max_area=config.get("max_area", global_config.get("max_area", 24000000)), + threshold=config.get("threshold", global_config.get("threshold", 0.7)), + min_score=config.get("min_score", global_config.get("min_score", 0.5)), + mask=mask, + raw_mask=raw_mask, + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "min_area": self.min_area, + "max_area": self.max_area, + "threshold": self.threshold, + "min_score": self.min_score, + "mask": self.raw_mask, + } + + +ZONE_SCHEMA = { + str: { + vol.Required("coordinates"): vol.Any(str, [str]), + vol.Optional("filters", default={}): ZONE_FILTER_SCHEMA, } -) +} + + +@dataclasses.dataclass(frozen=True) +class ZoneConfig: + filters: Dict[str, FilterConfig] + coordinates: Union[str, List[str]] + contour: np.ndarray + color: Tuple[int, int, int] + + @classmethod + def build(cls, config, color: Tuple[int, int, int]) -> ZoneConfig: + coordinates = config["coordinates"] + + if isinstance(coordinates, list): + contour = np.array( + [[int(p.split(",")[0]), int(p.split(",")[1])] for p in coordinates] + ) + elif isinstance(coordinates, str): + points = coordinates.split(",") + 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}") + contour = np.array([]) + + return ZoneConfig( + {name: FilterConfig.build(c) for name, c in config["filters"].items()}, + coordinates, + contour, + color=color, + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "filters": {k: f.to_dict() for k, f in self.filters.items()}, + "coordinates": self.coordinates, + } + def filters_for_all_tracked_objects(object_config): - for tracked_object in object_config.get('track', DEFAULT_TRACKED_OBJECTS): - if not 'filters' in object_config: - object_config['filters'] = {} - if not tracked_object in object_config['filters']: - object_config['filters'][tracked_object] = {} + for tracked_object in object_config.get("track", DEFAULT_TRACKED_OBJECTS): + 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, + +OBJECTS_SCHEMA = vol.Schema( + vol.All( + filters_for_all_tracked_objects, + { + "track": [str], + "mask": vol.Any(str, [str]), + vol.Optional("filters", default={}): FILTER_SCHEMA, + }, + ) +) + + +@dataclasses.dataclass(frozen=True) +class ObjectConfig: + track: List[str] + filters: Dict[str, FilterConfig] + raw_mask: Optional[Union[str, List[str]]] + + @classmethod + def build(cls, config, global_config, frame_shape) -> ObjectConfig: + track = config.get("track", global_config.get("track", DEFAULT_TRACKED_OBJECTS)) + raw_mask = config.get("mask") + return ObjectConfig( + track, + { + name: FilterConfig.build( + config["filters"].get(name, {}), + global_config["filters"].get(name, {}), + raw_mask, + frame_shape, + ) + for name in track + }, + raw_mask, + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "track": self.track, + "mask": self.raw_mask, + "filters": {k: f.to_dict() for k, f in self.filters.items()}, + } + + +FFMPEG_GLOBAL_ARGS_DEFAULT = ["-hide_banner", "-loglevel", "warning"] +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( { - 'track': [str], - 'mask': vol.Any(str, [str]), - vol.Optional('filters', default = {}): FILTER_SCHEMA.extend( - { - str: { - 'min_score': float, - 'mask': vol.Any(str, [str]), - } - }) + 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] + ), + }, } -)) +) + def each_role_used_once(inputs): - roles = [role for i in inputs for role in i['roles']] + 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 + def detect_is_required(inputs): - roles = [role for i in inputs for role in i['roles']] - if not 'detect' in roles: + roles = [role for i in inputs for role in i["roles"]] + if not "detect" in roles: 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"), - vol.Msg(detect_is_required, msg="The detect role is required")), - 'global_args': vol.Any(str, [str]), - 'hwaccel_args': vol.Any(str, [str]), - 'input_args': vol.Any(str, [str]), - '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]), - } + 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(detect_is_required, msg="The detect role is required"), + ), + "global_args": vol.Any(str, [str]), + "hwaccel_args": vol.Any(str, [str]), + "input_args": vol.Any(str, [str]), + "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] + ), + }, } ) + +@dataclasses.dataclass(frozen=True) +class CameraFfmpegConfig: + inputs: List[CameraInput] + output_args: Dict[str, List[str]] + + @classmethod + def build(self, config, global_config): + output_args = config.get("output_args", global_config["output_args"]) + output_args = { + k: v if isinstance(v, list) else v.split(" ") + for k, v in output_args.items() + } + return CameraFfmpegConfig( + [CameraInput.build(i, config, global_config) for i in config["inputs"]], + output_args, + ) + + +@dataclasses.dataclass(frozen=True) +class CameraInput: + path: str + roles: List[str] + global_args: List[str] + hwaccel_args: List[str] + input_args: List[str] + + @classmethod + def build(cls, ffmpeg_input, camera_config, global_config) -> CameraInput: + return CameraInput( + ffmpeg_input["path"], + ffmpeg_input["roles"], + CameraInput._extract_args( + "global_args", ffmpeg_input, camera_config, global_config + ), + CameraInput._extract_args( + "hwaccel_args", ffmpeg_input, camera_config, global_config + ), + CameraInput._extract_args( + "input_args", ffmpeg_input, camera_config, global_config + ), + ) + + @staticmethod + def _extract_args(name, ffmpeg_input, camera_config, global_config): + args = ffmpeg_input.get(name, camera_config.get(name, global_config[name])) + return args if isinstance(args, list) else args.split(" ") + + def ensure_zones_and_cameras_have_different_names(cameras): - zones = [zone for camera in cameras.values() for zone in camera['zones'].keys()] + 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, - 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('clips', default={}): { - vol.Optional('enabled', default=False): bool, - vol.Optional('pre_capture', default=5): int, - vol.Optional('post_capture', default=5): int, - vol.Optional('required_zones', default=[]): [str], - 'objects': [str], - vol.Optional('retain', default={}): 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('enabled', default=False): bool, - vol.Optional('timestamp', default=False): bool, - vol.Optional('bounding_box', default=False): bool, - vol.Optional('crop', default=False): bool, - vol.Optional('required_zones', default=[]): [str], - 'height': int, - vol.Optional('retain', default={}): RETAIN_SCHEMA, - }, - vol.Optional('mqtt', default={}): { - vol.Optional('enabled', default=True): bool, - vol.Optional('timestamp', default=True): bool, - vol.Optional('bounding_box', default=True): bool, - vol.Optional('crop', default=True): bool, - vol.Optional('height', default=270): int, - vol.Optional('required_zones', default=[]): [str], - }, - vol.Optional('objects', default={}): OBJECTS_SCHEMA, - vol.Optional('motion', default={}): MOTION_SCHEMA, - vol.Optional('detect', default={}): DETECT_SCHEMA.extend({ - vol.Optional('enabled', default=True): bool - }) - } - }, vol.Msg(ensure_zones_and_cameras_have_different_names, msg='Zones cannot share names with cameras')) + +CAMERAS_SCHEMA = vol.Schema( + vol.All( + { + str: { + vol.Required("ffmpeg"): CAMERA_FFMPEG_SCHEMA, + vol.Required("height"): int, + vol.Required("width"): int, + "fps": int, + vol.Optional("best_image_timeout", default=60): int, + vol.Optional("zones", default={}): ZONE_SCHEMA, + vol.Optional("clips", default={}): { + vol.Optional("enabled", default=False): bool, + vol.Optional("pre_capture", default=5): int, + vol.Optional("post_capture", default=5): int, + vol.Optional("required_zones", default=[]): [str], + "objects": [str], + vol.Optional("retain", default={}): 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("enabled", default=False): bool, + vol.Optional("timestamp", default=False): bool, + vol.Optional("bounding_box", default=False): bool, + vol.Optional("crop", default=False): bool, + vol.Optional("required_zones", default=[]): [str], + "height": int, + vol.Optional("retain", default={}): RETAIN_SCHEMA, + }, + vol.Optional("mqtt", default={}): { + vol.Optional("enabled", default=True): bool, + vol.Optional("timestamp", default=True): bool, + vol.Optional("bounding_box", default=True): bool, + vol.Optional("crop", default=True): bool, + vol.Optional("height", default=270): int, + vol.Optional("required_zones", default=[]): [str], + }, + vol.Optional("objects", default={}): 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('snapshots', default={}): { - vol.Optional('retain', default={}): RETAIN_SCHEMA - }, - vol.Optional('clips', default={}): 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, - vol.Optional('environment_vars', default={}): { str: str } - } -) -class DatabaseConfig(): - def __init__(self, config): - self._path = config['path'] +@dataclasses.dataclass +class CameraSnapshotsConfig: + enabled: bool + timestamp: bool + bounding_box: bool + crop: bool + required_zones: List[str] + height: Optional[int] + retain: RetainConfig - @property - def path(self): - return self._path + @classmethod + def build(self, config, global_config) -> CameraSnapshotsConfig: + return CameraSnapshotsConfig( + enabled=config["enabled"], + timestamp=config["timestamp"], + bounding_box=config["bounding_box"], + crop=config["crop"], + required_zones=config["required_zones"], + height=config.get("height"), + retain=RetainConfig.build( + config["retain"], global_config["snapshots"]["retain"] + ), + ) - def to_dict(self): + def to_dict(self) -> Dict[str, Any]: 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') - self._stats_interval = config.get('stats_interval') - - @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 - - @property - def stats_interval(self): - return self._stats_interval - - def to_dict(self): - return { - 'host': self.host, - 'port': self.port, - 'topic_prefix': self.topic_prefix, - 'client_id': self.client_id, - 'user': self.user, - 'stats_interval': self.stats_interval - } - -class CameraInput(): - def __init__(self, camera_config, global_config, ffmpeg_input): - self._path = ffmpeg_input['path'] - self._roles = ffmpeg_input['roles'] - self._global_args = ffmpeg_input.get('global_args', camera_config.get('global_args', global_config['global_args'])) - self._hwaccel_args = ffmpeg_input.get('hwaccel_args', camera_config.get('hwaccel_args', global_config['hwaccel_args'])) - self._input_args = ffmpeg_input.get('input_args', camera_config.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(config, 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 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, - 'objects': self.objects - } - -class ClipsConfig(): - def __init__(self, config): - self._max_seconds = config['max_seconds'] - self._tmpfs_cache_size = config.get('tmpfs_cache_size', '').strip() - self._retain = RetainConfig(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 SnapshotsConfig(): - def __init__(self, config): - self._retain = RetainConfig(config['retain'], config['retain']) - - @property - def retain(self): - return self._retain - - def to_dict(self): - return { - '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, global_config, config, global_mask=None, frame_shape=None): - self._min_area = config.get('min_area', global_config.get('min_area', 0)) - self._max_area = config.get('max_area', global_config.get('max_area', 24000000)) - self._threshold = config.get('threshold', global_config.get('threshold', 0.7)) - self._min_score = config.get('min_score', global_config.get('min_score', 0.5)) - - self._raw_mask = [] - if global_mask: - if isinstance(global_mask, list): - self._raw_mask += global_mask - elif isinstance(global_mask, str): - self._raw_mask += [global_mask] - - mask = config.get('mask') - if mask: - if isinstance(mask, list): - self._raw_mask += mask - elif isinstance(mask, str): - self._raw_mask += [mask] - self._mask = create_mask(frame_shape, self._raw_mask) if self._raw_mask else None - - @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 - - @property - def mask(self): - return self._mask - - def to_dict(self): - return { - 'min_area': self.min_area, - 'max_area': self.max_area, - 'threshold': self.threshold, - 'min_score': self.min_score, - 'mask': self._raw_mask - } - -class ObjectConfig(): - def __init__(self, global_config, config, frame_shape): - self._track = config.get('track', global_config.get('track', DEFAULT_TRACKED_OBJECTS)) - self._raw_mask = config.get('mask') - self._filters = { name: FilterConfig(global_config['filters'].get(name, {}), config['filters'].get(name, {}), self._raw_mask, frame_shape) for name in self._track } - - @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, - 'mask': self._raw_mask, - 'filters': { k: f.to_dict() for k, f in self.filters.items() } - } - -class CameraSnapshotsConfig(): - def __init__(self, global_config, config): - self._enabled = config['enabled'] - self._timestamp = config['timestamp'] - self._bounding_box = config['bounding_box'] - self._crop = config['crop'] - self._height = config.get('height') - self._retain = RetainConfig(global_config['snapshots']['retain'], config['retain']) - self._required_zones = config['required_zones'] - - @property - def enabled(self): - return self._enabled - - @property - def timestamp(self): - return self._timestamp - - @property - def bounding_box(self): - return self._bounding_box - - @property - def crop(self): - return self._crop - - @property - def height(self): - return self._height - - @property - def retain(self): - return self._retain - - @property - def required_zones(self): - return self._required_zones - - def to_dict(self): - return { - 'enabled': self.enabled, - 'timestamp': self.timestamp, - 'bounding_box': self.bounding_box, - 'crop': self.crop, - 'height': self.height, - 'retain': self.retain.to_dict(), - 'required_zones': self.required_zones - } - -class CameraMqttConfig(): - def __init__(self, config): - self._enabled = config['enabled'] - self._timestamp = config['timestamp'] - self._bounding_box = config['bounding_box'] - self._crop = config['crop'] - self._height = config.get('height') - self._required_zones = config['required_zones'] - - @property - def enabled(self): - return self._enabled - - @property - def timestamp(self): - return self._timestamp - - @property - def bounding_box(self): - return self._bounding_box - - @property - def crop(self): - return self._crop - - @property - def height(self): - return self._height - - @property - def required_zones(self): - return self._required_zones - - def to_dict(self): - return { - 'enabled': self.enabled, - 'timestamp': self.timestamp, - 'bounding_box': self.bounding_box, - 'crop': self.crop, - 'height': self.height, - 'required_zones': self.required_zones - } - -class CameraClipsConfig(): - 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') - self._retain = RetainConfig(global_config['clips']['retain'], config['retain']) - self._required_zones = config['required_zones'] - - @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 - - @property - def required_zones(self): - return self._required_zones - - 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(), - 'required_zones': self.required_zones - } - -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, frame_shape): - self._raw_mask = config.get('mask') - if self._raw_mask: - self._mask = create_mask(frame_shape, self._raw_mask) - else: - default_mask = np.zeros(frame_shape, np.uint8) - default_mask[:] = 255 - self._mask = default_mask - 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', frame_shape[0]//6)) - - @property - def mask(self): - return self._mask - - @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 { - 'mask': self._raw_mask, - 'threshold': self.threshold, - 'contour_area': self.contour_area, - 'delta_alpha': self.delta_alpha, - 'frame_alpha': self.frame_alpha, - 'frame_height': self.frame_height, + "enabled": self.enabled, + "timestamp": self.timestamp, + "bounding_box": self.bounding_box, + "crop": self.crop, + "height": self.height, + "retain": self.retain.to_dict(), + "required_zones": self.required_zones, } +@dataclasses.dataclass +class CameraMqttConfig: + enabled: bool + timestamp: bool + bounding_box: bool + crop: bool + height: int + required_zones: List[str] -class DetectConfig(): - def __init__(self, global_config, config, camera_fps): - self._enabled = config['enabled'] - self._max_disappeared = config.get('max_disappeared', global_config.get('max_disappeared', camera_fps*5)) + @classmethod + def build(cls, config) -> CameraMqttConfig: + return CameraMqttConfig( + config["enabled"], + config["timestamp"], + config["bounding_box"], + config["crop"], + config.get("height"), + config["required_zones"], + ) - @property - def enabled(self): - return self._enabled + def to_dict(self) -> Dict[str, Any]: + return dataclasses.asdict(self) - @property - def max_disappeared(self): - return self._max_disappeared - def to_dict(self): +@dataclasses.dataclass +class CameraClipsConfig: + enabled: bool + pre_capture: int + post_capture: int + required_zones: List[str] + objects: Optional[List[str]] + retain: RetainConfig + + @classmethod + def build(cls, config, global_config) -> CameraClipsConfig: + return CameraClipsConfig( + enabled=config["enabled"], + pre_capture=config["pre_capture"], + post_capture=config["post_capture"], + required_zones=config["required_zones"], + objects=config.get("objects"), + retain=RetainConfig.build( + config["retain"], + global_config["clips"]["retain"], + ), + ) + + def to_dict(self) -> Dict[str, Any]: return { - 'enabled': self.enabled, - 'max_disappeared': self._max_disappeared, + "enabled": self.enabled, + "pre_capture": self.pre_capture, + "post_capture": self.post_capture, + "objects": self.objects, + "retain": self.retain.to_dict(), + "required_zones": self.required_zones, } -class ZoneConfig(): - def __init__(self, name, config): - self._coordinates = config['coordinates'] - self._filters = { name: FilterConfig(c, 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([]) +@dataclasses.dataclass +class CameraRtmpConfig: + enabled: bool - self._color = (0,0,0) + @classmethod + def build(cls, config) -> CameraRtmpConfig: + return CameraRtmpConfig(config["enabled"]) + + def to_dict(self) -> Dict[str, Any]: + return dataclasses.asdict(self) + + +@dataclasses.dataclass(frozen=True) +class CameraConfig: + name: str + ffmpeg: CameraFfmpegConfig + height: int + width: int + fps: Optional[int] + best_image_timeout: int + zones: Dict[str, ZoneConfig] + clips: CameraClipsConfig + record: RecordConfig + rtmp: CameraRtmpConfig + snapshots: CameraSnapshotsConfig + mqtt: CameraMqttConfig + objects: ObjectConfig + motion: MotionConfig + detect: DetectConfig @property - def coordinates(self): - return self._coordinates + def frame_shape(self) -> Tuple[int, int]: + return self.height, self.width @property - def contour(self): - return self._contour - - @contour.setter - def contour(self, val): - self._contour = val + def frame_shape_yuv(self) -> Tuple[int, int]: + return self.height * 3 // 2, self.width @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()}, - 'coordinates': self._coordinates - } - -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._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']) - self._record = RecordConfig(global_config['record'], config['record']) - self._rtmp = CameraRtmpConfig(global_config, config['rtmp']) - self._snapshots = CameraSnapshotsConfig(global_config, config['snapshots']) - self._mqtt = CameraMqttConfig(config['mqtt']) - self._objects = ObjectConfig(global_config['objects'], config.get('objects', {}), self._frame_shape) - self._motion = MotionConfig(global_config['motion'], config['motion'], self._frame_shape) - self._detect = DetectConfig(global_config['detect'], config['detect'], config.get('fps', 5)) - - self._ffmpeg_cmds = [] - for ffmpeg_input in self._ffmpeg.inputs: + def ffmpeg_cmds(self) -> List[Dict[str, List[str]]]: + 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 - }) + ffmpeg_cmds.append({"roles": ffmpeg_input.roles, "cmd": ffmpeg_cmd}) + return ffmpeg_cmds + @classmethod + def build(cls, name, config, global_config) -> CameraConfig: + colors = plt.cm.get_cmap("tab10", len(config["zones"])) + zones = { + name: ZoneConfig.build(z, tuple(round(255 * c) for c in colors(idx)[:3])) + for idx, (name, z) in enumerate(config["zones"].items()) + } - self._set_zone_colors(self._zones) + frame_shape = config["height"], config["width"] + + return CameraConfig( + name=name, + ffmpeg=CameraFfmpegConfig.build(config["ffmpeg"], global_config["ffmpeg"]), + height=config["height"], + width=config["width"], + fps=config.get("fps"), + best_image_timeout=config["best_image_timeout"], + zones=zones, + clips=CameraClipsConfig.build(config["clips"], global_config), + record=RecordConfig.build(config["record"], global_config["record"]), + rtmp=CameraRtmpConfig.build(config["rtmp"]), + snapshots=CameraSnapshotsConfig.build(config["snapshots"], global_config), + mqtt=CameraMqttConfig.build(config["mqtt"]), + objects=ObjectConfig.build( + config.get("objects", {}), global_config["objects"], frame_shape + ), + motion=MotionConfig.build( + config["motion"], global_config["motion"], frame_shape + ), + detect=DetectConfig.build( + config["detect"], global_config["detect"], config.get("fps", 5) + ), + ) 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 "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: - 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 "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: + 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) + 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 != ''] + 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 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 - - @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): + def to_dict(self) -> Dict[str, Any]: 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()}, - 'clips': self.clips.to_dict(), - 'record': self.record.to_dict(), - 'rtmp': self.rtmp.to_dict(), - 'snapshots': self.snapshots.to_dict(), - 'mqtt': self.mqtt.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], + "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()}, + "clips": self.clips.to_dict(), + "record": self.record.to_dict(), + "rtmp": self.rtmp.to_dict(), + "snapshots": self.snapshots.to_dict(), + "mqtt": self.mqtt.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): +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={}): { + 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("snapshots", default={}): { + vol.Optional("retain", default={}): RETAIN_SCHEMA + }, + vol.Optional("clips", default={}): 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={}): GLOBAL_DETECT_SCHEMA, + vol.Required("cameras", default={}): CAMERAS_SCHEMA, + vol.Optional("environment_vars", default={}): {str: str}, + } +) + + +@dataclasses.dataclass(frozen=True) +class DatabaseConfig: + path: str + + @classmethod + def build(cls, config) -> DatabaseConfig: + return DatabaseConfig(config["path"]) + + def to_dict(self) -> Dict[str, Any]: + return dataclasses.asdict(self) + + +@dataclasses.dataclass(frozen=True) +class ModelConfig: + width: int + height: int + + @classmethod + def build(cls, config) -> ModelConfig: + return ModelConfig(config["width"], config["height"]) + + def to_dict(self) -> Dict[str, Any]: + return dataclasses.asdict(self) + + +@dataclasses.dataclass(frozen=True) +class LoggerConfig: + default: str + logs: Dict[str, str] + + @classmethod + def build(cls, config) -> LoggerConfig: + return LoggerConfig( + config["default"].upper(), + {k: v.upper() for k, v in config["logs"].items()}, + ) + + def to_dict(self) -> Dict[str, Any]: + return dataclasses.asdict(self) + + +@dataclasses.dataclass(frozen=True) +class SnapshotsConfig: + retain: RetainConfig + + @classmethod + def build(cls, config) -> SnapshotsConfig: + return SnapshotsConfig(RetainConfig.build(config["retain"])) + + def to_dict(self) -> Dict[str, Any]: + return {"retain": self.retain.to_dict()} + + +@dataclasses.dataclass +class RecordConfig: + enabled: bool + retain_days: int + + @classmethod + def build(cls, config, global_config) -> RecordConfig: + return RecordConfig( + config.get("enabled", global_config["enabled"]), + config.get("retain_days", global_config["retain_days"]), + ) + + def to_dict(self) -> Dict[str, Any]: + return dataclasses.asdict(self) + + +class FrigateConfig: + def __init__(self, config_file=None, config=None) -> None: if config is None and config_file is None: - raise ValueError('config or config_file must be defined') + raise ValueError("config or config_file must be defined") elif not config_file is None: config = self._load_file(config_file) @@ -1029,25 +987,34 @@ class FrigateConfig(): 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._clips = ClipsConfig(config['clips']) - self._snapshots = SnapshotsConfig(config['snapshots']) - self._cameras = { name: CameraConfig(name, c, config) for name, c in config['cameras'].items() } - self._logger = LoggerConfig(config['logger']) - self._environment_vars = config['environment_vars'] + self._database = DatabaseConfig.build(config["database"]) + self._model = ModelConfig.build(config["model"]) + self._detectors = { + name: DetectorConfig.build(d) for name, d in config["detectors"].items() + } + self._mqtt = MqttConfig.build(config["mqtt"]) + self._clips = ClipsConfig.build(config["clips"]) + self._snapshots = SnapshotsConfig.build(config["snapshots"]) + self._cameras = { + name: CameraConfig.build(name, c, config) + for name, c in config["cameras"].items() + } + self._logger = LoggerConfig.build(config["logger"]) + self._environment_vars = config["environment_vars"] def _sub_env_vars(self, config): - frigate_env_vars = {k: v for k, v in os.environ.items() if k.startswith('FRIGATE_')} + 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) + 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) + for camera in config["cameras"].values(): + for i in camera["ffmpeg"]["inputs"]: + i["path"] = i["path"].format(**frigate_env_vars) return config @@ -1062,25 +1029,25 @@ class FrigateConfig(): return config - def to_dict(self): + def to_dict(self) -> Dict[str, Any]: 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(), - 'clips': self.clips.to_dict(), - 'snapshots': self.snapshots.to_dict(), - 'cameras': {k: c.to_dict() for k, c in self.cameras.items()}, - 'logger': self.logger.to_dict(), - 'environment_vars': self._environment_vars + "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(), + "clips": self.clips.to_dict(), + "snapshots": self.snapshots.to_dict(), + "cameras": {k: c.to_dict() for k, c in self.cameras.items()}, + "logger": self.logger.to_dict(), + "environment_vars": self._environment_vars, } @property - def database(self): + def database(self) -> DatabaseConfig: return self._database @property - def model(self): + def model(self) -> ModelConfig: return self._model @property @@ -1088,19 +1055,19 @@ class FrigateConfig(): return self._detectors @property - def logger(self): + def logger(self) -> LoggerConfig: return self._logger @property - def mqtt(self): + def mqtt(self) -> MqttConfig: return self._mqtt @property - def clips(self): + def clips(self) -> ClipsConfig: return self._clips @property - def snapshots(self): + def snapshots(self) -> SnapshotsConfig: return self._snapshots @property diff --git a/frigate/const.py b/frigate/const.py index 2ea9f9f68..64a42b11a 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -1,3 +1,3 @@ -CLIPS_DIR = '/media/frigate/clips' -RECORD_DIR = '/media/frigate/recordings' -CACHE_DIR = '/tmp/cache' \ No newline at end of file +CLIPS_DIR = "/media/frigate/clips" +RECORD_DIR = "/media/frigate/recordings" +CACHE_DIR = "/tmp/cache" diff --git a/frigate/edgetpu.py b/frigate/edgetpu.py index d65ce523b..2ffd8c198 100644 --- a/frigate/edgetpu.py +++ b/frigate/edgetpu.py @@ -1,48 +1,49 @@ import datetime -import hashlib import logging import multiprocessing as mp import os import queue -import threading import signal +import threading from abc import ABC, abstractmethod -from multiprocessing.connection import Connection -from setproctitle import setproctitle from typing import Dict import numpy as np import tflite_runtime.interpreter as tflite +from setproctitle import setproctitle from tflite_runtime.interpreter import load_delegate from frigate.util import EventsPerSecond, SharedMemoryFrameManager, listen logger = logging.getLogger(__name__) -def load_labels(path, encoding='utf-8'): - """Loads labels from file (with or without index numbers). - Args: - path: path to label file. - encoding: label file encoding. - Returns: - Dictionary mapping indices to labels. - """ - with open(path, 'r', encoding=encoding) as f: - lines = f.readlines() - if not lines: - return {} - if lines[0].split(' ', maxsplit=1)[0].isdigit(): - pairs = [line.split(' ', maxsplit=1) for line in lines] - return {int(index): label.strip() for index, label in pairs} - else: - return {index: line.strip() for index, line in enumerate(lines)} +def load_labels(path, encoding="utf-8"): + """Loads labels from file (with or without index numbers). + Args: + path: path to label file. + encoding: label file encoding. + Returns: + Dictionary mapping indices to labels. + """ + with open(path, "r", encoding=encoding) as f: + lines = f.readlines() + if not lines: + return {} + + if lines[0].split(" ", maxsplit=1)[0].isdigit(): + pairs = [line.split(" ", maxsplit=1) for line in lines] + return {int(index): label.strip() for index, label in pairs} + else: + return {index: line.strip() for index, line in enumerate(lines)} + class ObjectDetector(ABC): @abstractmethod - def detect(self, tensor_input, threshold = .4): + def detect(self, tensor_input, threshold=0.4): pass + class LocalObjectDetector(ObjectDetector): def __init__(self, tf_device=None, num_threads=3, labels=None): self.fps = EventsPerSecond() @@ -57,27 +58,29 @@ class LocalObjectDetector(ObjectDetector): edge_tpu_delegate = None - if tf_device != 'cpu': + if tf_device != "cpu": try: logger.info(f"Attempting to load TPU as {device_config['device']}") - edge_tpu_delegate = load_delegate('libedgetpu.so.1.0', device_config) + edge_tpu_delegate = load_delegate("libedgetpu.so.1.0", device_config) logger.info("TPU found") self.interpreter = tflite.Interpreter( - model_path='/edgetpu_model.tflite', - experimental_delegates=[edge_tpu_delegate]) + model_path="/edgetpu_model.tflite", + experimental_delegates=[edge_tpu_delegate], + ) except ValueError: logger.info("No EdgeTPU detected.") raise else: self.interpreter = tflite.Interpreter( - model_path='/cpu_model.tflite', num_threads=num_threads) - + model_path="/cpu_model.tflite", num_threads=num_threads + ) + self.interpreter.allocate_tensors() self.tensor_input_details = self.interpreter.get_input_details() self.tensor_output_details = self.interpreter.get_output_details() - - def detect(self, tensor_input, threshold=.4): + + def detect(self, tensor_input, threshold=0.4): detections = [] raw_detections = self.detect_raw(tensor_input) @@ -85,28 +88,49 @@ class LocalObjectDetector(ObjectDetector): for d in raw_detections: if d[1] < threshold: break - detections.append(( - self.labels[int(d[0])], - float(d[1]), - (d[2], d[3], d[4], d[5]) - )) + detections.append( + (self.labels[int(d[0])], float(d[1]), (d[2], d[3], d[4], d[5])) + ) self.fps.update() return detections def detect_raw(self, tensor_input): - self.interpreter.set_tensor(self.tensor_input_details[0]['index'], tensor_input) + self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input) self.interpreter.invoke() - boxes = np.squeeze(self.interpreter.get_tensor(self.tensor_output_details[0]['index'])) - label_codes = np.squeeze(self.interpreter.get_tensor(self.tensor_output_details[1]['index'])) - scores = np.squeeze(self.interpreter.get_tensor(self.tensor_output_details[2]['index'])) + boxes = np.squeeze( + self.interpreter.get_tensor(self.tensor_output_details[0]["index"]) + ) + label_codes = np.squeeze( + self.interpreter.get_tensor(self.tensor_output_details[1]["index"]) + ) + scores = np.squeeze( + self.interpreter.get_tensor(self.tensor_output_details[2]["index"]) + ) - detections = np.zeros((20,6), np.float32) + detections = np.zeros((20, 6), np.float32) for i, score in enumerate(scores): - detections[i] = [label_codes[i], score, boxes[i][0], boxes[i][1], boxes[i][2], boxes[i][3]] - + detections[i] = [ + label_codes[i], + score, + boxes[i][0], + boxes[i][1], + boxes[i][2], + boxes[i][3], + ] + return detections -def run_detector(name: str, detection_queue: mp.Queue, out_events: Dict[str, mp.Event], avg_speed, start, model_shape, tf_device, num_threads): + +def run_detector( + name: str, + detection_queue: mp.Queue, + out_events: Dict[str, mp.Event], + avg_speed, + start, + model_shape, + tf_device, + num_threads, +): threading.current_thread().name = f"detector:{name}" logger = logging.getLogger(f"detector.{name}") logger.info(f"Starting detection process: {os.getpid()}") @@ -114,9 +138,10 @@ def run_detector(name: str, detection_queue: mp.Queue, out_events: Dict[str, mp. listen() stop_event = mp.Event() + def receiveSignal(signalNumber, frame): stop_event.set() - + signal.signal(signal.SIGTERM, receiveSignal) signal.signal(signal.SIGINT, receiveSignal) @@ -126,21 +151,17 @@ def run_detector(name: str, detection_queue: mp.Queue, out_events: Dict[str, mp. outputs = {} for name in out_events.keys(): out_shm = mp.shared_memory.SharedMemory(name=f"out-{name}", create=False) - out_np = np.ndarray((20,6), dtype=np.float32, buffer=out_shm.buf) - outputs[name] = { - 'shm': out_shm, - 'np': out_np - } - - while True: - if stop_event.is_set(): - break + out_np = np.ndarray((20, 6), dtype=np.float32, buffer=out_shm.buf) + outputs[name] = {"shm": out_shm, "np": out_np} + while not stop_event.is_set(): try: connection_id = detection_queue.get(timeout=5) except queue.Empty: continue - input_frame = frame_manager.get(connection_id, (1,model_shape[0],model_shape[1],3)) + input_frame = frame_manager.get( + connection_id, (1, model_shape[0], model_shape[1], 3) + ) if input_frame is None: continue @@ -148,26 +169,35 @@ def run_detector(name: str, detection_queue: mp.Queue, out_events: Dict[str, mp. # detect and send the output start.value = datetime.datetime.now().timestamp() detections = object_detector.detect_raw(input_frame) - duration = datetime.datetime.now().timestamp()-start.value - outputs[connection_id]['np'][:] = detections[:] + duration = datetime.datetime.now().timestamp() - start.value + outputs[connection_id]["np"][:] = detections[:] out_events[connection_id].set() start.value = 0.0 - avg_speed.value = (avg_speed.value*9 + duration)/10 - -class EdgeTPUProcess(): - def __init__(self, name, detection_queue, out_events, model_shape, tf_device=None, num_threads=3): + avg_speed.value = (avg_speed.value * 9 + duration) / 10 + + +class EdgeTPUProcess: + def __init__( + self, + name, + detection_queue, + out_events, + model_shape, + tf_device=None, + num_threads=3, + ): self.name = name self.out_events = out_events self.detection_queue = detection_queue - self.avg_inference_speed = mp.Value('d', 0.01) - self.detection_start = mp.Value('d', 0.0) + self.avg_inference_speed = mp.Value("d", 0.01) + self.detection_start = mp.Value("d", 0.0) self.detect_process = None self.model_shape = model_shape self.tf_device = tf_device self.num_threads = num_threads self.start_or_restart() - + def stop(self): self.detect_process.terminate() logging.info("Waiting for detection process to exit gracefully...") @@ -181,11 +211,25 @@ class EdgeTPUProcess(): self.detection_start.value = 0.0 if (not self.detect_process is None) and self.detect_process.is_alive(): self.stop() - self.detect_process = mp.Process(target=run_detector, name=f"detector:{self.name}", args=(self.name, self.detection_queue, self.out_events, self.avg_inference_speed, self.detection_start, self.model_shape, self.tf_device, self.num_threads)) + self.detect_process = mp.Process( + target=run_detector, + name=f"detector:{self.name}", + args=( + self.name, + self.detection_queue, + self.out_events, + self.avg_inference_speed, + self.detection_start, + self.model_shape, + self.tf_device, + self.num_threads, + ), + ) self.detect_process.daemon = True self.detect_process.start() -class RemoteObjectDetector(): + +class RemoteObjectDetector: def __init__(self, name, labels, detection_queue, event, model_shape): self.labels = load_labels(labels) self.name = name @@ -193,11 +237,15 @@ class RemoteObjectDetector(): self.detection_queue = detection_queue self.event = event self.shm = mp.shared_memory.SharedMemory(name=self.name, create=False) - self.np_shm = np.ndarray((1,model_shape[0],model_shape[1],3), dtype=np.uint8, buffer=self.shm.buf) - self.out_shm = mp.shared_memory.SharedMemory(name=f"out-{self.name}", create=False) - self.out_np_shm = np.ndarray((20,6), dtype=np.float32, buffer=self.out_shm.buf) - - def detect(self, tensor_input, threshold=.4): + self.np_shm = np.ndarray( + (1, model_shape[0], model_shape[1], 3), dtype=np.uint8, buffer=self.shm.buf + ) + self.out_shm = mp.shared_memory.SharedMemory( + name=f"out-{self.name}", create=False + ) + self.out_np_shm = np.ndarray((20, 6), dtype=np.float32, buffer=self.out_shm.buf) + + def detect(self, tensor_input, threshold=0.4): detections = [] # copy input to shared memory @@ -213,14 +261,12 @@ class RemoteObjectDetector(): for d in self.out_np_shm: if d[1] < threshold: break - detections.append(( - self.labels[int(d[0])], - float(d[1]), - (d[2], d[3], d[4], d[5]) - )) + detections.append( + (self.labels[int(d[0])], float(d[1]), (d[2], d[3], d[4], d[5])) + ) self.fps.update() return detections - + def cleanup(self): self.shm.unlink() self.out_shm.unlink() diff --git a/frigate/events.py b/frigate/events.py index 2430d9db3..a9b24dbcf 100644 --- a/frigate/events.py +++ b/frigate/events.py @@ -20,10 +20,13 @@ from peewee import fn logger = logging.getLogger(__name__) + class EventProcessor(threading.Thread): - def __init__(self, config, camera_processes, event_queue, event_processed_queue, stop_event): + def __init__( + self, config, camera_processes, event_queue, event_processed_queue, stop_event + ): threading.Thread.__init__(self) - self.name = 'event_processor' + self.name = "event_processor" self.config = config self.camera_processes = camera_processes self.cached_clips = {} @@ -33,31 +36,35 @@ class EventProcessor(threading.Thread): self.stop_event = stop_event def should_create_clip(self, camera, event_data): - if event_data['false_positive']: + if event_data["false_positive"]: return False - + # if there are required zones and there is no overlap required_zones = self.config.cameras[camera].clips.required_zones - if len(required_zones) > 0 and not set(event_data['entered_zones']) & set(required_zones): - logger.debug(f"Not creating clip for {event_data['id']} because it did not enter required zones") + if len(required_zones) > 0 and not set(event_data["entered_zones"]) & set( + required_zones + ): + logger.debug( + f"Not creating clip for {event_data['id']} because it did not enter required zones" + ) return False return True - + def refresh_cache(self): cached_files = os.listdir(CACHE_DIR) files_in_use = [] for process in psutil.process_iter(): try: - if process.name() != 'ffmpeg': + if process.name() != "ffmpeg": continue flist = process.open_files() if flist: for nt in flist: if nt.path.startswith(CACHE_DIR): - files_in_use.append(nt.path.split('/')[-1]) + files_in_use.append(nt.path.split("/")[-1]) except: continue @@ -65,130 +72,158 @@ class EventProcessor(threading.Thread): if f in files_in_use or f in self.cached_clips: continue - camera = '-'.join(f.split('-')[:-1]) - start_time = datetime.datetime.strptime(f.split('-')[-1].split('.')[0], '%Y%m%d%H%M%S') - - ffprobe_cmd = " ".join([ - 'ffprobe', - '-v', - 'error', - '-show_entries', - 'format=duration', - '-of', - 'default=noprint_wrappers=1:nokey=1', - f"{os.path.join(CACHE_DIR,f)}" - ]) - p = sp.Popen(ffprobe_cmd, stdout=sp.PIPE, shell=True) - (output, err) = p.communicate() - p_status = p.wait() - if p_status == 0: - duration = float(output.decode('utf-8').strip()) + basename = os.path.splitext(f)[0] + camera, date = basename.rsplit("-", maxsplit=1) + start_time = datetime.datetime.strptime(date, "%Y%m%d%H%M%S") + + ffprobe_cmd = [ + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + f"{os.path.join(CACHE_DIR, f)}", + ] + p = sp.run(ffprobe_cmd, capture_output=True) + if p.returncode == 0: + duration = float(p.stdout.decode().strip()) else: logger.info(f"bad file: {f}") - os.remove(os.path.join(CACHE_DIR,f)) + os.remove(os.path.join(CACHE_DIR, f)) continue self.cached_clips[f] = { - 'path': f, - 'camera': camera, - 'start_time': start_time.timestamp(), - 'duration': duration + "path": f, + "camera": camera, + "start_time": start_time.timestamp(), + "duration": duration, } if len(self.events_in_process) > 0: - earliest_event = min(self.events_in_process.values(), key=lambda x:x['start_time'])['start_time'] + earliest_event = min( + self.events_in_process.values(), key=lambda x: x["start_time"] + )["start_time"] else: earliest_event = datetime.datetime.now().timestamp() - # if the earliest event exceeds the max seconds, cap it + # if the earliest event is more tha max seconds ago, cap it max_seconds = self.config.clips.max_seconds - if datetime.datetime.now().timestamp()-earliest_event > max_seconds: - earliest_event = datetime.datetime.now().timestamp()-max_seconds - + earliest_event = max( + earliest_event, + datetime.datetime.now().timestamp() - self.config.clips.max_seconds, + ) + for f, data in list(self.cached_clips.items()): - if earliest_event-90 > data['start_time']+data['duration']: + if earliest_event - 90 > data["start_time"] + data["duration"]: del self.cached_clips[f] logger.debug(f"Cleaning up cached file {f}") - os.remove(os.path.join(CACHE_DIR,f)) - + os.remove(os.path.join(CACHE_DIR, f)) + # if we are still using more than 90% of the cache, proactively cleanup cache_usage = shutil.disk_usage("/tmp/cache") - if cache_usage.used/cache_usage.total > .9 and cache_usage.free < 200000000 and len(self.cached_clips) > 0: + if ( + cache_usage.used / cache_usage.total > 0.9 + and cache_usage.free < 200000000 + and len(self.cached_clips) > 0 + ): logger.warning("More than 90% of the cache is used.") - logger.warning("Consider increasing space available at /tmp/cache or reducing max_seconds in your clips config.") + logger.warning( + "Consider increasing space available at /tmp/cache or reducing max_seconds in your clips config." + ) logger.warning("Proactively cleaning up the cache...") - while cache_usage.used/cache_usage.total > .9: - oldest_clip = min(self.cached_clips.values(), key=lambda x:x['start_time']) - del self.cached_clips[oldest_clip['path']] - os.remove(os.path.join(CACHE_DIR,oldest_clip['path'])) + while cache_usage.used / cache_usage.total > 0.9: + oldest_clip = min( + self.cached_clips.values(), key=lambda x: x["start_time"] + ) + del self.cached_clips[oldest_clip["path"]] + os.remove(os.path.join(CACHE_DIR, oldest_clip["path"])) cache_usage = shutil.disk_usage("/tmp/cache") def create_clip(self, camera, event_data, pre_capture, post_capture): # get all clips from the camera with the event sorted - sorted_clips = sorted([c for c in self.cached_clips.values() if c['camera'] == camera], key = lambda i: i['start_time']) + sorted_clips = sorted( + [c for c in self.cached_clips.values() if c["camera"] == camera], + key=lambda i: i["start_time"], + ) # if there are no clips in the cache or we are still waiting on a needed file check every 5 seconds wait_count = 0 - while len(sorted_clips) == 0 or sorted_clips[-1]['start_time'] + sorted_clips[-1]['duration'] < event_data['end_time']+post_capture: + while ( + len(sorted_clips) == 0 + or sorted_clips[-1]["start_time"] + sorted_clips[-1]["duration"] + < event_data["end_time"] + post_capture + ): if wait_count > 4: - logger.warning(f"Unable to create clip for {camera} and event {event_data['id']}. There were no cache files for this event.") + logger.warning( + f"Unable to create clip for {camera} and event {event_data['id']}. There were no cache files for this event." + ) return False logger.debug(f"No cache clips for {camera}. Waiting...") time.sleep(5) self.refresh_cache() # get all clips from the camera with the event sorted - sorted_clips = sorted([c for c in self.cached_clips.values() if c['camera'] == camera], key = lambda i: i['start_time']) + sorted_clips = sorted( + [c for c in self.cached_clips.values() if c["camera"] == camera], + key=lambda i: i["start_time"], + ) wait_count += 1 - - playlist_start = event_data['start_time']-pre_capture - playlist_end = event_data['end_time']+post_capture + + playlist_start = event_data["start_time"] - pre_capture + playlist_end = event_data["end_time"] + post_capture playlist_lines = [] for clip in sorted_clips: # clip ends before playlist start time, skip - if clip['start_time']+clip['duration'] < playlist_start: + if clip["start_time"] + clip["duration"] < playlist_start: continue # clip starts after playlist ends, finish - if clip['start_time'] > playlist_end: + if clip["start_time"] > playlist_end: break playlist_lines.append(f"file '{os.path.join(CACHE_DIR,clip['path'])}'") # if this is the starting clip, add an inpoint - if clip['start_time'] < playlist_start: - playlist_lines.append(f"inpoint {int(playlist_start-clip['start_time'])}") + if clip["start_time"] < playlist_start: + playlist_lines.append( + f"inpoint {int(playlist_start-clip['start_time'])}" + ) # if this is the ending clip, add an outpoint - if clip['start_time']+clip['duration'] > playlist_end: - playlist_lines.append(f"outpoint {int(playlist_end-clip['start_time'])}") + if clip["start_time"] + clip["duration"] > playlist_end: + playlist_lines.append( + f"outpoint {int(playlist_end-clip['start_time'])}" + ) clip_name = f"{camera}-{event_data['id']}" ffmpeg_cmd = [ - 'ffmpeg', - '-y', - '-protocol_whitelist', - 'pipe,file', - '-f', - 'concat', - '-safe', - '0', - '-i', - '-', - '-c', - 'copy', - '-movflags', - '+faststart', - f"{os.path.join(CLIPS_DIR, clip_name)}.mp4" + "ffmpeg", + "-y", + "-protocol_whitelist", + "pipe,file", + "-f", + "concat", + "-safe", + "0", + "-i", + "-", + "-c", + "copy", + "-movflags", + "+faststart", + f"{os.path.join(CLIPS_DIR, clip_name)}.mp4", ] - p = sp.run(ffmpeg_cmd, input="\n".join(playlist_lines), encoding='ascii', capture_output=True) + p = sp.run( + ffmpeg_cmd, + input="\n".join(playlist_lines), + encoding="ascii", + capture_output=True, + ) if p.returncode != 0: logger.error(p.stderr) return False return True def run(self): - while True: - if self.stop_event.is_set(): - logger.info(f"Exiting event processor...") - break - + while not self.stop_event.is_set(): try: event_type, camera, event_data = self.event_queue.get(timeout=10) except queue.Empty: @@ -199,68 +234,82 @@ class EventProcessor(threading.Thread): logger.debug(f"Event received: {event_type} {camera} {event_data['id']}") self.refresh_cache() - if event_type == 'start': - self.events_in_process[event_data['id']] = event_data + if event_type == "start": + self.events_in_process[event_data["id"]] = event_data - if event_type == 'end': + if event_type == "end": clips_config = self.config.cameras[camera].clips clip_created = False if self.should_create_clip(camera, event_data): - if clips_config.enabled and (clips_config.objects is None or event_data['label'] in clips_config.objects): - clip_created = self.create_clip(camera, event_data, clips_config.pre_capture, clips_config.post_capture) - - if clip_created or event_data['has_snapshot']: + if clips_config.enabled and ( + clips_config.objects is None + or event_data["label"] in clips_config.objects + ): + clip_created = self.create_clip( + camera, + event_data, + clips_config.pre_capture, + clips_config.post_capture, + ) + + if clip_created or event_data["has_snapshot"]: Event.create( - id=event_data['id'], - label=event_data['label'], + id=event_data["id"], + label=event_data["label"], camera=camera, - start_time=event_data['start_time'], - end_time=event_data['end_time'], - top_score=event_data['top_score'], - false_positive=event_data['false_positive'], - zones=list(event_data['entered_zones']), - thumbnail=event_data['thumbnail'], + start_time=event_data["start_time"], + end_time=event_data["end_time"], + top_score=event_data["top_score"], + false_positive=event_data["false_positive"], + zones=list(event_data["entered_zones"]), + thumbnail=event_data["thumbnail"], has_clip=clip_created, - has_snapshot=event_data['has_snapshot'], + has_snapshot=event_data["has_snapshot"], ) - del self.events_in_process[event_data['id']] - self.event_processed_queue.put((event_data['id'], camera)) + del self.events_in_process[event_data["id"]] + self.event_processed_queue.put((event_data["id"], camera)) + + logger.info(f"Exiting event processor...") + class EventCleanup(threading.Thread): def __init__(self, config: FrigateConfig, stop_event): threading.Thread.__init__(self) - self.name = 'event_cleanup' + self.name = "event_cleanup" self.config = config self.stop_event = stop_event self.camera_keys = list(self.config.cameras.keys()) def expire(self, media): ## Expire events from unlisted cameras based on the global config - if media == 'clips': + if media == "clips": retain_config = self.config.clips.retain - file_extension = 'mp4' - update_params = {'has_clip': False} + file_extension = "mp4" + update_params = {"has_clip": False} else: retain_config = self.config.snapshots.retain - file_extension = 'jpg' - update_params = {'has_snapshot': False} - - distinct_labels = (Event.select(Event.label) - .where(Event.camera.not_in(self.camera_keys)) - .distinct()) - + file_extension = "jpg" + update_params = {"has_snapshot": False} + + distinct_labels = ( + Event.select(Event.label) + .where(Event.camera.not_in(self.camera_keys)) + .distinct() + ) + # loop over object types in db for l in distinct_labels: # get expiration time for this label expire_days = retain_config.objects.get(l.label, retain_config.default) - expire_after = (datetime.datetime.now() - datetime.timedelta(days=expire_days)).timestamp() + expire_after = ( + datetime.datetime.now() - datetime.timedelta(days=expire_days) + ).timestamp() # grab all events after specific time - expired_events = ( - Event.select() - .where(Event.camera.not_in(self.camera_keys), - Event.start_time < expire_after, - Event.label == l.label) + expired_events = Event.select().where( + Event.camera.not_in(self.camera_keys), + Event.start_time < expire_after, + Event.label == l.label, ) # delete the media from disk for event in expired_events: @@ -268,56 +317,57 @@ class EventCleanup(threading.Thread): media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}") media.unlink(missing_ok=True) # update the clips attribute for the db entry - update_query = ( - Event.update(update_params) - .where(Event.camera.not_in(self.camera_keys), - Event.start_time < expire_after, - Event.label == l.label) + update_query = Event.update(update_params).where( + Event.camera.not_in(self.camera_keys), + Event.start_time < expire_after, + Event.label == l.label, ) update_query.execute() ## Expire events from cameras based on the camera config for name, camera in self.config.cameras.items(): - if media == 'clips': + if media == "clips": retain_config = camera.clips.retain else: retain_config = camera.snapshots.retain # get distinct objects in database for this camera - distinct_labels = (Event.select(Event.label) - .where(Event.camera == name) - .distinct()) + distinct_labels = ( + Event.select(Event.label).where(Event.camera == name).distinct() + ) # loop over object types in db for l in distinct_labels: # get expiration time for this label expire_days = retain_config.objects.get(l.label, retain_config.default) - expire_after = (datetime.datetime.now() - datetime.timedelta(days=expire_days)).timestamp() + expire_after = ( + datetime.datetime.now() - datetime.timedelta(days=expire_days) + ).timestamp() # grab all events after specific time - expired_events = ( - Event.select() - .where(Event.camera == name, - Event.start_time < expire_after, - Event.label == l.label) + expired_events = Event.select().where( + Event.camera == name, + Event.start_time < expire_after, + Event.label == l.label, ) # delete the grabbed clips from disk for event in expired_events: media_name = f"{event.camera}-{event.id}" - media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}") + media = Path( + f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}" + ) media.unlink(missing_ok=True) # update the clips attribute for the db entry - update_query = ( - Event.update(update_params) - .where( Event.camera == name, - Event.start_time < expire_after, - Event.label == l.label) + update_query = Event.update(update_params).where( + Event.camera == name, + Event.start_time < expire_after, + Event.label == l.label, ) update_query.execute() def purge_duplicates(self): duplicate_query = """with grouped_events as ( select id, - label, - camera, + label, + camera, has_snapshot, has_clip, row_number() over ( @@ -327,7 +377,7 @@ class EventCleanup(threading.Thread): from event ) - select distinct id, camera, has_snapshot, has_clip from grouped_events + select distinct id, camera, has_snapshot, has_clip from grouped_events where copy_number > 1;""" duplicate_events = Event.raw(duplicate_query) @@ -341,32 +391,23 @@ class EventCleanup(threading.Thread): media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4") media.unlink(missing_ok=True) - (Event.delete() - .where( Event.id << [event.id for event in duplicate_events] ) - .execute()) - + ( + Event.delete() + .where(Event.id << [event.id for event in duplicate_events]) + .execute() + ) + def run(self): - counter = 0 - while(True): - if self.stop_event.is_set(): - logger.info(f"Exiting event cleanup...") - break - - # only expire events every 5 minutes, but check for stop events every 10 seconds - time.sleep(10) - counter = counter + 1 - if counter < 30: - continue - counter = 0 - - self.expire('clips') - self.expire('snapshots') + # only expire events every 5 minutes + while not self.stop_event.wait(300): + self.expire("clips") + self.expire("snapshots") self.purge_duplicates() # drop events from db where has_clip and has_snapshot are false - delete_query = ( - Event.delete() - .where( Event.has_clip == False, - Event.has_snapshot == False) + delete_query = Event.delete().where( + Event.has_clip == False, Event.has_snapshot == False ) delete_query.execute() + + logger.info(f"Exiting event cleanup...") diff --git a/frigate/http.py b/frigate/http.py index 4d818d37c..040e66bb3 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -1,21 +1,32 @@ import base64 -import datetime +from collections import OrderedDict +from datetime import datetime, timedelta import json +import glob import logging import os +import re import time from functools import reduce +from pathlib import Path import cv2 import gevent import numpy as np -from flask import (Blueprint, Flask, Response, current_app, jsonify, - make_response, request) +from flask import ( + Blueprint, + Flask, + Response, + current_app, + jsonify, + make_response, + request, +) from flask_sockets import Sockets -from peewee import SqliteDatabase, operator, fn, DoesNotExist +from peewee import SqliteDatabase, operator, fn, DoesNotExist, Value from playhouse.shortcuts import model_to_dict -from frigate.const import CLIPS_DIR +from frigate.const import CLIPS_DIR, RECORD_DIR from frigate.models import Event from frigate.stats import stats_snapshot from frigate.util import calculate_region @@ -23,10 +34,11 @@ from frigate.version import VERSION logger = logging.getLogger(__name__) -bp = Blueprint('frigate', __name__) -ws = Blueprint('ws', __name__) +bp = Blueprint("frigate", __name__) +ws = Blueprint("ws", __name__) -class MqttBackend(): + +class MqttBackend: """Interface for registering and updating WebSocket clients.""" def __init__(self, mqtt_client, topic_prefix): @@ -42,36 +54,48 @@ class MqttBackend(): try: json_message = json.loads(message) json_message = { - 'topic': f"{self.topic_prefix}/{json_message['topic']}", - 'payload': json_message['payload'], - 'retain': json_message.get('retain', False) + "topic": f"{self.topic_prefix}/{json_message['topic']}", + "payload": json_message["payload"], + "retain": json_message.get("retain", False), } except: logger.warning("Unable to parse websocket message as valid json.") return - logger.debug(f"Publishing mqtt message from websockets at {json_message['topic']}.") - self.mqtt_client.publish(json_message['topic'], json_message['payload'], retain=json_message['retain']) + logger.debug( + f"Publishing mqtt message from websockets at {json_message['topic']}." + ) + self.mqtt_client.publish( + json_message["topic"], + json_message["payload"], + retain=json_message["retain"], + ) def run(self): def send(client, userdata, message): """Sends mqtt messages to clients.""" try: logger.debug(f"Received mqtt message on {message.topic}.") - ws_message = json.dumps({ - 'topic': message.topic.replace(f"{self.topic_prefix}/",""), - 'payload': message.payload.decode() - }) + ws_message = json.dumps( + { + "topic": message.topic.replace(f"{self.topic_prefix}/", ""), + "payload": message.payload.decode(), + } + ) except: # if the payload can't be decoded don't relay to clients - logger.debug(f"MQTT payload for {message.topic} wasn't text. Skipping...") + logger.debug( + f"MQTT payload for {message.topic} wasn't text. Skipping..." + ) return for client in self.clients: try: client.send(ws_message) except: - logger.debug("Removing websocket client due to a closed connection.") + logger.debug( + "Removing websocket client due to a closed connection." + ) self.clients.remove(client) self.mqtt_client.message_callback_add(f"{self.topic_prefix}/#", send) @@ -80,7 +104,14 @@ class MqttBackend(): """Maintains mqtt subscription in the background.""" gevent.spawn(self.run) -def create_app(frigate_config, database: SqliteDatabase, stats_tracking, detected_frames_processor, mqtt_client): + +def create_app( + frigate_config, + database: SqliteDatabase, + stats_tracking, + detected_frames_processor, + mqtt_client, +): app = Flask(__name__) sockets = Sockets(app) @@ -105,14 +136,16 @@ def create_app(frigate_config, database: SqliteDatabase, stats_tracking, detecte return app -@bp.route('/') + +@bp.route("/") def is_healthy(): return "Frigate is running. Alive and healthy!" -@bp.route('/events/summary') + +@bp.route("/events/summary") def events_summary(): - has_clip = request.args.get('has_clip', type=int) - has_snapshot = request.args.get('has_snapshot', type=int) + has_clip = request.args.get("has_clip", type=int) + has_snapshot = request.args.get("has_snapshot", type=int) clauses = [] @@ -126,35 +159,63 @@ def events_summary(): clauses.append((1 == 1)) groups = ( - Event - .select( - 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') - ) - .where(reduce(operator.and_, clauses)) - .group_by( - Event.camera, - Event.label, - fn.strftime('%Y-%m-%d', fn.datetime(Event.start_time, 'unixepoch', 'localtime')), - Event.zones - ) + Event.select( + 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"), ) + .where(reduce(operator.and_, clauses)) + .group_by( + Event.camera, + Event.label, + fn.strftime( + "%Y-%m-%d", fn.datetime(Event.start_time, "unixepoch", "localtime") + ), + Event.zones, + ) + ) return jsonify([e for e in groups.dicts()]) -@bp.route('/events/') + +@bp.route("/events/", methods=("GET",)) def event(id): try: return model_to_dict(Event.get(Event.id == id)) except DoesNotExist: return "Event not found", 404 -@bp.route('/events//thumbnail.jpg') + +@bp.route("/events/", methods=("DELETE",)) +def delete_event(id): + try: + event = Event.get(Event.id == id) + except DoesNotExist: + return make_response( + jsonify({"success": False, "message": "Event" + id + " not found"}), 404 + ) + + media_name = f"{event.camera}-{event.id}" + if event.has_snapshot: + media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg") + media.unlink(missing_ok=True) + if event.has_clip: + media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4") + media.unlink(missing_ok=True) + + event.delete_instance() + return make_response( + jsonify({"success": True, "message": "Event" + id + " deleted"}), 200 + ) + + +@bp.route("/events//thumbnail.jpg") def event_thumbnail(id): - format = request.args.get('format', 'ios') + format = request.args.get("format", "ios") thumbnail_bytes = None try: event = Event.get(Event.id == id) @@ -162,7 +223,8 @@ def event_thumbnail(id): except DoesNotExist: # see if the object is currently being tracked try: - for camera_state in current_app.detected_frames_processor.camera_states.values(): + camera_states = current_app.detected_frames_processor.camera_states.values() + for camera_state in camera_states: if id in camera_state.tracked_objects: tracked_obj = camera_state.tracked_objects.get(id) if not tracked_obj is None: @@ -174,18 +236,27 @@ def event_thumbnail(id): return "Event not found", 404 # android notifications prefer a 2:1 ratio - if format == 'android': + 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, [int(cv2.IMWRITE_JPEG_QUALITY), 70]) + 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, [int(cv2.IMWRITE_JPEG_QUALITY), 70]) thumbnail_bytes = jpg.tobytes() response = make_response(thumbnail_bytes) - response.headers['Content-Type'] = 'image/jpg' + response.headers["Content-Type"] = "image/jpg" return response -@bp.route('/events//snapshot.jpg') + +@bp.route("/events//snapshot.jpg") def event_snapshot(id): jpg_bytes = None try: @@ -193,20 +264,23 @@ def event_snapshot(id): if not event.has_snapshot: return "Snapshot not available", 404 # read snapshot from disk - with open(os.path.join(CLIPS_DIR, f"{event.camera}-{id}.jpg"), 'rb') as image_file: + with open( + os.path.join(CLIPS_DIR, f"{event.camera}-{id}.jpg"), "rb" + ) as image_file: jpg_bytes = image_file.read() except DoesNotExist: # see if the object is currently being tracked try: - for camera_state in current_app.detected_frames_processor.camera_states.values(): + camera_states = current_app.detected_frames_processor.camera_states.values() + for camera_state in camera_states: if id in camera_state.tracked_objects: tracked_obj = camera_state.tracked_objects.get(id) if not tracked_obj is None: jpg_bytes = tracked_obj.get_jpg_bytes( - timestamp=request.args.get('timestamp', type=int), - bounding_box=request.args.get('bbox', type=int), - crop=request.args.get('crop', type=int), - height=request.args.get('h', type=int) + timestamp=request.args.get("timestamp", type=int), + bounding_box=request.args.get("bbox", type=int), + crop=request.args.get("crop", type=int), + height=request.args.get("h", type=int), ) except: return "Event not found", 404 @@ -214,20 +288,21 @@ def event_snapshot(id): return "Event not found", 404 response = make_response(jpg_bytes) - response.headers['Content-Type'] = 'image/jpg' + response.headers["Content-Type"] = "image/jpg" return response -@bp.route('/events') + +@bp.route("/events") def events(): - limit = request.args.get('limit', 100) - camera = request.args.get('camera') - label = request.args.get('label') - zone = request.args.get('zone') - after = request.args.get('after', type=float) - before = request.args.get('before', type=float) - has_clip = request.args.get('has_clip', type=int) - has_snapshot = request.args.get('has_snapshot', type=int) - include_thumbnails = request.args.get('include_thumbnails', default=1, type=int) + limit = request.args.get("limit", 100) + camera = request.args.get("camera") + label = request.args.get("label") + zone = request.args.get("zone") + after = request.args.get("after", type=float) + before = request.args.get("before", type=float) + has_clip = request.args.get("has_clip", type=int) + has_snapshot = request.args.get("has_snapshot", type=int) + include_thumbnails = request.args.get("include_thumbnails", default=1, type=int) clauses = [] excluded_fields = [] @@ -239,7 +314,7 @@ def events(): clauses.append((Event.label == label)) if zone: - clauses.append((Event.zones.cast('text') % f"*\"{zone}\"*")) + clauses.append((Event.zones.cast("text") % f'*"{zone}"*')) if after: clauses.append((Event.start_time >= after)) @@ -259,116 +334,268 @@ def events(): if len(clauses) == 0: clauses.append((1 == 1)) - events = (Event.select() - .where(reduce(operator.and_, clauses)) - .order_by(Event.start_time.desc()) - .limit(limit)) + events = ( + Event.select() + .where(reduce(operator.and_, clauses)) + .order_by(Event.start_time.desc()) + .limit(limit) + ) return jsonify([model_to_dict(e, exclude=excluded_fields) for e in events]) -@bp.route('/config') + +@bp.route("/config") def config(): return jsonify(current_app.frigate_config.to_dict()) -@bp.route('/version') + +@bp.route("/version") def version(): return VERSION -@bp.route('/stats') + +@bp.route("/stats") def stats(): stats = stats_snapshot(current_app.stats_tracking) return jsonify(stats) -@bp.route('//