mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-02 01:05:20 +03:00
Merge branch 'release-0.9.0' into fix/shared_memory_exception
This commit is contained in:
commit
0a5ece4b60
27
.devcontainer/devcontainer.json
Normal file
27
.devcontainer/devcontainer.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,3 +5,4 @@ debug
|
|||||||
config/
|
config/
|
||||||
*.pyc
|
*.pyc
|
||||||
.git
|
.git
|
||||||
|
core
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,5 +1,6 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.pyc
|
*.pyc
|
||||||
|
*.swp
|
||||||
debug
|
debug
|
||||||
.vscode
|
.vscode
|
||||||
config/config.yml
|
config/config.yml
|
||||||
@ -10,3 +11,4 @@ frigate/version.py
|
|||||||
web/build
|
web/build
|
||||||
web/node_modules
|
web/node_modules
|
||||||
web/coverage
|
web/coverage
|
||||||
|
core
|
||||||
|
|||||||
588
.pylintrc
Normal file
588
.pylintrc
Normal file
@ -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*(# )?<?https?://\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
|
||||||
13
Makefile
13
Makefile
@ -3,7 +3,7 @@ default_target: amd64_frigate
|
|||||||
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
|
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
|
||||||
|
|
||||||
version:
|
version:
|
||||||
echo "VERSION='0.8.4-$(COMMIT_HASH)'" > frigate/version.py
|
echo "VERSION='0.9.0-$(COMMIT_HASH)'" > frigate/version.py
|
||||||
|
|
||||||
web:
|
web:
|
||||||
docker build --tag frigate-web --file docker/Dockerfile.web web/
|
docker build --tag frigate-web --file docker/Dockerfile.web web/
|
||||||
@ -14,8 +14,11 @@ amd64_wheels:
|
|||||||
amd64_ffmpeg:
|
amd64_ffmpeg:
|
||||||
docker build --tag blakeblackshear/frigate-ffmpeg:1.1.0-amd64 --file docker/Dockerfile.ffmpeg.amd64 .
|
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
|
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 .
|
docker build --tag frigate --file docker/Dockerfile.amd64 .
|
||||||
|
|
||||||
amd64_all: amd64_wheels amd64_ffmpeg amd64_frigate
|
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 .
|
docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-amd64nvidia --file docker/Dockerfile.ffmpeg.amd64nvidia .
|
||||||
|
|
||||||
amd64nvidia_frigate: version web
|
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 .
|
docker build --tag frigate --file docker/Dockerfile.amd64nvidia .
|
||||||
|
|
||||||
amd64nvidia_all: amd64nvidia_wheels amd64nvidia_ffmpeg amd64nvidia_frigate
|
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 .
|
docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-aarch64 --file docker/Dockerfile.ffmpeg.aarch64 .
|
||||||
|
|
||||||
aarch64_frigate: version web
|
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 .
|
docker build --tag frigate --file docker/Dockerfile.aarch64 .
|
||||||
|
|
||||||
armv7_all: armv7_wheels armv7_ffmpeg armv7_frigate
|
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 .
|
docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-armv7 --file docker/Dockerfile.ffmpeg.armv7 .
|
||||||
|
|
||||||
armv7_frigate: version web
|
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 .
|
docker build --tag frigate --file docker/Dockerfile.armv7 .
|
||||||
|
|
||||||
armv7_all: armv7_wheels armv7_ffmpeg armv7_frigate
|
armv7_all: armv7_wheels armv7_ffmpeg armv7_frigate
|
||||||
|
|||||||
@ -4,11 +4,11 @@
|
|||||||
|
|
||||||
# Frigate - NVR With Realtime Object Detection for IP Cameras
|
# 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.
|
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
|
- 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
|
- 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
|
- 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).
|
If you would like to make a donation to support development, please use [Github Sponsors](https://github.com/sponsors/blakeblackshear).
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
Integration into HomeAssistant
|
Integration into Home Assistant
|
||||||
<div>
|
<div>
|
||||||
<a href="docs/static/img/media_browser.png"><img src="docs/static/img/media_browser.png" height=400></a>
|
<a href="docs/static/img/media_browser.png"><img src="docs/static/img/media_browser.png" height=400></a>
|
||||||
<a href="docs/static/img/notification.png"><img src="docs/static/img/notification.png" height=400></a>
|
<a href="docs/static/img/notification.png"><img src="docs/static/img/notification.png" height=400></a>
|
||||||
|
|||||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@ -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"
|
||||||
@ -1,8 +1,10 @@
|
|||||||
ARG ARCH=amd64
|
ARG ARCH=amd64
|
||||||
ARG WHEELS_VERSION
|
ARG WHEELS_VERSION
|
||||||
ARG FFMPEG_VERSION
|
ARG FFMPEG_VERSION
|
||||||
|
ARG NGINX_VERSION
|
||||||
FROM blakeblackshear/frigate-wheels:${WHEELS_VERSION}-${ARCH} as wheels
|
FROM blakeblackshear/frigate-wheels:${WHEELS_VERSION}-${ARCH} as wheels
|
||||||
FROM blakeblackshear/frigate-ffmpeg:${FFMPEG_VERSION}-${ARCH} as ffmpeg
|
FROM blakeblackshear/frigate-ffmpeg:${FFMPEG_VERSION}-${ARCH} as ffmpeg
|
||||||
|
FROM blakeblackshear/frigate-nginx:${NGINX_VERSION} as nginx
|
||||||
FROM frigate-web as web
|
FROM frigate-web as web
|
||||||
|
|
||||||
FROM ubuntu:20.04
|
FROM ubuntu:20.04
|
||||||
@ -18,16 +20,13 @@ ENV DEBIAN_FRONTEND=noninteractive
|
|||||||
# Install packages for apt repo
|
# Install packages for apt repo
|
||||||
RUN apt-get -qq update \
|
RUN apt-get -qq update \
|
||||||
&& apt-get upgrade -y \
|
&& apt-get upgrade -y \
|
||||||
&& apt-get -qq install --no-install-recommends -y \
|
&& apt-get -qq install --no-install-recommends -y gnupg wget unzip tzdata libxml2 \
|
||||||
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 \
|
|
||||||
python3-pip \
|
|
||||||
&& pip3 install -U /wheels/*.whl \
|
&& 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 \
|
&& 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 "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 \
|
&& echo "libedgetpu1-max libedgetpu/accepted-eula select true" | debconf-set-selections \
|
||||||
&& apt-get -qq update && apt-get -qq install --no-install-recommends -y \
|
&& apt-get -qq update && apt-get -qq install --no-install-recommends -y libedgetpu1-max=15.0 \
|
||||||
libedgetpu1-max=15.0 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* /wheels \
|
&& rm -rf /var/lib/apt/lists/* /wheels \
|
||||||
&& (apt-get autoremove -y; apt-get autoclean -y)
|
&& (apt-get autoremove -y; apt-get autoclean -y)
|
||||||
|
|
||||||
@ -39,7 +38,8 @@ RUN pip3 install \
|
|||||||
gevent \
|
gevent \
|
||||||
gevent-websocket
|
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
|
# get model and labels
|
||||||
COPY labelmap.txt /labelmap.txt
|
COPY labelmap.txt /labelmap.txt
|
||||||
|
|||||||
23
docker/Dockerfile.dev
Normal file
23
docker/Dockerfile.dev
Normal file
@ -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
|
||||||
46
docker/Dockerfile.nginx
Normal file
46
docker/Dockerfile.nginx
Normal file
@ -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;"]
|
||||||
@ -81,7 +81,7 @@ environment_vars:
|
|||||||
|
|
||||||
### `database`
|
### `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.
|
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
|
# Required: name of the detector
|
||||||
coral:
|
coral:
|
||||||
# Required: type of the detector
|
# 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
|
# Optional: device name as defined here: https://coral.ai/docs/edgetpu/multiple-edgetpu/#using-the-tensorflow-lite-python-api
|
||||||
device: usb
|
device: usb
|
||||||
# Optional: num_threads value passed to the tflite.Interpreter (default: shown below)
|
# Optional: num_threads value passed to the tflite.Interpreter (default: shown below)
|
||||||
|
|||||||
@ -62,7 +62,7 @@ Example of a finished row corresponding to the below example image:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
motion:
|
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"
|
||||||
```
|
```
|
||||||
|
|
||||||

|

|
||||||
@ -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.
|
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
|
:::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.
|
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
|
||||||
|
|
||||||
24/7 recordings can be enabled and are stored at `/media/frigate/recordings`. The folder structure for the recordings is `YYYY-MM/DD/HH/<camera_name>/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/<camera_name>/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
|
:::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.
|
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
|
## RTMP streams
|
||||||
|
|
||||||
Frigate can re-stream your video feed as a RTMP feed for other applications such as HomeAssistant to utilize it at `rtmp://<frigate_host>/live/<camera_name>`. 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://<frigate_host>/live/<camera_name>`. 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.
|
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
|
## 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
|
### RTMP Cameras
|
||||||
|
|
||||||
The input parameters need to be adjusted for RTMP cameras
|
The input parameters need to be adjusted for RTMP cameras
|
||||||
@ -406,7 +437,7 @@ ffmpeg:
|
|||||||
- -fflags
|
- -fflags
|
||||||
- +genpts+discardcorrupt
|
- +genpts+discardcorrupt
|
||||||
- -use_wallclock_as_timestamps
|
- -use_wallclock_as_timestamps
|
||||||
- '1'
|
- "1"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Reolink 410/520 (possibly others)
|
### Reolink 410/520 (possibly others)
|
||||||
@ -427,9 +458,9 @@ ffmpeg:
|
|||||||
- -fflags
|
- -fflags
|
||||||
- +genpts+discardcorrupt
|
- +genpts+discardcorrupt
|
||||||
- -rw_timeout
|
- -rw_timeout
|
||||||
- '5000000'
|
- "5000000"
|
||||||
- -use_wallclock_as_timestamps
|
- -use_wallclock_as_timestamps
|
||||||
- '1'
|
- "1"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Blue Iris RTSP Cameras
|
### Blue Iris RTSP Cameras
|
||||||
@ -450,7 +481,7 @@ ffmpeg:
|
|||||||
- -rtsp_transport
|
- -rtsp_transport
|
||||||
- tcp
|
- tcp
|
||||||
- -stimeout
|
- -stimeout
|
||||||
- '5000000'
|
- "5000000"
|
||||||
- -use_wallclock_as_timestamps
|
- -use_wallclock_as_timestamps
|
||||||
- '1'
|
- "1"
|
||||||
```
|
```
|
||||||
|
|||||||
@ -30,6 +30,18 @@ detectors:
|
|||||||
device: usb:1
|
device: usb:1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Multiple PCIE/M.2 Corals:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
detectors:
|
||||||
|
coral1:
|
||||||
|
type: edgetpu
|
||||||
|
device: pci:0
|
||||||
|
coral2:
|
||||||
|
type: edgetpu
|
||||||
|
device: pci:1
|
||||||
|
```
|
||||||
|
|
||||||
Mixing Corals:
|
Mixing Corals:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@ -3,7 +3,9 @@ id: index
|
|||||||
title: Configuration
|
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:
|
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 {}.
|
# NOTE: Environment variables that begin with 'FRIGATE_' may be referenced in {}.
|
||||||
# eg. password: '{FRIGATE_MQTT_PASSWORD}'
|
# eg. password: '{FRIGATE_MQTT_PASSWORD}'
|
||||||
password: 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)
|
# Optional: interval in seconds for publishing stats (default: shown below)
|
||||||
stats_interval: 60
|
stats_interval: 60
|
||||||
```
|
```
|
||||||
@ -78,11 +91,6 @@ clips:
|
|||||||
# NOTE: If an object is being tracked for longer than this amount of time, the cache
|
# 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.
|
# will begin to expire and the resulting clip will be the last x seconds of the event.
|
||||||
max_seconds: 300
|
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)
|
# Optional: Retention settings for clips (default: shown below)
|
||||||
retain:
|
retain:
|
||||||
# Required: Default retention days (default: shown below)
|
# 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)
|
# Optional: minimum decimal percentage for tracked object's computed score to be considered a true positive (default: shown below)
|
||||||
threshold: 0.7
|
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/<camera_name>/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
|
||||||
|
```
|
||||||
|
|||||||
@ -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
|
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:
|
ffmpeg:
|
||||||
|
|||||||
@ -3,7 +3,7 @@ id: optimizing
|
|||||||
title: Optimizing performance
|
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.
|
- **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.
|
- **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.
|
- **Hardware Acceleration**: Make sure you configure the `hwaccel_args` for your hardware. They provide a significant reduction in CPU usage if they are available.
|
||||||
|
|||||||
@ -36,6 +36,59 @@ Fork [blakeblackshear/frigate-hass-integration](https://github.com/blakeblackshe
|
|||||||
- [Frigate source code](#frigate-core-web-and-docs)
|
- [Frigate source code](#frigate-core-web-and-docs)
|
||||||
- GNU make
|
- GNU make
|
||||||
- Docker
|
- 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
|
## Web Interface
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ title: Recommended hardware
|
|||||||
|
|
||||||
## Cameras
|
## 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
|
## Computer
|
||||||
|
|
||||||
@ -25,5 +25,5 @@ Many people have powerful enough NAS devices or home servers to also run docker.
|
|||||||
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)
|
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 |
|
| 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) |
|
| [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) |
|
||||||
|
|||||||
@ -5,11 +5,11 @@ sidebar_label: Features
|
|||||||
slug: /
|
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.
|
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
|
- 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
|
- 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
|
- Uses a very low overhead motion detection to determine where to run object detection
|
||||||
|
|||||||
@ -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.
|
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.
|
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.
|
HassOS users can install via the addon repository. Frigate requires an MQTT server.
|
||||||
|
|
||||||
1. Navigate to Supervisor > Add-on Store > Repositories
|
1. Navigate to Supervisor > Add-on Store > Repositories
|
||||||
1. Add https://github.com/blakeblackshear/frigate-hass-addons
|
2. Add https://github.com/blakeblackshear/frigate-hass-addons
|
||||||
1. Setup your configuration in the `Configuration` tab
|
3. Setup your network configuration in the `Configuration` tab if deisred
|
||||||
1. Start the addon container
|
4. Create the file `frigate.yml` in your `config` directory with your detailed Frigate configuration
|
||||||
1. If you are using hardware acceleration for ffmpeg, you will need to disable "Protection mode"
|
5. Start the addon container
|
||||||
|
6. If you are using hardware acceleration for ffmpeg, you will need to disable "Protection mode"
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
Make sure you choose the right image for your architecture:
|
Make sure you choose the right image for your architecture:
|
||||||
|
|
||||||
|Arch|Image Name|
|
| Arch | Image Name |
|
||||||
|-|-|
|
| ----------- | ------------------------------------------ |
|
||||||
|amd64|blakeblackshear/frigate:stable-amd64|
|
| amd64 | blakeblackshear/frigate:stable-amd64 |
|
||||||
|amd64nvidia|blakeblackshear/frigate:stable-amd64nvidia|
|
| amd64nvidia | blakeblackshear/frigate:stable-amd64nvidia |
|
||||||
|armv7|blakeblackshear/frigate:stable-armv7|
|
| armv7 | blakeblackshear/frigate:stable-armv7 |
|
||||||
|aarch64|blakeblackshear/frigate:stable-aarch64|
|
| aarch64 | blakeblackshear/frigate:stable-aarch64 |
|
||||||
|
|
||||||
It is recommended to run with docker-compose:
|
It is recommended to run with docker-compose:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: '3.9'
|
version: "3.9"
|
||||||
services:
|
services:
|
||||||
frigate:
|
frigate:
|
||||||
container_name: frigate
|
container_name: frigate
|
||||||
@ -52,10 +53,10 @@ services:
|
|||||||
tmpfs:
|
tmpfs:
|
||||||
size: 1000000000
|
size: 1000000000
|
||||||
ports:
|
ports:
|
||||||
- '5000:5000'
|
- "5000:5000"
|
||||||
- '1935:1935' # RTMP feeds
|
- "1935:1935" # RTMP feeds
|
||||||
environment:
|
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:
|
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 \
|
--restart=unless-stopped \
|
||||||
--mount type=tmpfs,target=/tmp/cache,tmpfs-size=1000000000 \
|
--mount type=tmpfs,target=/tmp/cache,tmpfs-size=1000000000 \
|
||||||
--device /dev/bus/usb:/dev/bus/usb \
|
--device /dev/bus/usb:/dev/bus/usb \
|
||||||
--device /dev/dri/renderD128
|
--device /dev/dri/renderD128 \
|
||||||
-v <path_to_directory_for_media>:/media/frigate \
|
-v <path_to_directory_for_media>:/media/frigate \
|
||||||
-v <path_to_config_file>:/config/config.yml:ro \
|
-v <path_to_config_file>:/config/config.yml:ro \
|
||||||
-v /etc/localtime:/etc/localtime: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 = <shm size in mb>
|
(width * height * 1.5 * 7 + 270480)/1048576 = <shm size in mb>
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## Kubernetes
|
||||||
|
|
||||||
@ -119,5 +120,5 @@ lxc.cap.drop:
|
|||||||
```
|
```
|
||||||
|
|
||||||
### ESX
|
### 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).
|
||||||
|
|||||||
@ -5,7 +5,7 @@ title: HTTP API
|
|||||||
|
|
||||||
A web server is available on port 5000 with the following endpoints.
|
A web server is available on port 5000 with the following endpoints.
|
||||||
|
|
||||||
### `/api/<camera_name>`
|
### `GET /api/<camera_name>`
|
||||||
|
|
||||||
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.
|
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`.
|
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/<camera_name>/<object_name>/best.jpg[?h=300&crop=1]`
|
### `GET /api/<camera_name>/<object_name>/best.jpg[?h=300&crop=1]`
|
||||||
|
|
||||||
The best snapshot for any object type. It is a full resolution image by default.
|
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
|
- `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
|
- `crop=1`: crops the image to the region of the detection rather than returning the entire image
|
||||||
|
|
||||||
### `/api/<camera_name>/latest.jpg[?h=300]`
|
### `GET /api/<camera_name>/latest.jpg[?h=300]`
|
||||||
|
|
||||||
The most recent frame that frigate has finished processing. It is a full resolution image by default.
|
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
|
- `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:
|
Sample response:
|
||||||
|
|
||||||
@ -125,40 +125,40 @@ Sample response:
|
|||||||
"total": 1000,
|
"total": 1000,
|
||||||
"used": 700,
|
"used": 700,
|
||||||
"free": 300,
|
"free": 300,
|
||||||
"mnt_type": "ext4",
|
"mnt_type": "ext4"
|
||||||
},
|
},
|
||||||
"/media/frigate/recordings": {
|
"/media/frigate/recordings": {
|
||||||
"total": 1000,
|
"total": 1000,
|
||||||
"used": 700,
|
"used": 700,
|
||||||
"free": 300,
|
"free": 300,
|
||||||
"mnt_type": "ext4",
|
"mnt_type": "ext4"
|
||||||
},
|
},
|
||||||
"/tmp/cache": {
|
"/tmp/cache": {
|
||||||
"total": 256,
|
"total": 256,
|
||||||
"used": 100,
|
"used": 100,
|
||||||
"free": 156,
|
"free": 156,
|
||||||
"mnt_type": "tmpfs",
|
"mnt_type": "tmpfs"
|
||||||
},
|
},
|
||||||
"/dev/shm": {
|
"/dev/shm": {
|
||||||
"total": 256,
|
"total": 256,
|
||||||
"used": 100,
|
"used": 100,
|
||||||
"free": 156,
|
"free": 156,
|
||||||
"mnt_type": "tmpfs",
|
"mnt_type": "tmpfs"
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `/api/config`
|
### `GET /api/config`
|
||||||
|
|
||||||
A json representation of your configuration
|
A json representation of your configuration
|
||||||
|
|
||||||
### `/api/version`
|
### `GET /api/version`
|
||||||
|
|
||||||
Version info
|
Version info
|
||||||
|
|
||||||
### `/api/events`
|
### `GET /api/events`
|
||||||
|
|
||||||
Events from the database. Accepts the following query string parameters:
|
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) |
|
| `has_clip` | int | Filter to events that have clips (0 or 1) |
|
||||||
| `include_thumbnails` | int | Include thumbnails in the response (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/<id>`
|
### `GET /api/events/<id>`
|
||||||
|
|
||||||
Returns data for a single event.
|
Returns data for a single event.
|
||||||
|
|
||||||
### `/api/events/<id>/thumbnail.jpg`
|
### `DELETE /api/events/<id>`
|
||||||
|
|
||||||
|
Permanently deletes the event along with any clips/snapshots.
|
||||||
|
|
||||||
|
### `GET /api/events/<id>/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.
|
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/<id>/snapshot.jpg`
|
### `GET /api/events/<id>/snapshot.jpg`
|
||||||
|
|
||||||
Returns the snapshot image for the event id. Works while the event is in progress and after completion.
|
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/<camera>-<id>.jpg`
|
### `/clips/<camera>-<id>.jpg`
|
||||||
|
|
||||||
JPG snapshot for the given camera and event id.
|
JPG snapshot for the given camera and event id.
|
||||||
|
|
||||||
|
### `/vod/<year>-<month>/<day>/<hour>/<camera>/master.m3u8`
|
||||||
|
|
||||||
|
HTTP Live Streaming Video on Demand URL for the specified hour and camera. Can be viewed in an application like VLC.
|
||||||
|
|||||||
@ -4,7 +4,7 @@ title: Integration with Home Assistant
|
|||||||
sidebar_label: 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://<host>: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://<host>: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:
|
## Sensors:
|
||||||
|
|
||||||
@ -30,12 +30,14 @@ 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).
|
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):
|
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/<event-id>/thumbnail.jpg
|
https://HA_URL/api/frigate/notifications/<event-id>/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/<event-id>/<camera>/clip.mp4
|
https://HA_URL/api/frigate/notifications/<event-id>/<camera>/clip.mp4
|
||||||
```
|
```
|
||||||
@ -57,7 +59,6 @@ automation:
|
|||||||
tag: '{{trigger.payload_json["after"]["id"]}}'
|
tag: '{{trigger.payload_json["after"]["id"]}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
automation:
|
||||||
- alias: When a person enters a zone named yard
|
- alias: When a person enters a zone named yard
|
||||||
@ -106,7 +107,7 @@ automation:
|
|||||||
action:
|
action:
|
||||||
- service: notify.mobile_app_pixel_3
|
- service: notify.mobile_app_pixel_3
|
||||||
data_template:
|
data_template:
|
||||||
message: 'High confidence dog detection.'
|
message: "High confidence dog detection."
|
||||||
data:
|
data:
|
||||||
image: "https://url.com/api/frigate/notifications/{{trigger.payload_json['after']['id']}}/thumbnail.jpg"
|
image: "https://url.com/api/frigate/notifications/{{trigger.payload_json['after']['id']}}/thumbnail.jpg"
|
||||||
tag: "{{trigger.payload_json['after']['id']}}"
|
tag: "{{trigger.payload_json['after']['id']}}"
|
||||||
|
|||||||
11
docs/docs/usage/howtos.md
Normal file
11
docs/docs/usage/howtos.md
Normal file
@ -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/)
|
||||||
@ -7,17 +7,17 @@ These are the MQTT messages generated by Frigate. The default topic_prefix is `f
|
|||||||
|
|
||||||
### `frigate/available`
|
### `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)
|
"online": published when frigate is running (on startup)
|
||||||
"offline": published right before frigate stops
|
"offline": published right before frigate stops
|
||||||
|
|
||||||
### `frigate/<camera_name>/<object_name>`
|
### `frigate/<camera_name>/<object_name>`
|
||||||
|
|
||||||
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/<zone_name>/<object_name>`
|
### `frigate/<zone_name>/<object_name>`
|
||||||
|
|
||||||
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/<camera_name>/<object_name>/snapshot`
|
### `frigate/<camera_name>/<object_name>/snapshot`
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import faulthandler; faulthandler.enable()
|
import faulthandler
|
||||||
|
|
||||||
|
faulthandler.enable()
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
@ -6,10 +8,10 @@ threading.current_thread().name = "frigate"
|
|||||||
|
|
||||||
from frigate.app import FrigateApp
|
from frigate.app import FrigateApp
|
||||||
|
|
||||||
cli = sys.modules['flask.cli']
|
cli = sys.modules["flask.cli"]
|
||||||
cli.show_server_banner = lambda *x: None
|
cli.show_server_banner = lambda *x: None
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
frigate_app = FrigateApp()
|
frigate_app = FrigateApp()
|
||||||
|
|
||||||
frigate_app.start()
|
frigate_app.start()
|
||||||
|
|||||||
195
frigate/app.py
195
frigate/app.py
@ -31,7 +31,8 @@ from frigate.zeroconf import broadcast_zeroconf
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class FrigateApp():
|
|
||||||
|
class FrigateApp:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.stop_event = mp.Event()
|
self.stop_event = mp.Event()
|
||||||
self.config: FrigateConfig = None
|
self.config: FrigateConfig = None
|
||||||
@ -54,62 +55,73 @@ class FrigateApp():
|
|||||||
else:
|
else:
|
||||||
logger.debug(f"Skipping directory: {d}")
|
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):
|
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.daemon = True
|
||||||
self.log_process.start()
|
self.log_process.start()
|
||||||
root_configurer(self.log_queue)
|
root_configurer(self.log_queue)
|
||||||
|
|
||||||
def init_config(self):
|
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)
|
self.config = FrigateConfig(config_file=config_file)
|
||||||
|
|
||||||
for camera_name in self.config.cameras.keys():
|
for camera_name in self.config.cameras.keys():
|
||||||
# create camera_metrics
|
# create camera_metrics
|
||||||
self.camera_metrics[camera_name] = {
|
self.camera_metrics[camera_name] = {
|
||||||
'camera_fps': mp.Value('d', 0.0),
|
"camera_fps": mp.Value("d", 0.0),
|
||||||
'skipped_fps': mp.Value('d', 0.0),
|
"skipped_fps": mp.Value("d", 0.0),
|
||||||
'process_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_enabled": mp.Value(
|
||||||
'detection_fps': mp.Value('d', 0.0),
|
"i", self.config.cameras[camera_name].detect.enabled
|
||||||
'detection_frame': mp.Value('d', 0.0),
|
),
|
||||||
'read_start': mp.Value('d', 0.0),
|
"detection_fps": mp.Value("d", 0.0),
|
||||||
'ffmpeg_pid': mp.Value('i', 0),
|
"detection_frame": mp.Value("d", 0.0),
|
||||||
'frame_queue': mp.Queue(maxsize=2),
|
"read_start": mp.Value("d", 0.0),
|
||||||
|
"ffmpeg_pid": mp.Value("i", 0),
|
||||||
|
"frame_queue": mp.Queue(maxsize=2),
|
||||||
}
|
}
|
||||||
|
|
||||||
def check_config(self):
|
def check_config(self):
|
||||||
for name, camera in self.config.cameras.items():
|
for name, camera in self.config.cameras.items():
|
||||||
assigned_roles = list(set([r for i in camera.ffmpeg.inputs for r in i.roles]))
|
assigned_roles = list(
|
||||||
if not camera.clips.enabled and 'clips' in assigned_roles:
|
set([r for i in camera.ffmpeg.inputs for r in i.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:
|
if not camera.clips.enabled and "clips" in assigned_roles:
|
||||||
logger.warning(f"Camera {name} has clips enabled, but clips is not assigned to an input.")
|
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:
|
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.")
|
logger.warning(
|
||||||
elif camera.record.enabled and not 'record' in assigned_roles:
|
f"Camera {name} has record assigned to an input, but record is not enabled."
|
||||||
logger.warning(f"Camera {name} has record enabled, but record is not assigned to an input.")
|
)
|
||||||
|
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:
|
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.")
|
logger.warning(
|
||||||
elif camera.rtmp.enabled and not 'rtmp' in assigned_roles:
|
f"Camera {name} has rtmp assigned to an input, but rtmp is not enabled."
|
||||||
logger.warning(f"Camera {name} has rtmp enabled, but rtmp is not assigned to an input.")
|
)
|
||||||
|
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):
|
def set_log_levels(self):
|
||||||
logging.getLogger().setLevel(self.config.logger.default)
|
logging.getLogger().setLevel(self.config.logger.default)
|
||||||
for log, level in self.config.logger.logs.items():
|
for log, level in self.config.logger.logs.items():
|
||||||
logging.getLogger(log).setLevel(level)
|
logging.getLogger(log).setLevel(level)
|
||||||
|
|
||||||
if not 'geventwebsocket.handler' in self.config.logger.logs:
|
if not "geventwebsocket.handler" in self.config.logger.logs:
|
||||||
logging.getLogger('geventwebsocket.handler').setLevel('ERROR')
|
logging.getLogger("geventwebsocket.handler").setLevel("ERROR")
|
||||||
|
|
||||||
def init_queues(self):
|
def init_queues(self):
|
||||||
# Queues for clip processing
|
# Queues for clip processing
|
||||||
@ -117,13 +129,15 @@ class FrigateApp():
|
|||||||
self.event_processed_queue = mp.Queue()
|
self.event_processed_queue = mp.Queue()
|
||||||
|
|
||||||
# Queue for cameras to push tracked objects to
|
# 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):
|
def init_database(self):
|
||||||
migrate_db = SqliteExtDatabase(self.config.database.path)
|
migrate_db = SqliteExtDatabase(self.config.database.path)
|
||||||
|
|
||||||
# Run migrations
|
# Run migrations
|
||||||
del(logging.getLogger('peewee_migrate').handlers[:])
|
del logging.getLogger("peewee_migrate").handlers[:]
|
||||||
router = Router(migrate_db)
|
router = Router(migrate_db)
|
||||||
router.run()
|
router.run()
|
||||||
|
|
||||||
@ -137,7 +151,13 @@ class FrigateApp():
|
|||||||
self.stats_tracking = stats_init(self.camera_metrics, self.detectors)
|
self.stats_tracking = stats_init(self.camera_metrics, self.detectors)
|
||||||
|
|
||||||
def init_web_server(self):
|
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):
|
def init_mqtt(self):
|
||||||
self.mqtt_client = create_mqtt_client(self.config, self.camera_metrics)
|
self.mqtt_client = create_mqtt_client(self.config, self.camera_metrics)
|
||||||
@ -148,48 +168,97 @@ class FrigateApp():
|
|||||||
self.detection_out_events[name] = mp.Event()
|
self.detection_out_events[name] = mp.Event()
|
||||||
|
|
||||||
try:
|
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:
|
except FileExistsError:
|
||||||
self.detection_shms.append(mp.shared_memory.SharedMemory(name=name))
|
shm_in = mp.shared_memory.SharedMemory(name=name)
|
||||||
|
|
||||||
try:
|
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:
|
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():
|
for name, detector in self.config.detectors.items():
|
||||||
if detector.type == 'cpu':
|
if detector.type == "cpu":
|
||||||
self.detectors[name] = EdgeTPUProcess(name, self.detection_queue, self.detection_out_events, model_shape, 'cpu', detector.num_threads)
|
self.detectors[name] = EdgeTPUProcess(
|
||||||
if detector.type == 'edgetpu':
|
name,
|
||||||
self.detectors[name] = EdgeTPUProcess(name, self.detection_queue, self.detection_out_events, model_shape, detector.device, detector.num_threads)
|
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):
|
def start_detected_frames_processor(self):
|
||||||
self.detected_frames_processor = TrackedObjectProcessor(self.config, self.mqtt_client, self.config.mqtt.topic_prefix,
|
self.detected_frames_processor = TrackedObjectProcessor(
|
||||||
self.detected_frames_queue, self.event_queue, self.event_processed_queue, self.stop_event)
|
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()
|
self.detected_frames_processor.start()
|
||||||
|
|
||||||
def start_camera_processors(self):
|
def start_camera_processors(self):
|
||||||
model_shape = (self.config.model.height, self.config.model.width)
|
model_shape = (self.config.model.height, self.config.model.width)
|
||||||
for name, config in self.config.cameras.items():
|
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,
|
camera_process = mp.Process(
|
||||||
self.detection_queue, self.detection_out_events[name], self.detected_frames_queue,
|
target=track_camera,
|
||||||
self.camera_metrics[name]))
|
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
|
camera_process.daemon = True
|
||||||
self.camera_metrics[name]['process'] = camera_process
|
self.camera_metrics[name]["process"] = camera_process
|
||||||
camera_process.start()
|
camera_process.start()
|
||||||
logger.info(f"Camera processor started for {name}: {camera_process.pid}")
|
logger.info(f"Camera processor started for {name}: {camera_process.pid}")
|
||||||
|
|
||||||
def start_camera_capture_processes(self):
|
def start_camera_capture_processes(self):
|
||||||
for name, config in self.config.cameras.items():
|
for name, config in self.config.cameras.items():
|
||||||
capture_process = mp.Process(target=capture_camera, name=f"camera_capture:{name}", args=(name, config,
|
capture_process = mp.Process(
|
||||||
self.camera_metrics[name]))
|
target=capture_camera,
|
||||||
|
name=f"camera_capture:{name}",
|
||||||
|
args=(name, config, self.camera_metrics[name]),
|
||||||
|
)
|
||||||
capture_process.daemon = True
|
capture_process.daemon = True
|
||||||
self.camera_metrics[name]['capture_process'] = capture_process
|
self.camera_metrics[name]["capture_process"] = capture_process
|
||||||
capture_process.start()
|
capture_process.start()
|
||||||
logger.info(f"Capture process started for {name}: {capture_process.pid}")
|
logger.info(f"Capture process started for {name}: {capture_process.pid}")
|
||||||
|
|
||||||
def start_event_processor(self):
|
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()
|
self.event_processor.start()
|
||||||
|
|
||||||
def start_event_cleanup(self):
|
def start_event_cleanup(self):
|
||||||
@ -201,7 +270,13 @@ class FrigateApp():
|
|||||||
self.recording_maintainer.start()
|
self.recording_maintainer.start()
|
||||||
|
|
||||||
def start_stats_emitter(self):
|
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()
|
self.stats_emitter.start()
|
||||||
|
|
||||||
def start_watchdog(self):
|
def start_watchdog(self):
|
||||||
@ -247,8 +322,14 @@ class FrigateApp():
|
|||||||
|
|
||||||
signal.signal(signal.SIGTERM, receiveSignal)
|
signal.signal(signal.SIGTERM, receiveSignal)
|
||||||
|
|
||||||
server = pywsgi.WSGIServer(('127.0.0.1', 5001), self.flask_app, handler_class=WebSocketHandler)
|
server = pywsgi.WSGIServer(
|
||||||
|
("127.0.0.1", 5001), self.flask_app, handler_class=WebSocketHandler
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
|
|||||||
1809
frigate/config.py
1809
frigate/config.py
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,3 @@
|
|||||||
CLIPS_DIR = '/media/frigate/clips'
|
CLIPS_DIR = "/media/frigate/clips"
|
||||||
RECORD_DIR = '/media/frigate/recordings'
|
RECORD_DIR = "/media/frigate/recordings"
|
||||||
CACHE_DIR = '/tmp/cache'
|
CACHE_DIR = "/tmp/cache"
|
||||||
|
|||||||
@ -1,25 +1,24 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing as mp
|
import multiprocessing as mp
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import threading
|
|
||||||
import signal
|
import signal
|
||||||
|
import threading
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from multiprocessing.connection import Connection
|
|
||||||
from setproctitle import setproctitle
|
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import tflite_runtime.interpreter as tflite
|
import tflite_runtime.interpreter as tflite
|
||||||
|
from setproctitle import setproctitle
|
||||||
from tflite_runtime.interpreter import load_delegate
|
from tflite_runtime.interpreter import load_delegate
|
||||||
|
|
||||||
from frigate.util import EventsPerSecond, SharedMemoryFrameManager, listen
|
from frigate.util import EventsPerSecond, SharedMemoryFrameManager, listen
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def load_labels(path, encoding='utf-8'):
|
|
||||||
|
def load_labels(path, encoding="utf-8"):
|
||||||
"""Loads labels from file (with or without index numbers).
|
"""Loads labels from file (with or without index numbers).
|
||||||
Args:
|
Args:
|
||||||
path: path to label file.
|
path: path to label file.
|
||||||
@ -27,22 +26,24 @@ def load_labels(path, encoding='utf-8'):
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping indices to labels.
|
Dictionary mapping indices to labels.
|
||||||
"""
|
"""
|
||||||
with open(path, 'r', encoding=encoding) as f:
|
with open(path, "r", encoding=encoding) as f:
|
||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
if not lines:
|
if not lines:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
if lines[0].split(' ', maxsplit=1)[0].isdigit():
|
if lines[0].split(" ", maxsplit=1)[0].isdigit():
|
||||||
pairs = [line.split(' ', maxsplit=1) for line in lines]
|
pairs = [line.split(" ", maxsplit=1) for line in lines]
|
||||||
return {int(index): label.strip() for index, label in pairs}
|
return {int(index): label.strip() for index, label in pairs}
|
||||||
else:
|
else:
|
||||||
return {index: line.strip() for index, line in enumerate(lines)}
|
return {index: line.strip() for index, line in enumerate(lines)}
|
||||||
|
|
||||||
|
|
||||||
class ObjectDetector(ABC):
|
class ObjectDetector(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def detect(self, tensor_input, threshold = .4):
|
def detect(self, tensor_input, threshold=0.4):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class LocalObjectDetector(ObjectDetector):
|
class LocalObjectDetector(ObjectDetector):
|
||||||
def __init__(self, tf_device=None, num_threads=3, labels=None):
|
def __init__(self, tf_device=None, num_threads=3, labels=None):
|
||||||
self.fps = EventsPerSecond()
|
self.fps = EventsPerSecond()
|
||||||
@ -57,27 +58,29 @@ class LocalObjectDetector(ObjectDetector):
|
|||||||
|
|
||||||
edge_tpu_delegate = None
|
edge_tpu_delegate = None
|
||||||
|
|
||||||
if tf_device != 'cpu':
|
if tf_device != "cpu":
|
||||||
try:
|
try:
|
||||||
logger.info(f"Attempting to load TPU as {device_config['device']}")
|
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")
|
logger.info("TPU found")
|
||||||
self.interpreter = tflite.Interpreter(
|
self.interpreter = tflite.Interpreter(
|
||||||
model_path='/edgetpu_model.tflite',
|
model_path="/edgetpu_model.tflite",
|
||||||
experimental_delegates=[edge_tpu_delegate])
|
experimental_delegates=[edge_tpu_delegate],
|
||||||
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.info("No EdgeTPU detected.")
|
logger.info("No EdgeTPU detected.")
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
self.interpreter = tflite.Interpreter(
|
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.interpreter.allocate_tensors()
|
||||||
|
|
||||||
self.tensor_input_details = self.interpreter.get_input_details()
|
self.tensor_input_details = self.interpreter.get_input_details()
|
||||||
self.tensor_output_details = self.interpreter.get_output_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 = []
|
detections = []
|
||||||
|
|
||||||
raw_detections = self.detect_raw(tensor_input)
|
raw_detections = self.detect_raw(tensor_input)
|
||||||
@ -85,28 +88,49 @@ class LocalObjectDetector(ObjectDetector):
|
|||||||
for d in raw_detections:
|
for d in raw_detections:
|
||||||
if d[1] < threshold:
|
if d[1] < threshold:
|
||||||
break
|
break
|
||||||
detections.append((
|
detections.append(
|
||||||
self.labels[int(d[0])],
|
(self.labels[int(d[0])], float(d[1]), (d[2], d[3], d[4], d[5]))
|
||||||
float(d[1]),
|
)
|
||||||
(d[2], d[3], d[4], d[5])
|
|
||||||
))
|
|
||||||
self.fps.update()
|
self.fps.update()
|
||||||
return detections
|
return detections
|
||||||
|
|
||||||
def detect_raw(self, tensor_input):
|
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()
|
self.interpreter.invoke()
|
||||||
boxes = np.squeeze(self.interpreter.get_tensor(self.tensor_output_details[0]['index']))
|
boxes = np.squeeze(
|
||||||
label_codes = np.squeeze(self.interpreter.get_tensor(self.tensor_output_details[1]['index']))
|
self.interpreter.get_tensor(self.tensor_output_details[0]["index"])
|
||||||
scores = np.squeeze(self.interpreter.get_tensor(self.tensor_output_details[2]['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):
|
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
|
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}"
|
threading.current_thread().name = f"detector:{name}"
|
||||||
logger = logging.getLogger(f"detector.{name}")
|
logger = logging.getLogger(f"detector.{name}")
|
||||||
logger.info(f"Starting detection process: {os.getpid()}")
|
logger.info(f"Starting detection process: {os.getpid()}")
|
||||||
@ -114,6 +138,7 @@ def run_detector(name: str, detection_queue: mp.Queue, out_events: Dict[str, mp.
|
|||||||
listen()
|
listen()
|
||||||
|
|
||||||
stop_event = mp.Event()
|
stop_event = mp.Event()
|
||||||
|
|
||||||
def receiveSignal(signalNumber, frame):
|
def receiveSignal(signalNumber, frame):
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
|
|
||||||
@ -126,21 +151,17 @@ def run_detector(name: str, detection_queue: mp.Queue, out_events: Dict[str, mp.
|
|||||||
outputs = {}
|
outputs = {}
|
||||||
for name in out_events.keys():
|
for name in out_events.keys():
|
||||||
out_shm = mp.shared_memory.SharedMemory(name=f"out-{name}", create=False)
|
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)
|
out_np = np.ndarray((20, 6), dtype=np.float32, buffer=out_shm.buf)
|
||||||
outputs[name] = {
|
outputs[name] = {"shm": out_shm, "np": out_np}
|
||||||
'shm': out_shm,
|
|
||||||
'np': out_np
|
|
||||||
}
|
|
||||||
|
|
||||||
while True:
|
|
||||||
if stop_event.is_set():
|
|
||||||
break
|
|
||||||
|
|
||||||
|
while not stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
connection_id = detection_queue.get(timeout=5)
|
connection_id = detection_queue.get(timeout=5)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
continue
|
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:
|
if input_frame is None:
|
||||||
continue
|
continue
|
||||||
@ -148,20 +169,29 @@ def run_detector(name: str, detection_queue: mp.Queue, out_events: Dict[str, mp.
|
|||||||
# detect and send the output
|
# detect and send the output
|
||||||
start.value = datetime.datetime.now().timestamp()
|
start.value = datetime.datetime.now().timestamp()
|
||||||
detections = object_detector.detect_raw(input_frame)
|
detections = object_detector.detect_raw(input_frame)
|
||||||
duration = datetime.datetime.now().timestamp()-start.value
|
duration = datetime.datetime.now().timestamp() - start.value
|
||||||
outputs[connection_id]['np'][:] = detections[:]
|
outputs[connection_id]["np"][:] = detections[:]
|
||||||
out_events[connection_id].set()
|
out_events[connection_id].set()
|
||||||
start.value = 0.0
|
start.value = 0.0
|
||||||
|
|
||||||
avg_speed.value = (avg_speed.value*9 + duration)/10
|
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):
|
class EdgeTPUProcess:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name,
|
||||||
|
detection_queue,
|
||||||
|
out_events,
|
||||||
|
model_shape,
|
||||||
|
tf_device=None,
|
||||||
|
num_threads=3,
|
||||||
|
):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.out_events = out_events
|
self.out_events = out_events
|
||||||
self.detection_queue = detection_queue
|
self.detection_queue = detection_queue
|
||||||
self.avg_inference_speed = mp.Value('d', 0.01)
|
self.avg_inference_speed = mp.Value("d", 0.01)
|
||||||
self.detection_start = mp.Value('d', 0.0)
|
self.detection_start = mp.Value("d", 0.0)
|
||||||
self.detect_process = None
|
self.detect_process = None
|
||||||
self.model_shape = model_shape
|
self.model_shape = model_shape
|
||||||
self.tf_device = tf_device
|
self.tf_device = tf_device
|
||||||
@ -181,11 +211,25 @@ class EdgeTPUProcess():
|
|||||||
self.detection_start.value = 0.0
|
self.detection_start.value = 0.0
|
||||||
if (not self.detect_process is None) and self.detect_process.is_alive():
|
if (not self.detect_process is None) and self.detect_process.is_alive():
|
||||||
self.stop()
|
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.daemon = True
|
||||||
self.detect_process.start()
|
self.detect_process.start()
|
||||||
|
|
||||||
class RemoteObjectDetector():
|
|
||||||
|
class RemoteObjectDetector:
|
||||||
def __init__(self, name, labels, detection_queue, event, model_shape):
|
def __init__(self, name, labels, detection_queue, event, model_shape):
|
||||||
self.labels = load_labels(labels)
|
self.labels = load_labels(labels)
|
||||||
self.name = name
|
self.name = name
|
||||||
@ -193,11 +237,15 @@ class RemoteObjectDetector():
|
|||||||
self.detection_queue = detection_queue
|
self.detection_queue = detection_queue
|
||||||
self.event = event
|
self.event = event
|
||||||
self.shm = mp.shared_memory.SharedMemory(name=self.name, create=False)
|
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.np_shm = np.ndarray(
|
||||||
self.out_shm = mp.shared_memory.SharedMemory(name=f"out-{self.name}", create=False)
|
(1, model_shape[0], model_shape[1], 3), dtype=np.uint8, buffer=self.shm.buf
|
||||||
self.out_np_shm = np.ndarray((20,6), dtype=np.float32, buffer=self.out_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):
|
def detect(self, tensor_input, threshold=0.4):
|
||||||
detections = []
|
detections = []
|
||||||
|
|
||||||
# copy input to shared memory
|
# copy input to shared memory
|
||||||
@ -213,11 +261,9 @@ class RemoteObjectDetector():
|
|||||||
for d in self.out_np_shm:
|
for d in self.out_np_shm:
|
||||||
if d[1] < threshold:
|
if d[1] < threshold:
|
||||||
break
|
break
|
||||||
detections.append((
|
detections.append(
|
||||||
self.labels[int(d[0])],
|
(self.labels[int(d[0])], float(d[1]), (d[2], d[3], d[4], d[5]))
|
||||||
float(d[1]),
|
)
|
||||||
(d[2], d[3], d[4], d[5])
|
|
||||||
))
|
|
||||||
self.fps.update()
|
self.fps.update()
|
||||||
return detections
|
return detections
|
||||||
|
|
||||||
|
|||||||
@ -20,10 +20,13 @@ from peewee import fn
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class EventProcessor(threading.Thread):
|
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)
|
threading.Thread.__init__(self)
|
||||||
self.name = 'event_processor'
|
self.name = "event_processor"
|
||||||
self.config = config
|
self.config = config
|
||||||
self.camera_processes = camera_processes
|
self.camera_processes = camera_processes
|
||||||
self.cached_clips = {}
|
self.cached_clips = {}
|
||||||
@ -33,13 +36,17 @@ class EventProcessor(threading.Thread):
|
|||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
|
|
||||||
def should_create_clip(self, camera, event_data):
|
def should_create_clip(self, camera, event_data):
|
||||||
if event_data['false_positive']:
|
if event_data["false_positive"]:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# if there are required zones and there is no overlap
|
# if there are required zones and there is no overlap
|
||||||
required_zones = self.config.cameras[camera].clips.required_zones
|
required_zones = self.config.cameras[camera].clips.required_zones
|
||||||
if len(required_zones) > 0 and not set(event_data['entered_zones']) & set(required_zones):
|
if len(required_zones) > 0 and not set(event_data["entered_zones"]) & set(
|
||||||
logger.debug(f"Not creating clip for {event_data['id']} because it did not enter required zones")
|
required_zones
|
||||||
|
):
|
||||||
|
logger.debug(
|
||||||
|
f"Not creating clip for {event_data['id']} because it did not enter required zones"
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@ -50,14 +57,14 @@ class EventProcessor(threading.Thread):
|
|||||||
files_in_use = []
|
files_in_use = []
|
||||||
for process in psutil.process_iter():
|
for process in psutil.process_iter():
|
||||||
try:
|
try:
|
||||||
if process.name() != 'ffmpeg':
|
if process.name() != "ffmpeg":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
flist = process.open_files()
|
flist = process.open_files()
|
||||||
if flist:
|
if flist:
|
||||||
for nt in flist:
|
for nt in flist:
|
||||||
if nt.path.startswith(CACHE_DIR):
|
if nt.path.startswith(CACHE_DIR):
|
||||||
files_in_use.append(nt.path.split('/')[-1])
|
files_in_use.append(nt.path.split("/")[-1])
|
||||||
except:
|
except:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -65,130 +72,158 @@ class EventProcessor(threading.Thread):
|
|||||||
if f in files_in_use or f in self.cached_clips:
|
if f in files_in_use or f in self.cached_clips:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
camera = '-'.join(f.split('-')[:-1])
|
basename = os.path.splitext(f)[0]
|
||||||
start_time = datetime.datetime.strptime(f.split('-')[-1].split('.')[0], '%Y%m%d%H%M%S')
|
camera, date = basename.rsplit("-", maxsplit=1)
|
||||||
|
start_time = datetime.datetime.strptime(date, "%Y%m%d%H%M%S")
|
||||||
|
|
||||||
ffprobe_cmd = " ".join([
|
ffprobe_cmd = [
|
||||||
'ffprobe',
|
"ffprobe",
|
||||||
'-v',
|
"-v",
|
||||||
'error',
|
"error",
|
||||||
'-show_entries',
|
"-show_entries",
|
||||||
'format=duration',
|
"format=duration",
|
||||||
'-of',
|
"-of",
|
||||||
'default=noprint_wrappers=1:nokey=1',
|
"default=noprint_wrappers=1:nokey=1",
|
||||||
f"{os.path.join(CACHE_DIR,f)}"
|
f"{os.path.join(CACHE_DIR, f)}",
|
||||||
])
|
]
|
||||||
p = sp.Popen(ffprobe_cmd, stdout=sp.PIPE, shell=True)
|
p = sp.run(ffprobe_cmd, capture_output=True)
|
||||||
(output, err) = p.communicate()
|
if p.returncode == 0:
|
||||||
p_status = p.wait()
|
duration = float(p.stdout.decode().strip())
|
||||||
if p_status == 0:
|
|
||||||
duration = float(output.decode('utf-8').strip())
|
|
||||||
else:
|
else:
|
||||||
logger.info(f"bad file: {f}")
|
logger.info(f"bad file: {f}")
|
||||||
os.remove(os.path.join(CACHE_DIR,f))
|
os.remove(os.path.join(CACHE_DIR, f))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.cached_clips[f] = {
|
self.cached_clips[f] = {
|
||||||
'path': f,
|
"path": f,
|
||||||
'camera': camera,
|
"camera": camera,
|
||||||
'start_time': start_time.timestamp(),
|
"start_time": start_time.timestamp(),
|
||||||
'duration': duration
|
"duration": duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(self.events_in_process) > 0:
|
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:
|
else:
|
||||||
earliest_event = datetime.datetime.now().timestamp()
|
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
|
max_seconds = self.config.clips.max_seconds
|
||||||
if datetime.datetime.now().timestamp()-earliest_event > max_seconds:
|
earliest_event = max(
|
||||||
earliest_event = datetime.datetime.now().timestamp()-max_seconds
|
earliest_event,
|
||||||
|
datetime.datetime.now().timestamp() - self.config.clips.max_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
for f, data in list(self.cached_clips.items()):
|
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]
|
del self.cached_clips[f]
|
||||||
logger.debug(f"Cleaning up cached file {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
|
# if we are still using more than 90% of the cache, proactively cleanup
|
||||||
cache_usage = shutil.disk_usage("/tmp/cache")
|
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("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...")
|
logger.warning("Proactively cleaning up the cache...")
|
||||||
while cache_usage.used/cache_usage.total > .9:
|
while cache_usage.used / cache_usage.total > 0.9:
|
||||||
oldest_clip = min(self.cached_clips.values(), key=lambda x:x['start_time'])
|
oldest_clip = min(
|
||||||
del self.cached_clips[oldest_clip['path']]
|
self.cached_clips.values(), key=lambda x: x["start_time"]
|
||||||
os.remove(os.path.join(CACHE_DIR,oldest_clip['path']))
|
)
|
||||||
|
del self.cached_clips[oldest_clip["path"]]
|
||||||
|
os.remove(os.path.join(CACHE_DIR, oldest_clip["path"]))
|
||||||
cache_usage = shutil.disk_usage("/tmp/cache")
|
cache_usage = shutil.disk_usage("/tmp/cache")
|
||||||
|
|
||||||
def create_clip(self, camera, event_data, pre_capture, post_capture):
|
def create_clip(self, camera, event_data, pre_capture, post_capture):
|
||||||
# get all clips from the camera with the event sorted
|
# 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
|
# if there are no clips in the cache or we are still waiting on a needed file check every 5 seconds
|
||||||
wait_count = 0
|
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:
|
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
|
return False
|
||||||
logger.debug(f"No cache clips for {camera}. Waiting...")
|
logger.debug(f"No cache clips for {camera}. Waiting...")
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
self.refresh_cache()
|
self.refresh_cache()
|
||||||
# get all clips from the camera with the event sorted
|
# 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
|
wait_count += 1
|
||||||
|
|
||||||
playlist_start = event_data['start_time']-pre_capture
|
playlist_start = event_data["start_time"] - pre_capture
|
||||||
playlist_end = event_data['end_time']+post_capture
|
playlist_end = event_data["end_time"] + post_capture
|
||||||
playlist_lines = []
|
playlist_lines = []
|
||||||
for clip in sorted_clips:
|
for clip in sorted_clips:
|
||||||
# clip ends before playlist start time, skip
|
# clip ends before playlist start time, skip
|
||||||
if clip['start_time']+clip['duration'] < playlist_start:
|
if clip["start_time"] + clip["duration"] < playlist_start:
|
||||||
continue
|
continue
|
||||||
# clip starts after playlist ends, finish
|
# clip starts after playlist ends, finish
|
||||||
if clip['start_time'] > playlist_end:
|
if clip["start_time"] > playlist_end:
|
||||||
break
|
break
|
||||||
playlist_lines.append(f"file '{os.path.join(CACHE_DIR,clip['path'])}'")
|
playlist_lines.append(f"file '{os.path.join(CACHE_DIR,clip['path'])}'")
|
||||||
# if this is the starting clip, add an inpoint
|
# if this is the starting clip, add an inpoint
|
||||||
if clip['start_time'] < playlist_start:
|
if clip["start_time"] < playlist_start:
|
||||||
playlist_lines.append(f"inpoint {int(playlist_start-clip['start_time'])}")
|
playlist_lines.append(
|
||||||
|
f"inpoint {int(playlist_start-clip['start_time'])}"
|
||||||
|
)
|
||||||
# if this is the ending clip, add an outpoint
|
# if this is the ending clip, add an outpoint
|
||||||
if clip['start_time']+clip['duration'] > playlist_end:
|
if clip["start_time"] + clip["duration"] > playlist_end:
|
||||||
playlist_lines.append(f"outpoint {int(playlist_end-clip['start_time'])}")
|
playlist_lines.append(
|
||||||
|
f"outpoint {int(playlist_end-clip['start_time'])}"
|
||||||
|
)
|
||||||
|
|
||||||
clip_name = f"{camera}-{event_data['id']}"
|
clip_name = f"{camera}-{event_data['id']}"
|
||||||
ffmpeg_cmd = [
|
ffmpeg_cmd = [
|
||||||
'ffmpeg',
|
"ffmpeg",
|
||||||
'-y',
|
"-y",
|
||||||
'-protocol_whitelist',
|
"-protocol_whitelist",
|
||||||
'pipe,file',
|
"pipe,file",
|
||||||
'-f',
|
"-f",
|
||||||
'concat',
|
"concat",
|
||||||
'-safe',
|
"-safe",
|
||||||
'0',
|
"0",
|
||||||
'-i',
|
"-i",
|
||||||
'-',
|
"-",
|
||||||
'-c',
|
"-c",
|
||||||
'copy',
|
"copy",
|
||||||
'-movflags',
|
"-movflags",
|
||||||
'+faststart',
|
"+faststart",
|
||||||
f"{os.path.join(CLIPS_DIR, clip_name)}.mp4"
|
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:
|
if p.returncode != 0:
|
||||||
logger.error(p.stderr)
|
logger.error(p.stderr)
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
while True:
|
while not self.stop_event.is_set():
|
||||||
if self.stop_event.is_set():
|
|
||||||
logger.info(f"Exiting event processor...")
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
event_type, camera, event_data = self.event_queue.get(timeout=10)
|
event_type, camera, event_data = self.event_queue.get(timeout=10)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
@ -199,68 +234,82 @@ class EventProcessor(threading.Thread):
|
|||||||
logger.debug(f"Event received: {event_type} {camera} {event_data['id']}")
|
logger.debug(f"Event received: {event_type} {camera} {event_data['id']}")
|
||||||
self.refresh_cache()
|
self.refresh_cache()
|
||||||
|
|
||||||
if event_type == 'start':
|
if event_type == "start":
|
||||||
self.events_in_process[event_data['id']] = event_data
|
self.events_in_process[event_data["id"]] = event_data
|
||||||
|
|
||||||
if event_type == 'end':
|
if event_type == "end":
|
||||||
clips_config = self.config.cameras[camera].clips
|
clips_config = self.config.cameras[camera].clips
|
||||||
|
|
||||||
clip_created = False
|
clip_created = False
|
||||||
if self.should_create_clip(camera, event_data):
|
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):
|
if clips_config.enabled and (
|
||||||
clip_created = self.create_clip(camera, event_data, clips_config.pre_capture, clips_config.post_capture)
|
clips_config.objects is None
|
||||||
|
or event_data["label"] in clips_config.objects
|
||||||
if clip_created or event_data['has_snapshot']:
|
):
|
||||||
Event.create(
|
clip_created = self.create_clip(
|
||||||
id=event_data['id'],
|
camera,
|
||||||
label=event_data['label'],
|
event_data,
|
||||||
camera=camera,
|
clips_config.pre_capture,
|
||||||
start_time=event_data['start_time'],
|
clips_config.post_capture,
|
||||||
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'],
|
|
||||||
)
|
)
|
||||||
del self.events_in_process[event_data['id']]
|
|
||||||
self.event_processed_queue.put((event_data['id'], camera))
|
if clip_created or event_data["has_snapshot"]:
|
||||||
|
Event.create(
|
||||||
|
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"],
|
||||||
|
has_clip=clip_created,
|
||||||
|
has_snapshot=event_data["has_snapshot"],
|
||||||
|
)
|
||||||
|
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):
|
class EventCleanup(threading.Thread):
|
||||||
def __init__(self, config: FrigateConfig, stop_event):
|
def __init__(self, config: FrigateConfig, stop_event):
|
||||||
threading.Thread.__init__(self)
|
threading.Thread.__init__(self)
|
||||||
self.name = 'event_cleanup'
|
self.name = "event_cleanup"
|
||||||
self.config = config
|
self.config = config
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.camera_keys = list(self.config.cameras.keys())
|
self.camera_keys = list(self.config.cameras.keys())
|
||||||
|
|
||||||
def expire(self, media):
|
def expire(self, media):
|
||||||
## Expire events from unlisted cameras based on the global config
|
## Expire events from unlisted cameras based on the global config
|
||||||
if media == 'clips':
|
if media == "clips":
|
||||||
retain_config = self.config.clips.retain
|
retain_config = self.config.clips.retain
|
||||||
file_extension = 'mp4'
|
file_extension = "mp4"
|
||||||
update_params = {'has_clip': False}
|
update_params = {"has_clip": False}
|
||||||
else:
|
else:
|
||||||
retain_config = self.config.snapshots.retain
|
retain_config = self.config.snapshots.retain
|
||||||
file_extension = 'jpg'
|
file_extension = "jpg"
|
||||||
update_params = {'has_snapshot': False}
|
update_params = {"has_snapshot": False}
|
||||||
|
|
||||||
distinct_labels = (Event.select(Event.label)
|
distinct_labels = (
|
||||||
|
Event.select(Event.label)
|
||||||
.where(Event.camera.not_in(self.camera_keys))
|
.where(Event.camera.not_in(self.camera_keys))
|
||||||
.distinct())
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
# loop over object types in db
|
# loop over object types in db
|
||||||
for l in distinct_labels:
|
for l in distinct_labels:
|
||||||
# get expiration time for this label
|
# get expiration time for this label
|
||||||
expire_days = retain_config.objects.get(l.label, retain_config.default)
|
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
|
# grab all events after specific time
|
||||||
expired_events = (
|
expired_events = Event.select().where(
|
||||||
Event.select()
|
Event.camera.not_in(self.camera_keys),
|
||||||
.where(Event.camera.not_in(self.camera_keys),
|
|
||||||
Event.start_time < expire_after,
|
Event.start_time < expire_after,
|
||||||
Event.label == l.label)
|
Event.label == l.label,
|
||||||
)
|
)
|
||||||
# delete the media from disk
|
# delete the media from disk
|
||||||
for event in expired_events:
|
for event in expired_events:
|
||||||
@ -268,48 +317,49 @@ class EventCleanup(threading.Thread):
|
|||||||
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)
|
media.unlink(missing_ok=True)
|
||||||
# update the clips attribute for the db entry
|
# update the clips attribute for the db entry
|
||||||
update_query = (
|
update_query = Event.update(update_params).where(
|
||||||
Event.update(update_params)
|
Event.camera.not_in(self.camera_keys),
|
||||||
.where(Event.camera.not_in(self.camera_keys),
|
|
||||||
Event.start_time < expire_after,
|
Event.start_time < expire_after,
|
||||||
Event.label == l.label)
|
Event.label == l.label,
|
||||||
)
|
)
|
||||||
update_query.execute()
|
update_query.execute()
|
||||||
|
|
||||||
## Expire events from cameras based on the camera config
|
## Expire events from cameras based on the camera config
|
||||||
for name, camera in self.config.cameras.items():
|
for name, camera in self.config.cameras.items():
|
||||||
if media == 'clips':
|
if media == "clips":
|
||||||
retain_config = camera.clips.retain
|
retain_config = camera.clips.retain
|
||||||
else:
|
else:
|
||||||
retain_config = camera.snapshots.retain
|
retain_config = camera.snapshots.retain
|
||||||
# get distinct objects in database for this camera
|
# get distinct objects in database for this camera
|
||||||
distinct_labels = (Event.select(Event.label)
|
distinct_labels = (
|
||||||
.where(Event.camera == name)
|
Event.select(Event.label).where(Event.camera == name).distinct()
|
||||||
.distinct())
|
)
|
||||||
|
|
||||||
# loop over object types in db
|
# loop over object types in db
|
||||||
for l in distinct_labels:
|
for l in distinct_labels:
|
||||||
# get expiration time for this label
|
# get expiration time for this label
|
||||||
expire_days = retain_config.objects.get(l.label, retain_config.default)
|
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
|
# grab all events after specific time
|
||||||
expired_events = (
|
expired_events = Event.select().where(
|
||||||
Event.select()
|
Event.camera == name,
|
||||||
.where(Event.camera == name,
|
|
||||||
Event.start_time < expire_after,
|
Event.start_time < expire_after,
|
||||||
Event.label == l.label)
|
Event.label == l.label,
|
||||||
)
|
)
|
||||||
# delete the grabbed clips from disk
|
# delete the grabbed clips from disk
|
||||||
for event in expired_events:
|
for event in expired_events:
|
||||||
media_name = f"{event.camera}-{event.id}"
|
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)
|
media.unlink(missing_ok=True)
|
||||||
# update the clips attribute for the db entry
|
# update the clips attribute for the db entry
|
||||||
update_query = (
|
update_query = Event.update(update_params).where(
|
||||||
Event.update(update_params)
|
Event.camera == name,
|
||||||
.where( Event.camera == name,
|
|
||||||
Event.start_time < expire_after,
|
Event.start_time < expire_after,
|
||||||
Event.label == l.label)
|
Event.label == l.label,
|
||||||
)
|
)
|
||||||
update_query.execute()
|
update_query.execute()
|
||||||
|
|
||||||
@ -341,32 +391,23 @@ class EventCleanup(threading.Thread):
|
|||||||
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4")
|
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4")
|
||||||
media.unlink(missing_ok=True)
|
media.unlink(missing_ok=True)
|
||||||
|
|
||||||
(Event.delete()
|
(
|
||||||
.where( Event.id << [event.id for event in duplicate_events] )
|
Event.delete()
|
||||||
.execute())
|
.where(Event.id << [event.id for event in duplicate_events])
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
counter = 0
|
# only expire events every 5 minutes
|
||||||
while(True):
|
while not self.stop_event.wait(300):
|
||||||
if self.stop_event.is_set():
|
self.expire("clips")
|
||||||
logger.info(f"Exiting event cleanup...")
|
self.expire("snapshots")
|
||||||
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')
|
|
||||||
self.purge_duplicates()
|
self.purge_duplicates()
|
||||||
|
|
||||||
# drop events from db where has_clip and has_snapshot are false
|
# drop events from db where has_clip and has_snapshot are false
|
||||||
delete_query = (
|
delete_query = Event.delete().where(
|
||||||
Event.delete()
|
Event.has_clip == False, Event.has_snapshot == False
|
||||||
.where( Event.has_clip == False,
|
|
||||||
Event.has_snapshot == False)
|
|
||||||
)
|
)
|
||||||
delete_query.execute()
|
delete_query.execute()
|
||||||
|
|
||||||
|
logger.info(f"Exiting event cleanup...")
|
||||||
|
|||||||
437
frigate/http.py
437
frigate/http.py
@ -1,21 +1,32 @@
|
|||||||
import base64
|
import base64
|
||||||
import datetime
|
from collections import OrderedDict
|
||||||
|
from datetime import datetime, timedelta
|
||||||
import json
|
import json
|
||||||
|
import glob
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import gevent
|
import gevent
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from flask import (Blueprint, Flask, Response, current_app, jsonify,
|
from flask import (
|
||||||
make_response, request)
|
Blueprint,
|
||||||
|
Flask,
|
||||||
|
Response,
|
||||||
|
current_app,
|
||||||
|
jsonify,
|
||||||
|
make_response,
|
||||||
|
request,
|
||||||
|
)
|
||||||
from flask_sockets import Sockets
|
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 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.models import Event
|
||||||
from frigate.stats import stats_snapshot
|
from frigate.stats import stats_snapshot
|
||||||
from frigate.util import calculate_region
|
from frigate.util import calculate_region
|
||||||
@ -23,10 +34,11 @@ from frigate.version import VERSION
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
bp = Blueprint('frigate', __name__)
|
bp = Blueprint("frigate", __name__)
|
||||||
ws = Blueprint('ws', __name__)
|
ws = Blueprint("ws", __name__)
|
||||||
|
|
||||||
class MqttBackend():
|
|
||||||
|
class MqttBackend:
|
||||||
"""Interface for registering and updating WebSocket clients."""
|
"""Interface for registering and updating WebSocket clients."""
|
||||||
|
|
||||||
def __init__(self, mqtt_client, topic_prefix):
|
def __init__(self, mqtt_client, topic_prefix):
|
||||||
@ -42,36 +54,48 @@ class MqttBackend():
|
|||||||
try:
|
try:
|
||||||
json_message = json.loads(message)
|
json_message = json.loads(message)
|
||||||
json_message = {
|
json_message = {
|
||||||
'topic': f"{self.topic_prefix}/{json_message['topic']}",
|
"topic": f"{self.topic_prefix}/{json_message['topic']}",
|
||||||
'payload': json_message['payload'],
|
"payload": json_message["payload"],
|
||||||
'retain': json_message.get('retain', False)
|
"retain": json_message.get("retain", False),
|
||||||
}
|
}
|
||||||
except:
|
except:
|
||||||
logger.warning("Unable to parse websocket message as valid json.")
|
logger.warning("Unable to parse websocket message as valid json.")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.debug(f"Publishing mqtt message from websockets at {json_message['topic']}.")
|
logger.debug(
|
||||||
self.mqtt_client.publish(json_message['topic'], json_message['payload'], retain=json_message['retain'])
|
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 run(self):
|
||||||
def send(client, userdata, message):
|
def send(client, userdata, message):
|
||||||
"""Sends mqtt messages to clients."""
|
"""Sends mqtt messages to clients."""
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Received mqtt message on {message.topic}.")
|
logger.debug(f"Received mqtt message on {message.topic}.")
|
||||||
ws_message = json.dumps({
|
ws_message = json.dumps(
|
||||||
'topic': message.topic.replace(f"{self.topic_prefix}/",""),
|
{
|
||||||
'payload': message.payload.decode()
|
"topic": message.topic.replace(f"{self.topic_prefix}/", ""),
|
||||||
})
|
"payload": message.payload.decode(),
|
||||||
|
}
|
||||||
|
)
|
||||||
except:
|
except:
|
||||||
# if the payload can't be decoded don't relay to clients
|
# 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
|
return
|
||||||
|
|
||||||
for client in self.clients:
|
for client in self.clients:
|
||||||
try:
|
try:
|
||||||
client.send(ws_message)
|
client.send(ws_message)
|
||||||
except:
|
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.clients.remove(client)
|
||||||
|
|
||||||
self.mqtt_client.message_callback_add(f"{self.topic_prefix}/#", send)
|
self.mqtt_client.message_callback_add(f"{self.topic_prefix}/#", send)
|
||||||
@ -80,7 +104,14 @@ class MqttBackend():
|
|||||||
"""Maintains mqtt subscription in the background."""
|
"""Maintains mqtt subscription in the background."""
|
||||||
gevent.spawn(self.run)
|
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__)
|
app = Flask(__name__)
|
||||||
sockets = Sockets(app)
|
sockets = Sockets(app)
|
||||||
|
|
||||||
@ -105,14 +136,16 @@ def create_app(frigate_config, database: SqliteDatabase, stats_tracking, detecte
|
|||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
@bp.route('/')
|
|
||||||
|
@bp.route("/")
|
||||||
def is_healthy():
|
def is_healthy():
|
||||||
return "Frigate is running. Alive and healthy!"
|
return "Frigate is running. Alive and healthy!"
|
||||||
|
|
||||||
@bp.route('/events/summary')
|
|
||||||
|
@bp.route("/events/summary")
|
||||||
def events_summary():
|
def events_summary():
|
||||||
has_clip = request.args.get('has_clip', type=int)
|
has_clip = request.args.get("has_clip", type=int)
|
||||||
has_snapshot = request.args.get('has_snapshot', type=int)
|
has_snapshot = request.args.get("has_snapshot", type=int)
|
||||||
|
|
||||||
clauses = []
|
clauses = []
|
||||||
|
|
||||||
@ -126,35 +159,63 @@ def events_summary():
|
|||||||
clauses.append((1 == 1))
|
clauses.append((1 == 1))
|
||||||
|
|
||||||
groups = (
|
groups = (
|
||||||
Event
|
Event.select(
|
||||||
.select(
|
|
||||||
Event.camera,
|
Event.camera,
|
||||||
Event.label,
|
Event.label,
|
||||||
fn.strftime('%Y-%m-%d', fn.datetime(Event.start_time, 'unixepoch', 'localtime')).alias('day'),
|
fn.strftime(
|
||||||
|
"%Y-%m-%d", fn.datetime(Event.start_time, "unixepoch", "localtime")
|
||||||
|
).alias("day"),
|
||||||
Event.zones,
|
Event.zones,
|
||||||
fn.COUNT(Event.id).alias('count')
|
fn.COUNT(Event.id).alias("count"),
|
||||||
)
|
)
|
||||||
.where(reduce(operator.and_, clauses))
|
.where(reduce(operator.and_, clauses))
|
||||||
.group_by(
|
.group_by(
|
||||||
Event.camera,
|
Event.camera,
|
||||||
Event.label,
|
Event.label,
|
||||||
fn.strftime('%Y-%m-%d', fn.datetime(Event.start_time, 'unixepoch', 'localtime')),
|
fn.strftime(
|
||||||
Event.zones
|
"%Y-%m-%d", fn.datetime(Event.start_time, "unixepoch", "localtime")
|
||||||
|
),
|
||||||
|
Event.zones,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return jsonify([e for e in groups.dicts()])
|
return jsonify([e for e in groups.dicts()])
|
||||||
|
|
||||||
@bp.route('/events/<id>')
|
|
||||||
|
@bp.route("/events/<id>", methods=("GET",))
|
||||||
def event(id):
|
def event(id):
|
||||||
try:
|
try:
|
||||||
return model_to_dict(Event.get(Event.id == id))
|
return model_to_dict(Event.get(Event.id == id))
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return "Event not found", 404
|
return "Event not found", 404
|
||||||
|
|
||||||
@bp.route('/events/<id>/thumbnail.jpg')
|
|
||||||
|
@bp.route("/events/<id>", 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/<id>/thumbnail.jpg")
|
||||||
def event_thumbnail(id):
|
def event_thumbnail(id):
|
||||||
format = request.args.get('format', 'ios')
|
format = request.args.get("format", "ios")
|
||||||
thumbnail_bytes = None
|
thumbnail_bytes = None
|
||||||
try:
|
try:
|
||||||
event = Event.get(Event.id == id)
|
event = Event.get(Event.id == id)
|
||||||
@ -162,7 +223,8 @@ def event_thumbnail(id):
|
|||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
# see if the object is currently being tracked
|
# see if the object is currently being tracked
|
||||||
try:
|
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:
|
if id in camera_state.tracked_objects:
|
||||||
tracked_obj = camera_state.tracked_objects.get(id)
|
tracked_obj = camera_state.tracked_objects.get(id)
|
||||||
if not tracked_obj is None:
|
if not tracked_obj is None:
|
||||||
@ -174,18 +236,27 @@ def event_thumbnail(id):
|
|||||||
return "Event not found", 404
|
return "Event not found", 404
|
||||||
|
|
||||||
# android notifications prefer a 2:1 ratio
|
# android notifications prefer a 2:1 ratio
|
||||||
if format == 'android':
|
if format == "android":
|
||||||
jpg_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
|
jpg_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
|
||||||
img = cv2.imdecode(jpg_as_np, flags=1)
|
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))
|
thumbnail = cv2.copyMakeBorder(
|
||||||
ret, jpg = cv2.imencode('.jpg', thumbnail, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
|
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()
|
thumbnail_bytes = jpg.tobytes()
|
||||||
|
|
||||||
response = make_response(thumbnail_bytes)
|
response = make_response(thumbnail_bytes)
|
||||||
response.headers['Content-Type'] = 'image/jpg'
|
response.headers["Content-Type"] = "image/jpg"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@bp.route('/events/<id>/snapshot.jpg')
|
|
||||||
|
@bp.route("/events/<id>/snapshot.jpg")
|
||||||
def event_snapshot(id):
|
def event_snapshot(id):
|
||||||
jpg_bytes = None
|
jpg_bytes = None
|
||||||
try:
|
try:
|
||||||
@ -193,20 +264,23 @@ def event_snapshot(id):
|
|||||||
if not event.has_snapshot:
|
if not event.has_snapshot:
|
||||||
return "Snapshot not available", 404
|
return "Snapshot not available", 404
|
||||||
# read snapshot from disk
|
# 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()
|
jpg_bytes = image_file.read()
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
# see if the object is currently being tracked
|
# see if the object is currently being tracked
|
||||||
try:
|
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:
|
if id in camera_state.tracked_objects:
|
||||||
tracked_obj = camera_state.tracked_objects.get(id)
|
tracked_obj = camera_state.tracked_objects.get(id)
|
||||||
if not tracked_obj is None:
|
if not tracked_obj is None:
|
||||||
jpg_bytes = tracked_obj.get_jpg_bytes(
|
jpg_bytes = tracked_obj.get_jpg_bytes(
|
||||||
timestamp=request.args.get('timestamp', type=int),
|
timestamp=request.args.get("timestamp", type=int),
|
||||||
bounding_box=request.args.get('bbox', type=int),
|
bounding_box=request.args.get("bbox", type=int),
|
||||||
crop=request.args.get('crop', type=int),
|
crop=request.args.get("crop", type=int),
|
||||||
height=request.args.get('h', type=int)
|
height=request.args.get("h", type=int),
|
||||||
)
|
)
|
||||||
except:
|
except:
|
||||||
return "Event not found", 404
|
return "Event not found", 404
|
||||||
@ -214,20 +288,21 @@ def event_snapshot(id):
|
|||||||
return "Event not found", 404
|
return "Event not found", 404
|
||||||
|
|
||||||
response = make_response(jpg_bytes)
|
response = make_response(jpg_bytes)
|
||||||
response.headers['Content-Type'] = 'image/jpg'
|
response.headers["Content-Type"] = "image/jpg"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@bp.route('/events')
|
|
||||||
|
@bp.route("/events")
|
||||||
def events():
|
def events():
|
||||||
limit = request.args.get('limit', 100)
|
limit = request.args.get("limit", 100)
|
||||||
camera = request.args.get('camera')
|
camera = request.args.get("camera")
|
||||||
label = request.args.get('label')
|
label = request.args.get("label")
|
||||||
zone = request.args.get('zone')
|
zone = request.args.get("zone")
|
||||||
after = request.args.get('after', type=float)
|
after = request.args.get("after", type=float)
|
||||||
before = request.args.get('before', type=float)
|
before = request.args.get("before", type=float)
|
||||||
has_clip = request.args.get('has_clip', type=int)
|
has_clip = request.args.get("has_clip", type=int)
|
||||||
has_snapshot = request.args.get('has_snapshot', type=int)
|
has_snapshot = request.args.get("has_snapshot", type=int)
|
||||||
include_thumbnails = request.args.get('include_thumbnails', default=1, type=int)
|
include_thumbnails = request.args.get("include_thumbnails", default=1, type=int)
|
||||||
|
|
||||||
clauses = []
|
clauses = []
|
||||||
excluded_fields = []
|
excluded_fields = []
|
||||||
@ -239,7 +314,7 @@ def events():
|
|||||||
clauses.append((Event.label == label))
|
clauses.append((Event.label == label))
|
||||||
|
|
||||||
if zone:
|
if zone:
|
||||||
clauses.append((Event.zones.cast('text') % f"*\"{zone}\"*"))
|
clauses.append((Event.zones.cast("text") % f'*"{zone}"*'))
|
||||||
|
|
||||||
if after:
|
if after:
|
||||||
clauses.append((Event.start_time >= after))
|
clauses.append((Event.start_time >= after))
|
||||||
@ -259,116 +334,268 @@ def events():
|
|||||||
if len(clauses) == 0:
|
if len(clauses) == 0:
|
||||||
clauses.append((1 == 1))
|
clauses.append((1 == 1))
|
||||||
|
|
||||||
events = (Event.select()
|
events = (
|
||||||
|
Event.select()
|
||||||
.where(reduce(operator.and_, clauses))
|
.where(reduce(operator.and_, clauses))
|
||||||
.order_by(Event.start_time.desc())
|
.order_by(Event.start_time.desc())
|
||||||
.limit(limit))
|
.limit(limit)
|
||||||
|
)
|
||||||
|
|
||||||
return jsonify([model_to_dict(e, exclude=excluded_fields) for e in events])
|
return jsonify([model_to_dict(e, exclude=excluded_fields) for e in events])
|
||||||
|
|
||||||
@bp.route('/config')
|
|
||||||
|
@bp.route("/config")
|
||||||
def config():
|
def config():
|
||||||
return jsonify(current_app.frigate_config.to_dict())
|
return jsonify(current_app.frigate_config.to_dict())
|
||||||
|
|
||||||
@bp.route('/version')
|
|
||||||
|
@bp.route("/version")
|
||||||
def version():
|
def version():
|
||||||
return VERSION
|
return VERSION
|
||||||
|
|
||||||
@bp.route('/stats')
|
|
||||||
|
@bp.route("/stats")
|
||||||
def stats():
|
def stats():
|
||||||
stats = stats_snapshot(current_app.stats_tracking)
|
stats = stats_snapshot(current_app.stats_tracking)
|
||||||
return jsonify(stats)
|
return jsonify(stats)
|
||||||
|
|
||||||
@bp.route('/<camera_name>/<label>/best.jpg')
|
|
||||||
|
@bp.route("/<camera_name>/<label>/best.jpg")
|
||||||
def best(camera_name, label):
|
def best(camera_name, label):
|
||||||
if camera_name in current_app.frigate_config.cameras:
|
if camera_name in current_app.frigate_config.cameras:
|
||||||
best_object = current_app.detected_frames_processor.get_best(camera_name, label)
|
best_object = current_app.detected_frames_processor.get_best(camera_name, label)
|
||||||
best_frame = best_object.get('frame')
|
best_frame = best_object.get("frame")
|
||||||
if best_frame is None:
|
if best_frame is None:
|
||||||
best_frame = np.zeros((720,1280,3), np.uint8)
|
best_frame = np.zeros((720, 1280, 3), np.uint8)
|
||||||
else:
|
else:
|
||||||
best_frame = cv2.cvtColor(best_frame, cv2.COLOR_YUV2BGR_I420)
|
best_frame = cv2.cvtColor(best_frame, cv2.COLOR_YUV2BGR_I420)
|
||||||
|
|
||||||
crop = bool(request.args.get('crop', 0, type=int))
|
crop = bool(request.args.get("crop", 0, type=int))
|
||||||
if crop:
|
if crop:
|
||||||
box = best_object.get('box', (0,0,300,300))
|
box = best_object.get("box", (0, 0, 300, 300))
|
||||||
region = calculate_region(best_frame.shape, box[0], box[1], box[2], box[3], 1.1)
|
region = calculate_region(
|
||||||
best_frame = best_frame[region[1]:region[3], region[0]:region[2]]
|
best_frame.shape, box[0], box[1], box[2], box[3], 1.1
|
||||||
|
)
|
||||||
|
best_frame = best_frame[region[1] : region[3], region[0] : region[2]]
|
||||||
|
|
||||||
height = int(request.args.get('h', str(best_frame.shape[0])))
|
height = int(request.args.get("h", str(best_frame.shape[0])))
|
||||||
width = int(height*best_frame.shape[1]/best_frame.shape[0])
|
width = int(height * best_frame.shape[1] / best_frame.shape[0])
|
||||||
|
|
||||||
best_frame = cv2.resize(best_frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
|
best_frame = cv2.resize(
|
||||||
ret, jpg = cv2.imencode('.jpg', best_frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
|
best_frame, dsize=(width, height), interpolation=cv2.INTER_AREA
|
||||||
|
)
|
||||||
|
ret, jpg = cv2.imencode(".jpg", best_frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
|
||||||
response = make_response(jpg.tobytes())
|
response = make_response(jpg.tobytes())
|
||||||
response.headers['Content-Type'] = 'image/jpg'
|
response.headers["Content-Type"] = "image/jpg"
|
||||||
return response
|
return response
|
||||||
else:
|
else:
|
||||||
return "Camera named {} not found".format(camera_name), 404
|
return "Camera named {} not found".format(camera_name), 404
|
||||||
|
|
||||||
@bp.route('/<camera_name>')
|
|
||||||
|
@bp.route("/<camera_name>")
|
||||||
def mjpeg_feed(camera_name):
|
def mjpeg_feed(camera_name):
|
||||||
fps = int(request.args.get('fps', '3'))
|
fps = int(request.args.get("fps", "3"))
|
||||||
height = int(request.args.get('h', '360'))
|
height = int(request.args.get("h", "360"))
|
||||||
draw_options = {
|
draw_options = {
|
||||||
'bounding_boxes': request.args.get('bbox', type=int),
|
"bounding_boxes": request.args.get("bbox", type=int),
|
||||||
'timestamp': request.args.get('timestamp', type=int),
|
"timestamp": request.args.get("timestamp", type=int),
|
||||||
'zones': request.args.get('zones', type=int),
|
"zones": request.args.get("zones", type=int),
|
||||||
'mask': request.args.get('mask', type=int),
|
"mask": request.args.get("mask", type=int),
|
||||||
'motion_boxes': request.args.get('motion', type=int),
|
"motion_boxes": request.args.get("motion", type=int),
|
||||||
'regions': request.args.get('regions', type=int),
|
"regions": request.args.get("regions", type=int),
|
||||||
}
|
}
|
||||||
if camera_name in current_app.frigate_config.cameras:
|
if camera_name in current_app.frigate_config.cameras:
|
||||||
# return a multipart response
|
# return a multipart response
|
||||||
return Response(imagestream(current_app.detected_frames_processor, camera_name, fps, height, draw_options),
|
return Response(
|
||||||
mimetype='multipart/x-mixed-replace; boundary=frame')
|
imagestream(
|
||||||
|
current_app.detected_frames_processor,
|
||||||
|
camera_name,
|
||||||
|
fps,
|
||||||
|
height,
|
||||||
|
draw_options,
|
||||||
|
),
|
||||||
|
mimetype="multipart/x-mixed-replace; boundary=frame",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return "Camera named {} not found".format(camera_name), 404
|
return "Camera named {} not found".format(camera_name), 404
|
||||||
|
|
||||||
@bp.route('/<camera_name>/latest.jpg')
|
|
||||||
|
@bp.route("/<camera_name>/latest.jpg")
|
||||||
def latest_frame(camera_name):
|
def latest_frame(camera_name):
|
||||||
draw_options = {
|
draw_options = {
|
||||||
'bounding_boxes': request.args.get('bbox', type=int),
|
"bounding_boxes": request.args.get("bbox", type=int),
|
||||||
'timestamp': request.args.get('timestamp', type=int),
|
"timestamp": request.args.get("timestamp", type=int),
|
||||||
'zones': request.args.get('zones', type=int),
|
"zones": request.args.get("zones", type=int),
|
||||||
'mask': request.args.get('mask', type=int),
|
"mask": request.args.get("mask", type=int),
|
||||||
'motion_boxes': request.args.get('motion', type=int),
|
"motion_boxes": request.args.get("motion", type=int),
|
||||||
'regions': request.args.get('regions', type=int),
|
"regions": request.args.get("regions", type=int),
|
||||||
}
|
}
|
||||||
if camera_name in current_app.frigate_config.cameras:
|
if camera_name in current_app.frigate_config.cameras:
|
||||||
# max out at specified FPS
|
# max out at specified FPS
|
||||||
frame = current_app.detected_frames_processor.get_current_frame(camera_name, draw_options)
|
frame = current_app.detected_frames_processor.get_current_frame(
|
||||||
|
camera_name, draw_options
|
||||||
|
)
|
||||||
if frame is None:
|
if frame is None:
|
||||||
frame = np.zeros((720,1280,3), np.uint8)
|
frame = np.zeros((720, 1280, 3), np.uint8)
|
||||||
|
|
||||||
height = int(request.args.get('h', str(frame.shape[0])))
|
height = int(request.args.get("h", str(frame.shape[0])))
|
||||||
width = int(height*frame.shape[1]/frame.shape[0])
|
width = int(height * frame.shape[1] / frame.shape[0])
|
||||||
|
|
||||||
frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
|
frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
|
||||||
|
|
||||||
ret, jpg = cv2.imencode('.jpg', frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
|
ret, jpg = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
|
||||||
response = make_response(jpg.tobytes())
|
response = make_response(jpg.tobytes())
|
||||||
response.headers['Content-Type'] = 'image/jpg'
|
response.headers["Content-Type"] = "image/jpg"
|
||||||
return response
|
return response
|
||||||
else:
|
else:
|
||||||
return "Camera named {} not found".format(camera_name), 404
|
return "Camera named {} not found".format(camera_name), 404
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/<camera_name>/recordings")
|
||||||
|
def recordings(camera_name):
|
||||||
|
files = glob.glob(f"{RECORD_DIR}/*/*/*/{camera_name}")
|
||||||
|
|
||||||
|
if len(files) == 0:
|
||||||
|
return jsonify([])
|
||||||
|
|
||||||
|
files.sort()
|
||||||
|
|
||||||
|
dates = OrderedDict()
|
||||||
|
for path in files:
|
||||||
|
first = glob.glob(f"{path}/00.*.mp4")
|
||||||
|
delay = 0
|
||||||
|
if len(first) > 0:
|
||||||
|
delay = int(first[0].strip(path).split(".")[1])
|
||||||
|
search = re.search(r".+/(\d{4}[-]\d{2})/(\d{2})/(\d{2}).+", path)
|
||||||
|
if not search:
|
||||||
|
continue
|
||||||
|
date = f"{search.group(1)}-{search.group(2)}"
|
||||||
|
if date not in dates:
|
||||||
|
dates[date] = OrderedDict()
|
||||||
|
dates[date][search.group(3)] = {"delay": delay, "events": []}
|
||||||
|
|
||||||
|
# Packing intervals to return all events with same label and overlapping times as one row.
|
||||||
|
# See: https://blogs.solidq.com/en/sqlserver/packing-intervals/
|
||||||
|
events = Event.raw(
|
||||||
|
"""WITH C1 AS
|
||||||
|
(
|
||||||
|
SELECT id, label, camera, top_score, start_time AS ts, +1 AS type, 1 AS sub
|
||||||
|
FROM event
|
||||||
|
WHERE camera = ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, label, camera, top_score, end_time + 15 AS ts, -1 AS type, 0 AS sub
|
||||||
|
FROM event
|
||||||
|
WHERE camera = ?
|
||||||
|
),
|
||||||
|
C2 AS
|
||||||
|
(
|
||||||
|
SELECT C1.*,
|
||||||
|
SUM(type) OVER(PARTITION BY label ORDER BY ts, type DESC
|
||||||
|
ROWS BETWEEN UNBOUNDED PRECEDING
|
||||||
|
AND CURRENT ROW) - sub AS cnt
|
||||||
|
FROM C1
|
||||||
|
),
|
||||||
|
C3 AS
|
||||||
|
(
|
||||||
|
SELECT id, label, camera, top_score, ts,
|
||||||
|
(ROW_NUMBER() OVER(PARTITION BY label ORDER BY ts) - 1) / 2 + 1
|
||||||
|
AS grpnum
|
||||||
|
FROM C2
|
||||||
|
WHERE cnt = 0
|
||||||
|
)
|
||||||
|
SELECT MIN(id) as id, label, camera, MAX(top_score) as top_score, MIN(ts) AS start_time, max(ts) AS end_time
|
||||||
|
FROM C3
|
||||||
|
GROUP BY label, grpnum
|
||||||
|
ORDER BY start_time;""",
|
||||||
|
camera_name,
|
||||||
|
camera_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
e: Event
|
||||||
|
for e in events:
|
||||||
|
date = datetime.fromtimestamp(e.start_time)
|
||||||
|
key = date.strftime("%Y-%m-%d")
|
||||||
|
hour = date.strftime("%H")
|
||||||
|
if key in dates and hour in dates[key]:
|
||||||
|
dates[key][hour]["events"].append(
|
||||||
|
model_to_dict(
|
||||||
|
e,
|
||||||
|
exclude=[
|
||||||
|
Event.false_positive,
|
||||||
|
Event.zones,
|
||||||
|
Event.thumbnail,
|
||||||
|
Event.has_clip,
|
||||||
|
Event.has_snapshot,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"date": date,
|
||||||
|
"events": sum([len(value["events"]) for value in hours.values()]),
|
||||||
|
"recordings": [
|
||||||
|
{"hour": hour, "delay": value["delay"], "events": value["events"]}
|
||||||
|
for hour, value in hours.items()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for date, hours in dates.items()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/vod/<path:path>")
|
||||||
|
def vod(path):
|
||||||
|
if not os.path.isdir(f"{RECORD_DIR}/{path}"):
|
||||||
|
return "Recordings not found.", 404
|
||||||
|
|
||||||
|
files = glob.glob(f"{RECORD_DIR}/{path}/*.mp4")
|
||||||
|
files.sort()
|
||||||
|
|
||||||
|
clips = []
|
||||||
|
durations = []
|
||||||
|
for filename in files:
|
||||||
|
clips.append({"type": "source", "path": filename})
|
||||||
|
video = cv2.VideoCapture(filename)
|
||||||
|
duration = int(
|
||||||
|
video.get(cv2.CAP_PROP_FRAME_COUNT) / video.get(cv2.CAP_PROP_FPS) * 1000
|
||||||
|
)
|
||||||
|
durations.append(duration)
|
||||||
|
|
||||||
|
# Should we cache?
|
||||||
|
parts = path.split("/", 4)
|
||||||
|
date = datetime.strptime(f"{parts[0]}-{parts[1]} {parts[2]}", "%Y-%m-%d %H")
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"cache": datetime.now() - timedelta(hours=2) > date,
|
||||||
|
"discontinuity": False,
|
||||||
|
"durations": durations,
|
||||||
|
"sequences": [{"clips": clips}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def imagestream(detected_frames_processor, camera_name, fps, height, draw_options):
|
def imagestream(detected_frames_processor, camera_name, fps, height, draw_options):
|
||||||
while True:
|
while True:
|
||||||
# max out at specified FPS
|
# max out at specified FPS
|
||||||
gevent.sleep(1/fps)
|
gevent.sleep(1 / fps)
|
||||||
frame = detected_frames_processor.get_current_frame(camera_name, draw_options)
|
frame = detected_frames_processor.get_current_frame(camera_name, draw_options)
|
||||||
if frame is None:
|
if frame is None:
|
||||||
frame = np.zeros((height,int(height*16/9),3), np.uint8)
|
frame = np.zeros((height, int(height * 16 / 9), 3), np.uint8)
|
||||||
|
|
||||||
width = int(height*frame.shape[1]/frame.shape[0])
|
width = int(height * frame.shape[1] / frame.shape[0])
|
||||||
frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_LINEAR)
|
frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_LINEAR)
|
||||||
|
|
||||||
ret, jpg = cv2.imencode('.jpg', frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
|
ret, jpg = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
|
||||||
yield (b'--frame\r\n'
|
yield (
|
||||||
b'Content-Type: image/jpeg\r\n\r\n' + jpg.tobytes() + b'\r\n\r\n')
|
b"--frame\r\n"
|
||||||
|
b"Content-Type: image/jpeg\r\n\r\n" + jpg.tobytes() + b"\r\n\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
@ws.route('/ws')
|
|
||||||
|
@ws.route("/ws")
|
||||||
def echo_socket(socket):
|
def echo_socket(socket):
|
||||||
current_app.mqtt_backend.register(socket)
|
current_app.mqtt_backend.register(socket)
|
||||||
|
|
||||||
|
|||||||
@ -13,38 +13,34 @@ from collections import deque
|
|||||||
def listener_configurer():
|
def listener_configurer():
|
||||||
root = logging.getLogger()
|
root = logging.getLogger()
|
||||||
console_handler = logging.StreamHandler()
|
console_handler = logging.StreamHandler()
|
||||||
formatter = logging.Formatter('%(name)-30s %(levelname)-8s: %(message)s')
|
formatter = logging.Formatter(
|
||||||
|
"[%(asctime)s] %(name)-30s %(levelname)-8s: %(message)s", "%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
console_handler.setFormatter(formatter)
|
console_handler.setFormatter(formatter)
|
||||||
root.addHandler(console_handler)
|
root.addHandler(console_handler)
|
||||||
root.setLevel(logging.INFO)
|
root.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
def root_configurer(queue):
|
def root_configurer(queue):
|
||||||
h = handlers.QueueHandler(queue)
|
h = handlers.QueueHandler(queue)
|
||||||
root = logging.getLogger()
|
root = logging.getLogger()
|
||||||
root.addHandler(h)
|
root.addHandler(h)
|
||||||
root.setLevel(logging.INFO)
|
root.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
def log_process(log_queue):
|
def log_process(log_queue):
|
||||||
stop_event = mp.Event()
|
|
||||||
def receiveSignal(signalNumber, frame):
|
|
||||||
stop_event.set()
|
|
||||||
|
|
||||||
signal.signal(signal.SIGTERM, receiveSignal)
|
|
||||||
signal.signal(signal.SIGINT, receiveSignal)
|
|
||||||
|
|
||||||
threading.current_thread().name = f"logger"
|
threading.current_thread().name = f"logger"
|
||||||
setproctitle("frigate.logger")
|
setproctitle("frigate.logger")
|
||||||
listener_configurer()
|
listener_configurer()
|
||||||
while True:
|
while True:
|
||||||
if stop_event.is_set() and log_queue.empty():
|
|
||||||
break
|
|
||||||
try:
|
try:
|
||||||
record = log_queue.get(timeout=5)
|
record = log_queue.get(timeout=5)
|
||||||
except queue.Empty:
|
except (queue.Empty, KeyboardInterrupt):
|
||||||
continue
|
continue
|
||||||
logger = logging.getLogger(record.name)
|
logger = logging.getLogger(record.name)
|
||||||
logger.handle(record)
|
logger.handle(record)
|
||||||
|
|
||||||
|
|
||||||
# based on https://codereview.stackexchange.com/a/17959
|
# based on https://codereview.stackexchange.com/a/17959
|
||||||
class LogPipe(threading.Thread):
|
class LogPipe(threading.Thread):
|
||||||
def __init__(self, log_name, level):
|
def __init__(self, log_name, level):
|
||||||
@ -61,15 +57,13 @@ class LogPipe(threading.Thread):
|
|||||||
self.start()
|
self.start()
|
||||||
|
|
||||||
def fileno(self):
|
def fileno(self):
|
||||||
"""Return the write file descriptor of the pipe
|
"""Return the write file descriptor of the pipe"""
|
||||||
"""
|
|
||||||
return self.fdWrite
|
return self.fdWrite
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Run the thread, logging everything.
|
"""Run the thread, logging everything."""
|
||||||
"""
|
for line in iter(self.pipeReader.readline, ""):
|
||||||
for line in iter(self.pipeReader.readline, ''):
|
self.deque.append(line.strip("\n"))
|
||||||
self.deque.append(line.strip('\n'))
|
|
||||||
|
|
||||||
self.pipeReader.close()
|
self.pipeReader.close()
|
||||||
|
|
||||||
@ -78,6 +72,5 @@ class LogPipe(threading.Thread):
|
|||||||
self.logger.log(self.level, self.deque.popleft())
|
self.logger.log(self.level, self.deque.popleft())
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""Close the write end of the pipe.
|
"""Close the write end of the pipe."""
|
||||||
"""
|
|
||||||
os.close(self.fdWrite)
|
os.close(self.fdWrite)
|
||||||
|
|||||||
@ -4,26 +4,37 @@ import numpy as np
|
|||||||
from frigate.config import MotionConfig
|
from frigate.config import MotionConfig
|
||||||
|
|
||||||
|
|
||||||
class MotionDetector():
|
class MotionDetector:
|
||||||
def __init__(self, frame_shape, config: MotionConfig):
|
def __init__(self, frame_shape, config: MotionConfig):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.frame_shape = frame_shape
|
self.frame_shape = frame_shape
|
||||||
self.resize_factor = frame_shape[0]/config.frame_height
|
self.resize_factor = frame_shape[0] / config.frame_height
|
||||||
self.motion_frame_size = (config.frame_height, config.frame_height*frame_shape[1]//frame_shape[0])
|
self.motion_frame_size = (
|
||||||
|
config.frame_height,
|
||||||
|
config.frame_height * frame_shape[1] // frame_shape[0],
|
||||||
|
)
|
||||||
self.avg_frame = np.zeros(self.motion_frame_size, np.float)
|
self.avg_frame = np.zeros(self.motion_frame_size, np.float)
|
||||||
self.avg_delta = np.zeros(self.motion_frame_size, np.float)
|
self.avg_delta = np.zeros(self.motion_frame_size, np.float)
|
||||||
self.motion_frame_count = 0
|
self.motion_frame_count = 0
|
||||||
self.frame_counter = 0
|
self.frame_counter = 0
|
||||||
resized_mask = cv2.resize(config.mask, dsize=(self.motion_frame_size[1], self.motion_frame_size[0]), interpolation=cv2.INTER_LINEAR)
|
resized_mask = cv2.resize(
|
||||||
self.mask = np.where(resized_mask==[0])
|
config.mask,
|
||||||
|
dsize=(self.motion_frame_size[1], self.motion_frame_size[0]),
|
||||||
|
interpolation=cv2.INTER_LINEAR,
|
||||||
|
)
|
||||||
|
self.mask = np.where(resized_mask == [0])
|
||||||
|
|
||||||
def detect(self, frame):
|
def detect(self, frame):
|
||||||
motion_boxes = []
|
motion_boxes = []
|
||||||
|
|
||||||
gray = frame[0:self.frame_shape[0], 0:self.frame_shape[1]]
|
gray = frame[0 : self.frame_shape[0], 0 : self.frame_shape[1]]
|
||||||
|
|
||||||
# resize frame
|
# resize frame
|
||||||
resized_frame = cv2.resize(gray, dsize=(self.motion_frame_size[1], self.motion_frame_size[0]), interpolation=cv2.INTER_LINEAR)
|
resized_frame = cv2.resize(
|
||||||
|
gray,
|
||||||
|
dsize=(self.motion_frame_size[1], self.motion_frame_size[0]),
|
||||||
|
interpolation=cv2.INTER_LINEAR,
|
||||||
|
)
|
||||||
|
|
||||||
# TODO: can I improve the contrast of the grayscale image here?
|
# TODO: can I improve the contrast of the grayscale image here?
|
||||||
|
|
||||||
@ -48,7 +59,9 @@ class MotionDetector():
|
|||||||
|
|
||||||
# compute the threshold image for the current frame
|
# compute the threshold image for the current frame
|
||||||
# TODO: threshold
|
# TODO: threshold
|
||||||
current_thresh = cv2.threshold(frameDelta, self.config.threshold, 255, cv2.THRESH_BINARY)[1]
|
current_thresh = cv2.threshold(
|
||||||
|
frameDelta, self.config.threshold, 255, cv2.THRESH_BINARY
|
||||||
|
)[1]
|
||||||
|
|
||||||
# black out everything in the avg_delta where there isnt motion in the current frame
|
# black out everything in the avg_delta where there isnt motion in the current frame
|
||||||
avg_delta_image = cv2.convertScaleAbs(self.avg_delta)
|
avg_delta_image = cv2.convertScaleAbs(self.avg_delta)
|
||||||
@ -56,7 +69,9 @@ class MotionDetector():
|
|||||||
|
|
||||||
# then look for deltas above the threshold, but only in areas where there is a delta
|
# then look for deltas above the threshold, but only in areas where there is a delta
|
||||||
# in the current frame. this prevents deltas from previous frames from being included
|
# in the current frame. this prevents deltas from previous frames from being included
|
||||||
thresh = cv2.threshold(avg_delta_image, self.config.threshold, 255, cv2.THRESH_BINARY)[1]
|
thresh = cv2.threshold(
|
||||||
|
avg_delta_image, self.config.threshold, 255, cv2.THRESH_BINARY
|
||||||
|
)[1]
|
||||||
|
|
||||||
# dilate the thresholded image to fill in holes, then find contours
|
# dilate the thresholded image to fill in holes, then find contours
|
||||||
# on thresholded image
|
# on thresholded image
|
||||||
@ -70,16 +85,27 @@ class MotionDetector():
|
|||||||
contour_area = cv2.contourArea(c)
|
contour_area = cv2.contourArea(c)
|
||||||
if contour_area > self.config.contour_area:
|
if contour_area > self.config.contour_area:
|
||||||
x, y, w, h = cv2.boundingRect(c)
|
x, y, w, h = cv2.boundingRect(c)
|
||||||
motion_boxes.append((int(x*self.resize_factor), int(y*self.resize_factor), int((x+w)*self.resize_factor), int((y+h)*self.resize_factor)))
|
motion_boxes.append(
|
||||||
|
(
|
||||||
|
int(x * self.resize_factor),
|
||||||
|
int(y * self.resize_factor),
|
||||||
|
int((x + w) * self.resize_factor),
|
||||||
|
int((y + h) * self.resize_factor),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if len(motion_boxes) > 0:
|
if len(motion_boxes) > 0:
|
||||||
self.motion_frame_count += 1
|
self.motion_frame_count += 1
|
||||||
if self.motion_frame_count >= 10:
|
if self.motion_frame_count >= 10:
|
||||||
# only average in the current frame if the difference persists for a bit
|
# only average in the current frame if the difference persists for a bit
|
||||||
cv2.accumulateWeighted(resized_frame, self.avg_frame, self.config.frame_alpha)
|
cv2.accumulateWeighted(
|
||||||
|
resized_frame, self.avg_frame, self.config.frame_alpha
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# when no motion, just keep averaging the frames together
|
# when no motion, just keep averaging the frames together
|
||||||
cv2.accumulateWeighted(resized_frame, self.avg_frame, self.config.frame_alpha)
|
cv2.accumulateWeighted(
|
||||||
|
resized_frame, self.avg_frame, self.config.frame_alpha
|
||||||
|
)
|
||||||
self.motion_frame_count = 0
|
self.motion_frame_count = 0
|
||||||
|
|
||||||
return motion_boxes
|
return motion_boxes
|
||||||
|
|||||||
@ -7,6 +7,7 @@ from frigate.config import FrigateConfig
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def create_mqtt_client(config: FrigateConfig, camera_metrics):
|
def create_mqtt_client(config: FrigateConfig, camera_metrics):
|
||||||
mqtt_config = config.mqtt
|
mqtt_config = config.mqtt
|
||||||
|
|
||||||
@ -14,18 +15,18 @@ def create_mqtt_client(config: FrigateConfig, camera_metrics):
|
|||||||
payload = message.payload.decode()
|
payload = message.payload.decode()
|
||||||
logger.debug(f"on_clips_toggle: {message.topic} {payload}")
|
logger.debug(f"on_clips_toggle: {message.topic} {payload}")
|
||||||
|
|
||||||
camera_name = message.topic.split('/')[-3]
|
camera_name = message.topic.split("/")[-3]
|
||||||
|
|
||||||
clips_settings = config.cameras[camera_name].clips
|
clips_settings = config.cameras[camera_name].clips
|
||||||
|
|
||||||
if payload == 'ON':
|
if payload == "ON":
|
||||||
if not clips_settings.enabled:
|
if not clips_settings.enabled:
|
||||||
logger.info(f"Turning on clips for {camera_name} via mqtt")
|
logger.info(f"Turning on clips for {camera_name} via mqtt")
|
||||||
clips_settings._enabled = True
|
clips_settings.enabled = True
|
||||||
elif payload == 'OFF':
|
elif payload == "OFF":
|
||||||
if clips_settings.enabled:
|
if clips_settings.enabled:
|
||||||
logger.info(f"Turning off clips for {camera_name} via mqtt")
|
logger.info(f"Turning off clips for {camera_name} via mqtt")
|
||||||
clips_settings._enabled = False
|
clips_settings.enabled = False
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Received unsupported value at {message.topic}: {payload}")
|
logger.warning(f"Received unsupported value at {message.topic}: {payload}")
|
||||||
|
|
||||||
@ -36,18 +37,18 @@ def create_mqtt_client(config: FrigateConfig, camera_metrics):
|
|||||||
payload = message.payload.decode()
|
payload = message.payload.decode()
|
||||||
logger.debug(f"on_snapshots_toggle: {message.topic} {payload}")
|
logger.debug(f"on_snapshots_toggle: {message.topic} {payload}")
|
||||||
|
|
||||||
camera_name = message.topic.split('/')[-3]
|
camera_name = message.topic.split("/")[-3]
|
||||||
|
|
||||||
snapshots_settings = config.cameras[camera_name].snapshots
|
snapshots_settings = config.cameras[camera_name].snapshots
|
||||||
|
|
||||||
if payload == 'ON':
|
if payload == "ON":
|
||||||
if not snapshots_settings.enabled:
|
if not snapshots_settings.enabled:
|
||||||
logger.info(f"Turning on snapshots for {camera_name} via mqtt")
|
logger.info(f"Turning on snapshots for {camera_name} via mqtt")
|
||||||
snapshots_settings._enabled = True
|
snapshots_settings.enabled = True
|
||||||
elif payload == 'OFF':
|
elif payload == "OFF":
|
||||||
if snapshots_settings.enabled:
|
if snapshots_settings.enabled:
|
||||||
logger.info(f"Turning off snapshots for {camera_name} via mqtt")
|
logger.info(f"Turning off snapshots for {camera_name} via mqtt")
|
||||||
snapshots_settings._enabled = False
|
snapshots_settings.enabled = False
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Received unsupported value at {message.topic}: {payload}")
|
logger.warning(f"Received unsupported value at {message.topic}: {payload}")
|
||||||
|
|
||||||
@ -58,20 +59,20 @@ def create_mqtt_client(config: FrigateConfig, camera_metrics):
|
|||||||
payload = message.payload.decode()
|
payload = message.payload.decode()
|
||||||
logger.debug(f"on_detect_toggle: {message.topic} {payload}")
|
logger.debug(f"on_detect_toggle: {message.topic} {payload}")
|
||||||
|
|
||||||
camera_name = message.topic.split('/')[-3]
|
camera_name = message.topic.split("/")[-3]
|
||||||
|
|
||||||
detect_settings = config.cameras[camera_name].detect
|
detect_settings = config.cameras[camera_name].detect
|
||||||
|
|
||||||
if payload == 'ON':
|
if payload == "ON":
|
||||||
if not camera_metrics[camera_name]["detection_enabled"].value:
|
if not camera_metrics[camera_name]["detection_enabled"].value:
|
||||||
logger.info(f"Turning on detection for {camera_name} via mqtt")
|
logger.info(f"Turning on detection for {camera_name} via mqtt")
|
||||||
camera_metrics[camera_name]["detection_enabled"].value = True
|
camera_metrics[camera_name]["detection_enabled"].value = True
|
||||||
detect_settings._enabled = True
|
detect_settings.enabled = True
|
||||||
elif payload == 'OFF':
|
elif payload == "OFF":
|
||||||
if camera_metrics[camera_name]["detection_enabled"].value:
|
if camera_metrics[camera_name]["detection_enabled"].value:
|
||||||
logger.info(f"Turning off detection for {camera_name} via mqtt")
|
logger.info(f"Turning off detection for {camera_name} via mqtt")
|
||||||
camera_metrics[camera_name]["detection_enabled"].value = False
|
camera_metrics[camera_name]["detection_enabled"].value = False
|
||||||
detect_settings._enabled = False
|
detect_settings.enabled = False
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Received unsupported value at {message.topic}: {payload}")
|
logger.warning(f"Received unsupported value at {message.topic}: {payload}")
|
||||||
|
|
||||||
@ -88,22 +89,40 @@ def create_mqtt_client(config: FrigateConfig, camera_metrics):
|
|||||||
elif rc == 5:
|
elif rc == 5:
|
||||||
logger.error("MQTT Not authorized")
|
logger.error("MQTT Not authorized")
|
||||||
else:
|
else:
|
||||||
logger.error("Unable to connect to MQTT: Connection refused. Error code: " + str(rc))
|
logger.error(
|
||||||
|
"Unable to connect to MQTT: Connection refused. Error code: "
|
||||||
|
+ str(rc)
|
||||||
|
)
|
||||||
|
|
||||||
logger.info("MQTT connected")
|
logger.info("MQTT connected")
|
||||||
client.subscribe(f"{mqtt_config.topic_prefix}/#")
|
client.subscribe(f"{mqtt_config.topic_prefix}/#")
|
||||||
client.publish(mqtt_config.topic_prefix+'/available', 'online', retain=True)
|
client.publish(mqtt_config.topic_prefix + "/available", "online", retain=True)
|
||||||
|
|
||||||
client = mqtt.Client(client_id=mqtt_config.client_id)
|
client = mqtt.Client(client_id=mqtt_config.client_id)
|
||||||
client.on_connect = on_connect
|
client.on_connect = on_connect
|
||||||
client.will_set(mqtt_config.topic_prefix+'/available', payload='offline', qos=1, retain=True)
|
client.will_set(
|
||||||
|
mqtt_config.topic_prefix + "/available", payload="offline", qos=1, retain=True
|
||||||
|
)
|
||||||
|
|
||||||
# register callbacks
|
# register callbacks
|
||||||
for name in config.cameras.keys():
|
for name in config.cameras.keys():
|
||||||
client.message_callback_add(f"{mqtt_config.topic_prefix}/{name}/clips/set", on_clips_command)
|
client.message_callback_add(
|
||||||
client.message_callback_add(f"{mqtt_config.topic_prefix}/{name}/snapshots/set", on_snapshots_command)
|
f"{mqtt_config.topic_prefix}/{name}/clips/set", on_clips_command
|
||||||
client.message_callback_add(f"{mqtt_config.topic_prefix}/{name}/detect/set", on_detect_command)
|
)
|
||||||
|
client.message_callback_add(
|
||||||
|
f"{mqtt_config.topic_prefix}/{name}/snapshots/set", on_snapshots_command
|
||||||
|
)
|
||||||
|
client.message_callback_add(
|
||||||
|
f"{mqtt_config.topic_prefix}/{name}/detect/set", on_detect_command
|
||||||
|
)
|
||||||
|
|
||||||
|
if not mqtt_config.tls_ca_certs is None:
|
||||||
|
if not mqtt_config.tls_client_cert is None and not mqtt_config.tls_client_key is None:
|
||||||
|
client.tls_set(mqtt_config.tls_ca_certs, mqtt_config.tls_client_cert, mqtt_config.tls_client_key)
|
||||||
|
else:
|
||||||
|
client.tls_set(mqtt_config.tls_ca_certs)
|
||||||
|
if not mqtt_config.tls_insecure is None:
|
||||||
|
client.tls_insecure_set(mqtt_config.tls_insecure)
|
||||||
if not mqtt_config.user is None:
|
if not mqtt_config.user is None:
|
||||||
client.username_pw_set(mqtt_config.user, password=mqtt_config.password)
|
client.username_pw_set(mqtt_config.user, password=mqtt_config.password)
|
||||||
try:
|
try:
|
||||||
@ -115,10 +134,20 @@ def create_mqtt_client(config: FrigateConfig, camera_metrics):
|
|||||||
client.loop_start()
|
client.loop_start()
|
||||||
|
|
||||||
for name in config.cameras.keys():
|
for name in config.cameras.keys():
|
||||||
client.publish(f"{mqtt_config.topic_prefix}/{name}/clips/state", 'ON' if config.cameras[name].clips.enabled else 'OFF', retain=True)
|
client.publish(
|
||||||
client.publish(f"{mqtt_config.topic_prefix}/{name}/snapshots/state", 'ON' if config.cameras[name].snapshots.enabled else 'OFF', retain=True)
|
f"{mqtt_config.topic_prefix}/{name}/clips/state",
|
||||||
client.publish(f"{mqtt_config.topic_prefix}/{name}/detect/state", 'ON' if config.cameras[name].detect.enabled else 'OFF', retain=True)
|
"ON" if config.cameras[name].clips.enabled else "OFF",
|
||||||
|
retain=True,
|
||||||
client.subscribe(f"{mqtt_config.topic_prefix}/#")
|
)
|
||||||
|
client.publish(
|
||||||
|
f"{mqtt_config.topic_prefix}/{name}/snapshots/state",
|
||||||
|
"ON" if config.cameras[name].snapshots.enabled else "OFF",
|
||||||
|
retain=True,
|
||||||
|
)
|
||||||
|
client.publish(
|
||||||
|
f"{mqtt_config.topic_prefix}/{name}/detect/state",
|
||||||
|
"ON" if config.cameras[name].detect.enabled else "OFF",
|
||||||
|
retain=True,
|
||||||
|
)
|
||||||
|
|
||||||
return client
|
return client
|
||||||
|
|||||||
@ -24,44 +24,49 @@ from frigate.util import SharedMemoryFrameManager, draw_box_with_label, calculat
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PATH_TO_LABELS = '/labelmap.txt'
|
PATH_TO_LABELS = "/labelmap.txt"
|
||||||
|
|
||||||
LABELS = load_labels(PATH_TO_LABELS)
|
LABELS = load_labels(PATH_TO_LABELS)
|
||||||
cmap = plt.cm.get_cmap('tab10', len(LABELS.keys()))
|
cmap = plt.cm.get_cmap("tab10", len(LABELS.keys()))
|
||||||
|
|
||||||
COLOR_MAP = {}
|
COLOR_MAP = {}
|
||||||
for key, val in LABELS.items():
|
for key, val in LABELS.items():
|
||||||
COLOR_MAP[val] = tuple(int(round(255 * c)) for c in cmap(key)[:3])
|
COLOR_MAP[val] = tuple(int(round(255 * c)) for c in cmap(key)[:3])
|
||||||
|
|
||||||
|
|
||||||
def on_edge(box, frame_shape):
|
def on_edge(box, frame_shape):
|
||||||
if (
|
if (
|
||||||
box[0] == 0 or
|
box[0] == 0
|
||||||
box[1] == 0 or
|
or box[1] == 0
|
||||||
box[2] == frame_shape[1]-1 or
|
or box[2] == frame_shape[1] - 1
|
||||||
box[3] == frame_shape[0]-1
|
or box[3] == frame_shape[0] - 1
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def is_better_thumbnail(current_thumb, new_obj, frame_shape) -> bool:
|
def is_better_thumbnail(current_thumb, new_obj, frame_shape) -> bool:
|
||||||
# larger is better
|
# larger is better
|
||||||
# cutoff images are less ideal, but they should also be smaller?
|
# cutoff images are less ideal, but they should also be smaller?
|
||||||
# better scores are obviously better too
|
# better scores are obviously better too
|
||||||
|
|
||||||
# if the new_thumb is on an edge, and the current thumb is not
|
# if the new_thumb is on an edge, and the current thumb is not
|
||||||
if on_edge(new_obj['box'], frame_shape) and not on_edge(current_thumb['box'], frame_shape):
|
if on_edge(new_obj["box"], frame_shape) and not on_edge(
|
||||||
|
current_thumb["box"], frame_shape
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# if the score is better by more than 5%
|
# if the score is better by more than 5%
|
||||||
if new_obj['score'] > current_thumb['score']+.05:
|
if new_obj["score"] > current_thumb["score"] + 0.05:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# if the area is 10% larger
|
# if the area is 10% larger
|
||||||
if new_obj['area'] > current_thumb['area']*1.1:
|
if new_obj["area"] > current_thumb["area"] * 1.1:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
class TrackedObject():
|
|
||||||
|
class TrackedObject:
|
||||||
def __init__(self, camera, camera_config: CameraConfig, frame_cache, obj_data):
|
def __init__(self, camera, camera_config: CameraConfig, frame_cache, obj_data):
|
||||||
self.obj_data = obj_data
|
self.obj_data = obj_data
|
||||||
self.camera = camera
|
self.camera = camera
|
||||||
@ -78,33 +83,31 @@ class TrackedObject():
|
|||||||
self.previous = self.to_dict()
|
self.previous = self.to_dict()
|
||||||
|
|
||||||
# start the score history
|
# start the score history
|
||||||
self.score_history = [self.obj_data['score']]
|
self.score_history = [self.obj_data["score"]]
|
||||||
|
|
||||||
def _is_false_positive(self):
|
def _is_false_positive(self):
|
||||||
# once a true positive, always a true positive
|
# once a true positive, always a true positive
|
||||||
if not self.false_positive:
|
if not self.false_positive:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
threshold = self.camera_config.objects.filters[self.obj_data['label']].threshold
|
threshold = self.camera_config.objects.filters[self.obj_data["label"]].threshold
|
||||||
if self.computed_score < threshold:
|
return self.computed_score < threshold
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def compute_score(self):
|
def compute_score(self):
|
||||||
scores = self.score_history[:]
|
scores = self.score_history[:]
|
||||||
# pad with zeros if you dont have at least 3 scores
|
# pad with zeros if you dont have at least 3 scores
|
||||||
if len(scores) < 3:
|
if len(scores) < 3:
|
||||||
scores += [0.0]*(3 - len(scores))
|
scores += [0.0] * (3 - len(scores))
|
||||||
return median(scores)
|
return median(scores)
|
||||||
|
|
||||||
def update(self, current_frame_time, obj_data):
|
def update(self, current_frame_time, obj_data):
|
||||||
significant_update = False
|
significant_update = False
|
||||||
self.obj_data.update(obj_data)
|
self.obj_data.update(obj_data)
|
||||||
# if the object is not in the current frame, add a 0.0 to the score history
|
# if the object is not in the current frame, add a 0.0 to the score history
|
||||||
if self.obj_data['frame_time'] != current_frame_time:
|
if self.obj_data["frame_time"] != current_frame_time:
|
||||||
self.score_history.append(0.0)
|
self.score_history.append(0.0)
|
||||||
else:
|
else:
|
||||||
self.score_history.append(self.obj_data['score'])
|
self.score_history.append(self.obj_data["score"])
|
||||||
# only keep the last 10 scores
|
# only keep the last 10 scores
|
||||||
if len(self.score_history) > 10:
|
if len(self.score_history) > 10:
|
||||||
self.score_history = self.score_history[-10:]
|
self.score_history = self.score_history[-10:]
|
||||||
@ -117,27 +120,26 @@ class TrackedObject():
|
|||||||
|
|
||||||
if not self.false_positive:
|
if not self.false_positive:
|
||||||
# determine if this frame is a better thumbnail
|
# determine if this frame is a better thumbnail
|
||||||
if (
|
if self.thumbnail_data is None or is_better_thumbnail(
|
||||||
self.thumbnail_data is None
|
self.thumbnail_data, self.obj_data, self.camera_config.frame_shape
|
||||||
or is_better_thumbnail(self.thumbnail_data, self.obj_data, self.camera_config.frame_shape)
|
|
||||||
):
|
):
|
||||||
self.thumbnail_data = {
|
self.thumbnail_data = {
|
||||||
'frame_time': self.obj_data['frame_time'],
|
"frame_time": self.obj_data["frame_time"],
|
||||||
'box': self.obj_data['box'],
|
"box": self.obj_data["box"],
|
||||||
'area': self.obj_data['area'],
|
"area": self.obj_data["area"],
|
||||||
'region': self.obj_data['region'],
|
"region": self.obj_data["region"],
|
||||||
'score': self.obj_data['score']
|
"score": self.obj_data["score"],
|
||||||
}
|
}
|
||||||
significant_update = True
|
significant_update = True
|
||||||
|
|
||||||
# check zones
|
# check zones
|
||||||
current_zones = []
|
current_zones = []
|
||||||
bottom_center = (self.obj_data['centroid'][0], self.obj_data['box'][3])
|
bottom_center = (self.obj_data["centroid"][0], self.obj_data["box"][3])
|
||||||
# check each zone
|
# check each zone
|
||||||
for name, zone in self.camera_config.zones.items():
|
for name, zone in self.camera_config.zones.items():
|
||||||
contour = zone.contour
|
contour = zone.contour
|
||||||
# check if the object is in the zone
|
# check if the object is in the zone
|
||||||
if (cv2.pointPolygonTest(contour, bottom_center, False) >= 0):
|
if cv2.pointPolygonTest(contour, bottom_center, False) >= 0:
|
||||||
# if the object passed the filters once, dont apply again
|
# if the object passed the filters once, dont apply again
|
||||||
if name in self.current_zones or not zone_filtered(self, zone.filters):
|
if name in self.current_zones or not zone_filtered(self, zone.filters):
|
||||||
current_zones.append(name)
|
current_zones.append(name)
|
||||||
@ -151,92 +153,134 @@ class TrackedObject():
|
|||||||
return significant_update
|
return significant_update
|
||||||
|
|
||||||
def to_dict(self, include_thumbnail: bool = False):
|
def to_dict(self, include_thumbnail: bool = False):
|
||||||
return {
|
event = {
|
||||||
'id': self.obj_data['id'],
|
"id": self.obj_data["id"],
|
||||||
'camera': self.camera,
|
"camera": self.camera,
|
||||||
'frame_time': self.obj_data['frame_time'],
|
"frame_time": self.obj_data["frame_time"],
|
||||||
'label': self.obj_data['label'],
|
"label": self.obj_data["label"],
|
||||||
'top_score': self.top_score,
|
"top_score": self.top_score,
|
||||||
'false_positive': self.false_positive,
|
"false_positive": self.false_positive,
|
||||||
'start_time': self.obj_data['start_time'],
|
"start_time": self.obj_data["start_time"],
|
||||||
'end_time': self.obj_data.get('end_time', None),
|
"end_time": self.obj_data.get("end_time", None),
|
||||||
'score': self.obj_data['score'],
|
"score": self.obj_data["score"],
|
||||||
'box': self.obj_data['box'],
|
"box": self.obj_data["box"],
|
||||||
'area': self.obj_data['area'],
|
"area": self.obj_data["area"],
|
||||||
'region': self.obj_data['region'],
|
"region": self.obj_data["region"],
|
||||||
'current_zones': self.current_zones.copy(),
|
"current_zones": self.current_zones.copy(),
|
||||||
'entered_zones': list(self.entered_zones).copy(),
|
"entered_zones": list(self.entered_zones).copy(),
|
||||||
'thumbnail': base64.b64encode(self.get_thumbnail()).decode('utf-8') if include_thumbnail else None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_thumbnail(self):
|
if include_thumbnail:
|
||||||
if self.thumbnail_data is None or not self.thumbnail_data['frame_time'] in self.frame_cache:
|
event["thumbnail"] = base64.b64encode(self.get_thumbnail()).decode("utf-8")
|
||||||
ret, jpg = cv2.imencode('.jpg', np.zeros((175,175,3), np.uint8))
|
|
||||||
|
|
||||||
jpg_bytes = self.get_jpg_bytes(timestamp=False, bounding_box=False, crop=True, height=175)
|
return event
|
||||||
|
|
||||||
|
def get_thumbnail(self):
|
||||||
|
if (
|
||||||
|
self.thumbnail_data is None
|
||||||
|
or self.thumbnail_data["frame_time"] not in self.frame_cache
|
||||||
|
):
|
||||||
|
ret, jpg = cv2.imencode(".jpg", np.zeros((175, 175, 3), np.uint8))
|
||||||
|
|
||||||
|
jpg_bytes = self.get_jpg_bytes(
|
||||||
|
timestamp=False, bounding_box=False, crop=True, height=175
|
||||||
|
)
|
||||||
|
|
||||||
if jpg_bytes:
|
if jpg_bytes:
|
||||||
return jpg_bytes
|
return jpg_bytes
|
||||||
else:
|
else:
|
||||||
ret, jpg = cv2.imencode('.jpg', np.zeros((175,175,3), np.uint8))
|
ret, jpg = cv2.imencode(".jpg", np.zeros((175, 175, 3), np.uint8))
|
||||||
return jpg.tobytes()
|
return jpg.tobytes()
|
||||||
|
|
||||||
def get_jpg_bytes(self, timestamp=False, bounding_box=False, crop=False, height=None):
|
def get_jpg_bytes(
|
||||||
|
self, timestamp=False, bounding_box=False, crop=False, height=None
|
||||||
|
):
|
||||||
if self.thumbnail_data is None:
|
if self.thumbnail_data is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
best_frame = cv2.cvtColor(self.frame_cache[self.thumbnail_data['frame_time']], cv2.COLOR_YUV2BGR_I420)
|
best_frame = cv2.cvtColor(
|
||||||
|
self.frame_cache[self.thumbnail_data["frame_time"]],
|
||||||
|
cv2.COLOR_YUV2BGR_I420,
|
||||||
|
)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
logger.warning(f"Unable to create jpg because frame {self.thumbnail_data['frame_time']} is not in the cache")
|
logger.warning(
|
||||||
|
f"Unable to create jpg because frame {self.thumbnail_data['frame_time']} is not in the cache"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if bounding_box:
|
if bounding_box:
|
||||||
thickness = 2
|
thickness = 2
|
||||||
color = COLOR_MAP[self.obj_data['label']]
|
color = COLOR_MAP[self.obj_data["label"]]
|
||||||
|
|
||||||
# draw the bounding boxes on the frame
|
# draw the bounding boxes on the frame
|
||||||
box = self.thumbnail_data['box']
|
box = self.thumbnail_data["box"]
|
||||||
draw_box_with_label(best_frame, box[0], box[1], box[2], box[3], self.obj_data['label'], f"{int(self.thumbnail_data['score']*100)}% {int(self.thumbnail_data['area'])}", thickness=thickness, color=color)
|
draw_box_with_label(
|
||||||
|
best_frame,
|
||||||
|
box[0],
|
||||||
|
box[1],
|
||||||
|
box[2],
|
||||||
|
box[3],
|
||||||
|
self.obj_data["label"],
|
||||||
|
f"{int(self.thumbnail_data['score']*100)}% {int(self.thumbnail_data['area'])}",
|
||||||
|
thickness=thickness,
|
||||||
|
color=color,
|
||||||
|
)
|
||||||
|
|
||||||
if crop:
|
if crop:
|
||||||
box = self.thumbnail_data['box']
|
box = self.thumbnail_data["box"]
|
||||||
region = calculate_region(best_frame.shape, box[0], box[1], box[2], box[3], 1.1)
|
region = calculate_region(
|
||||||
best_frame = best_frame[region[1]:region[3], region[0]:region[2]]
|
best_frame.shape, box[0], box[1], box[2], box[3], 1.1
|
||||||
|
)
|
||||||
|
best_frame = best_frame[region[1] : region[3], region[0] : region[2]]
|
||||||
|
|
||||||
if height:
|
if height:
|
||||||
width = int(height*best_frame.shape[1]/best_frame.shape[0])
|
width = int(height * best_frame.shape[1] / best_frame.shape[0])
|
||||||
best_frame = cv2.resize(best_frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
|
best_frame = cv2.resize(
|
||||||
|
best_frame, dsize=(width, height), interpolation=cv2.INTER_AREA
|
||||||
|
)
|
||||||
|
|
||||||
if timestamp:
|
if timestamp:
|
||||||
time_to_show = datetime.datetime.fromtimestamp(self.thumbnail_data['frame_time']).strftime("%m/%d/%Y %H:%M:%S")
|
time_to_show = datetime.datetime.fromtimestamp(
|
||||||
size = cv2.getTextSize(time_to_show, cv2.FONT_HERSHEY_SIMPLEX, fontScale=1, thickness=2)
|
self.thumbnail_data["frame_time"]
|
||||||
|
).strftime("%m/%d/%Y %H:%M:%S")
|
||||||
|
size = cv2.getTextSize(
|
||||||
|
time_to_show, cv2.FONT_HERSHEY_SIMPLEX, fontScale=1, thickness=2
|
||||||
|
)
|
||||||
text_width = size[0][0]
|
text_width = size[0][0]
|
||||||
desired_size = max(150, 0.33*best_frame.shape[1])
|
desired_size = max(150, 0.33 * best_frame.shape[1])
|
||||||
font_scale = desired_size/text_width
|
font_scale = desired_size / text_width
|
||||||
cv2.putText(best_frame, time_to_show, (5, best_frame.shape[0]-7), cv2.FONT_HERSHEY_SIMPLEX,
|
cv2.putText(
|
||||||
fontScale=font_scale, color=(255, 255, 255), thickness=2)
|
best_frame,
|
||||||
|
time_to_show,
|
||||||
|
(5, best_frame.shape[0] - 7),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
fontScale=font_scale,
|
||||||
|
color=(255, 255, 255),
|
||||||
|
thickness=2,
|
||||||
|
)
|
||||||
|
|
||||||
ret, jpg = cv2.imencode('.jpg', best_frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
|
ret, jpg = cv2.imencode(".jpg", best_frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
|
||||||
if ret:
|
if ret:
|
||||||
return jpg.tobytes()
|
return jpg.tobytes()
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def zone_filtered(obj: TrackedObject, object_config):
|
def zone_filtered(obj: TrackedObject, object_config):
|
||||||
object_name = obj.obj_data['label']
|
object_name = obj.obj_data["label"]
|
||||||
|
|
||||||
if object_name in object_config:
|
if object_name in object_config:
|
||||||
obj_settings = object_config[object_name]
|
obj_settings = object_config[object_name]
|
||||||
|
|
||||||
# if the min area is larger than the
|
# if the min area is larger than the
|
||||||
# detected object, don't add it to detected objects
|
# detected object, don't add it to detected objects
|
||||||
if obj_settings.min_area > obj.obj_data['area']:
|
if obj_settings.min_area > obj.obj_data["area"]:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# if the detected object is larger than the
|
# if the detected object is larger than the
|
||||||
# max area, don't add it to detected objects
|
# max area, don't add it to detected objects
|
||||||
if obj_settings.max_area < obj.obj_data['area']:
|
if obj_settings.max_area < obj.obj_data["area"]:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# if the score is lower than the threshold, skip
|
# if the score is lower than the threshold, skip
|
||||||
@ -245,70 +289,109 @@ def zone_filtered(obj: TrackedObject, object_config):
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# Maintains the state of a camera
|
# Maintains the state of a camera
|
||||||
class CameraState():
|
class CameraState:
|
||||||
def __init__(self, name, config, frame_manager):
|
def __init__(self, name, config, frame_manager):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.config = config
|
self.config = config
|
||||||
self.camera_config = config.cameras[name]
|
self.camera_config = config.cameras[name]
|
||||||
self.frame_manager = frame_manager
|
self.frame_manager = frame_manager
|
||||||
self.best_objects: Dict[str, TrackedObject] = {}
|
self.best_objects: Dict[str, TrackedObject] = {}
|
||||||
self.object_counts = defaultdict(lambda: 0)
|
self.object_counts = defaultdict(int)
|
||||||
self.tracked_objects: Dict[str, TrackedObject] = {}
|
self.tracked_objects: Dict[str, TrackedObject] = {}
|
||||||
self.frame_cache = {}
|
self.frame_cache = {}
|
||||||
self.zone_objects = defaultdict(lambda: [])
|
self.zone_objects = defaultdict(list)
|
||||||
self._current_frame = np.zeros(self.camera_config.frame_shape_yuv, np.uint8)
|
self._current_frame = np.zeros(self.camera_config.frame_shape_yuv, np.uint8)
|
||||||
self.current_frame_lock = threading.Lock()
|
self.current_frame_lock = threading.Lock()
|
||||||
self.current_frame_time = 0.0
|
self.current_frame_time = 0.0
|
||||||
self.motion_boxes = []
|
self.motion_boxes = []
|
||||||
self.regions = []
|
self.regions = []
|
||||||
self.previous_frame_id = None
|
self.previous_frame_id = None
|
||||||
self.callbacks = defaultdict(lambda: [])
|
self.callbacks = defaultdict(list)
|
||||||
|
|
||||||
def get_current_frame(self, draw_options={}):
|
def get_current_frame(self, draw_options={}):
|
||||||
with self.current_frame_lock:
|
with self.current_frame_lock:
|
||||||
frame_copy = np.copy(self._current_frame)
|
frame_copy = np.copy(self._current_frame)
|
||||||
frame_time = self.current_frame_time
|
frame_time = self.current_frame_time
|
||||||
tracked_objects = {k: v.to_dict() for k,v in self.tracked_objects.items()}
|
tracked_objects = {k: v.to_dict() for k, v in self.tracked_objects.items()}
|
||||||
motion_boxes = self.motion_boxes.copy()
|
motion_boxes = self.motion_boxes.copy()
|
||||||
regions = self.regions.copy()
|
regions = self.regions.copy()
|
||||||
|
|
||||||
frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420)
|
frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420)
|
||||||
# draw on the frame
|
# draw on the frame
|
||||||
if draw_options.get('bounding_boxes'):
|
if draw_options.get("bounding_boxes"):
|
||||||
# draw the bounding boxes on the frame
|
# draw the bounding boxes on the frame
|
||||||
for obj in tracked_objects.values():
|
for obj in tracked_objects.values():
|
||||||
|
if obj["frame_time"] == frame_time:
|
||||||
thickness = 2
|
thickness = 2
|
||||||
color = COLOR_MAP[obj['label']]
|
color = COLOR_MAP[obj["label"]]
|
||||||
|
else:
|
||||||
if obj['frame_time'] != frame_time:
|
|
||||||
thickness = 1
|
thickness = 1
|
||||||
color = (255,0,0)
|
color = (255, 0, 0)
|
||||||
|
|
||||||
# draw the bounding boxes on the frame
|
# draw the bounding boxes on the frame
|
||||||
box = obj['box']
|
box = obj["box"]
|
||||||
draw_box_with_label(frame_copy, box[0], box[1], box[2], box[3], obj['label'], f"{int(obj['score']*100)}% {int(obj['area'])}", thickness=thickness, color=color)
|
draw_box_with_label(
|
||||||
|
frame_copy,
|
||||||
|
box[0],
|
||||||
|
box[1],
|
||||||
|
box[2],
|
||||||
|
box[3],
|
||||||
|
obj["label"],
|
||||||
|
f"{obj['score']:.0%} {int(obj['area'])}",
|
||||||
|
thickness=thickness,
|
||||||
|
color=color,
|
||||||
|
)
|
||||||
|
|
||||||
if draw_options.get('regions'):
|
if draw_options.get("regions"):
|
||||||
for region in regions:
|
for region in regions:
|
||||||
cv2.rectangle(frame_copy, (region[0], region[1]), (region[2], region[3]), (0,255,0), 2)
|
cv2.rectangle(
|
||||||
|
frame_copy,
|
||||||
|
(region[0], region[1]),
|
||||||
|
(region[2], region[3]),
|
||||||
|
(0, 255, 0),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
if draw_options.get('zones'):
|
if draw_options.get("zones"):
|
||||||
for name, zone in self.camera_config.zones.items():
|
for name, zone in self.camera_config.zones.items():
|
||||||
thickness = 8 if any([name in obj['current_zones'] for obj in tracked_objects.values()]) else 2
|
thickness = (
|
||||||
|
8
|
||||||
|
if any(
|
||||||
|
name in obj["current_zones"] for obj in tracked_objects.values()
|
||||||
|
)
|
||||||
|
else 2
|
||||||
|
)
|
||||||
cv2.drawContours(frame_copy, [zone.contour], -1, zone.color, thickness)
|
cv2.drawContours(frame_copy, [zone.contour], -1, zone.color, thickness)
|
||||||
|
|
||||||
if draw_options.get('mask'):
|
if draw_options.get("mask"):
|
||||||
mask_overlay = np.where(self.camera_config.motion.mask==[0])
|
mask_overlay = np.where(self.camera_config.motion.mask == [0])
|
||||||
frame_copy[mask_overlay] = [0,0,0]
|
frame_copy[mask_overlay] = [0, 0, 0]
|
||||||
|
|
||||||
if draw_options.get('motion_boxes'):
|
if draw_options.get("motion_boxes"):
|
||||||
for m_box in motion_boxes:
|
for m_box in motion_boxes:
|
||||||
cv2.rectangle(frame_copy, (m_box[0], m_box[1]), (m_box[2], m_box[3]), (0,0,255), 2)
|
cv2.rectangle(
|
||||||
|
frame_copy,
|
||||||
|
(m_box[0], m_box[1]),
|
||||||
|
(m_box[2], m_box[3]),
|
||||||
|
(0, 0, 255),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
if draw_options.get('timestamp'):
|
if draw_options.get("timestamp"):
|
||||||
time_to_show = datetime.datetime.fromtimestamp(frame_time).strftime("%m/%d/%Y %H:%M:%S")
|
time_to_show = datetime.datetime.fromtimestamp(frame_time).strftime(
|
||||||
cv2.putText(frame_copy, time_to_show, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, fontScale=.8, color=(255, 255, 255), thickness=2)
|
"%m/%d/%Y %H:%M:%S"
|
||||||
|
)
|
||||||
|
cv2.putText(
|
||||||
|
frame_copy,
|
||||||
|
time_to_show,
|
||||||
|
(10, 30),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
fontScale=0.8,
|
||||||
|
color=(255, 255, 255),
|
||||||
|
thickness=2,
|
||||||
|
)
|
||||||
|
|
||||||
return frame_copy
|
return frame_copy
|
||||||
|
|
||||||
@ -319,112 +402,156 @@ class CameraState():
|
|||||||
self.callbacks[event_type].append(callback)
|
self.callbacks[event_type].append(callback)
|
||||||
|
|
||||||
def update(self, frame_time, current_detections, motion_boxes, regions):
|
def update(self, frame_time, current_detections, motion_boxes, regions):
|
||||||
self.current_frame_time = frame_time
|
|
||||||
self.motion_boxes = motion_boxes
|
|
||||||
self.regions = regions
|
|
||||||
# get the new frame
|
# get the new frame
|
||||||
frame_id = f"{self.name}{frame_time}"
|
frame_id = f"{self.name}{frame_time}"
|
||||||
current_frame = self.frame_manager.get(frame_id, self.camera_config.frame_shape_yuv)
|
current_frame = self.frame_manager.get(
|
||||||
|
frame_id, self.camera_config.frame_shape_yuv
|
||||||
|
)
|
||||||
|
|
||||||
current_ids = current_detections.keys()
|
tracked_objects = self.tracked_objects.copy()
|
||||||
previous_ids = self.tracked_objects.keys()
|
current_ids = set(current_detections.keys())
|
||||||
removed_ids = list(set(previous_ids).difference(current_ids))
|
previous_ids = set(tracked_objects.keys())
|
||||||
new_ids = list(set(current_ids).difference(previous_ids))
|
removed_ids = previous_ids.difference(current_ids)
|
||||||
updated_ids = list(set(current_ids).intersection(previous_ids))
|
new_ids = current_ids.difference(previous_ids)
|
||||||
|
updated_ids = current_ids.intersection(previous_ids)
|
||||||
|
|
||||||
for id in new_ids:
|
for id in new_ids:
|
||||||
new_obj = self.tracked_objects[id] = TrackedObject(self.name, self.camera_config, self.frame_cache, current_detections[id])
|
new_obj = tracked_objects[id] = TrackedObject(
|
||||||
|
self.name, self.camera_config, self.frame_cache, current_detections[id]
|
||||||
|
)
|
||||||
|
|
||||||
# call event handlers
|
# call event handlers
|
||||||
for c in self.callbacks['start']:
|
for c in self.callbacks["start"]:
|
||||||
c(self.name, new_obj, frame_time)
|
c(self.name, new_obj, frame_time)
|
||||||
|
|
||||||
for id in updated_ids:
|
for id in updated_ids:
|
||||||
updated_obj = self.tracked_objects[id]
|
updated_obj = tracked_objects[id]
|
||||||
significant_update = updated_obj.update(frame_time, current_detections[id])
|
significant_update = updated_obj.update(frame_time, current_detections[id])
|
||||||
|
|
||||||
if significant_update:
|
if significant_update:
|
||||||
# ensure this frame is stored in the cache
|
# ensure this frame is stored in the cache
|
||||||
if updated_obj.thumbnail_data['frame_time'] == frame_time and frame_time not in self.frame_cache:
|
if (
|
||||||
|
updated_obj.thumbnail_data["frame_time"] == frame_time
|
||||||
|
and frame_time not in self.frame_cache
|
||||||
|
):
|
||||||
self.frame_cache[frame_time] = np.copy(current_frame)
|
self.frame_cache[frame_time] = np.copy(current_frame)
|
||||||
|
|
||||||
updated_obj.last_updated = frame_time
|
updated_obj.last_updated = frame_time
|
||||||
|
|
||||||
# if it has been more than 5 seconds since the last publish
|
# if it has been more than 5 seconds since the last publish
|
||||||
# and the last update is greater than the last publish
|
# and the last update is greater than the last publish
|
||||||
if frame_time - updated_obj.last_published > 5 and updated_obj.last_updated > updated_obj.last_published:
|
if (
|
||||||
|
frame_time - updated_obj.last_published > 5
|
||||||
|
and updated_obj.last_updated > updated_obj.last_published
|
||||||
|
):
|
||||||
# call event handlers
|
# call event handlers
|
||||||
for c in self.callbacks['update']:
|
for c in self.callbacks["update"]:
|
||||||
c(self.name, updated_obj, frame_time)
|
c(self.name, updated_obj, frame_time)
|
||||||
updated_obj.last_published = frame_time
|
updated_obj.last_published = frame_time
|
||||||
|
|
||||||
for id in removed_ids:
|
for id in removed_ids:
|
||||||
# publish events to mqtt
|
# publish events to mqtt
|
||||||
removed_obj = self.tracked_objects[id]
|
removed_obj = tracked_objects[id]
|
||||||
if not 'end_time' in removed_obj.obj_data:
|
if not "end_time" in removed_obj.obj_data:
|
||||||
removed_obj.obj_data['end_time'] = frame_time
|
removed_obj.obj_data["end_time"] = frame_time
|
||||||
for c in self.callbacks['end']:
|
for c in self.callbacks["end"]:
|
||||||
c(self.name, removed_obj, frame_time)
|
c(self.name, removed_obj, frame_time)
|
||||||
|
|
||||||
# TODO: can i switch to looking this up and only changing when an event ends?
|
# TODO: can i switch to looking this up and only changing when an event ends?
|
||||||
# maintain best objects
|
# maintain best objects
|
||||||
for obj in self.tracked_objects.values():
|
for obj in tracked_objects.values():
|
||||||
object_type = obj.obj_data['label']
|
object_type = obj.obj_data["label"]
|
||||||
# if the object's thumbnail is not from the current frame
|
# if the object's thumbnail is not from the current frame
|
||||||
if obj.false_positive or obj.thumbnail_data['frame_time'] != self.current_frame_time:
|
if obj.false_positive or obj.thumbnail_data["frame_time"] != frame_time:
|
||||||
continue
|
continue
|
||||||
if object_type in self.best_objects:
|
if object_type in self.best_objects:
|
||||||
current_best = self.best_objects[object_type]
|
current_best = self.best_objects[object_type]
|
||||||
now = datetime.datetime.now().timestamp()
|
now = datetime.datetime.now().timestamp()
|
||||||
# if the object is a higher score than the current best score
|
# if the object is a higher score than the current best score
|
||||||
# or the current object is older than desired, use the new object
|
# or the current object is older than desired, use the new object
|
||||||
if (is_better_thumbnail(current_best.thumbnail_data, obj.thumbnail_data, self.camera_config.frame_shape)
|
if (
|
||||||
or (now - current_best.thumbnail_data['frame_time']) > self.camera_config.best_image_timeout):
|
is_better_thumbnail(
|
||||||
|
current_best.thumbnail_data,
|
||||||
|
obj.thumbnail_data,
|
||||||
|
self.camera_config.frame_shape,
|
||||||
|
)
|
||||||
|
or (now - current_best.thumbnail_data["frame_time"])
|
||||||
|
> self.camera_config.best_image_timeout
|
||||||
|
):
|
||||||
self.best_objects[object_type] = obj
|
self.best_objects[object_type] = obj
|
||||||
for c in self.callbacks['snapshot']:
|
for c in self.callbacks["snapshot"]:
|
||||||
c(self.name, self.best_objects[object_type], frame_time)
|
c(self.name, self.best_objects[object_type], frame_time)
|
||||||
else:
|
else:
|
||||||
self.best_objects[object_type] = obj
|
self.best_objects[object_type] = obj
|
||||||
for c in self.callbacks['snapshot']:
|
for c in self.callbacks["snapshot"]:
|
||||||
c(self.name, self.best_objects[object_type], frame_time)
|
c(self.name, self.best_objects[object_type], frame_time)
|
||||||
|
|
||||||
# update overall camera state for each object type
|
# update overall camera state for each object type
|
||||||
obj_counter = Counter()
|
obj_counter = Counter(
|
||||||
for obj in self.tracked_objects.values():
|
obj.obj_data["label"]
|
||||||
if not obj.false_positive:
|
for obj in tracked_objects.values()
|
||||||
obj_counter[obj.obj_data['label']] += 1
|
if not obj.false_positive
|
||||||
|
)
|
||||||
|
|
||||||
# report on detected objects
|
# report on detected objects
|
||||||
for obj_name, count in obj_counter.items():
|
for obj_name, count in obj_counter.items():
|
||||||
if count != self.object_counts[obj_name]:
|
if count != self.object_counts[obj_name]:
|
||||||
self.object_counts[obj_name] = count
|
self.object_counts[obj_name] = count
|
||||||
for c in self.callbacks['object_status']:
|
for c in self.callbacks["object_status"]:
|
||||||
c(self.name, obj_name, count)
|
c(self.name, obj_name, count)
|
||||||
|
|
||||||
# expire any objects that are >0 and no longer detected
|
# expire any objects that are >0 and no longer detected
|
||||||
expired_objects = [obj_name for obj_name, count in self.object_counts.items() if count > 0 and not obj_name in obj_counter]
|
expired_objects = [
|
||||||
|
obj_name
|
||||||
|
for obj_name, count in self.object_counts.items()
|
||||||
|
if count > 0 and obj_name not in obj_counter
|
||||||
|
]
|
||||||
for obj_name in expired_objects:
|
for obj_name in expired_objects:
|
||||||
self.object_counts[obj_name] = 0
|
self.object_counts[obj_name] = 0
|
||||||
for c in self.callbacks['object_status']:
|
for c in self.callbacks["object_status"]:
|
||||||
c(self.name, obj_name, 0)
|
c(self.name, obj_name, 0)
|
||||||
for c in self.callbacks['snapshot']:
|
for c in self.callbacks["snapshot"]:
|
||||||
c(self.name, self.best_objects[obj_name], frame_time)
|
c(self.name, self.best_objects[obj_name], frame_time)
|
||||||
|
|
||||||
# cleanup thumbnail frame cache
|
# cleanup thumbnail frame cache
|
||||||
current_thumb_frames = set([obj.thumbnail_data['frame_time'] for obj in self.tracked_objects.values() if not obj.false_positive])
|
current_thumb_frames = {
|
||||||
current_best_frames = set([obj.thumbnail_data['frame_time'] for obj in self.best_objects.values()])
|
obj.thumbnail_data["frame_time"]
|
||||||
thumb_frames_to_delete = [t for t in self.frame_cache.keys() if not t in current_thumb_frames and not t in current_best_frames]
|
for obj in tracked_objects.values()
|
||||||
|
if not obj.false_positive
|
||||||
|
}
|
||||||
|
current_best_frames = {
|
||||||
|
obj.thumbnail_data["frame_time"] for obj in self.best_objects.values()
|
||||||
|
}
|
||||||
|
thumb_frames_to_delete = [
|
||||||
|
t
|
||||||
|
for t in self.frame_cache.keys()
|
||||||
|
if t not in current_thumb_frames and t not in current_best_frames
|
||||||
|
]
|
||||||
for t in thumb_frames_to_delete:
|
for t in thumb_frames_to_delete:
|
||||||
del self.frame_cache[t]
|
del self.frame_cache[t]
|
||||||
|
|
||||||
with self.current_frame_lock:
|
with self.current_frame_lock:
|
||||||
|
self.tracked_objects = tracked_objects
|
||||||
|
self.current_frame_time = frame_time
|
||||||
|
self.motion_boxes = motion_boxes
|
||||||
|
self.regions = regions
|
||||||
self._current_frame = current_frame
|
self._current_frame = current_frame
|
||||||
if not self.previous_frame_id is None:
|
if self.previous_frame_id is not None:
|
||||||
self.frame_manager.delete(self.previous_frame_id)
|
self.frame_manager.delete(self.previous_frame_id)
|
||||||
self.previous_frame_id = frame_id
|
self.previous_frame_id = frame_id
|
||||||
|
|
||||||
|
|
||||||
class TrackedObjectProcessor(threading.Thread):
|
class TrackedObjectProcessor(threading.Thread):
|
||||||
def __init__(self, config: FrigateConfig, client, topic_prefix, tracked_objects_queue, event_queue, event_processed_queue, stop_event):
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: FrigateConfig,
|
||||||
|
client,
|
||||||
|
topic_prefix,
|
||||||
|
tracked_objects_queue,
|
||||||
|
event_queue,
|
||||||
|
event_processed_queue,
|
||||||
|
stop_event,
|
||||||
|
):
|
||||||
threading.Thread.__init__(self)
|
threading.Thread.__init__(self)
|
||||||
self.name = "detected_frames_processor"
|
self.name = "detected_frames_processor"
|
||||||
self.config = config
|
self.config = config
|
||||||
@ -438,36 +565,55 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
self.frame_manager = SharedMemoryFrameManager()
|
self.frame_manager = SharedMemoryFrameManager()
|
||||||
|
|
||||||
def start(camera, obj: TrackedObject, current_frame_time):
|
def start(camera, obj: TrackedObject, current_frame_time):
|
||||||
self.event_queue.put(('start', camera, obj.to_dict()))
|
self.event_queue.put(("start", camera, obj.to_dict()))
|
||||||
|
|
||||||
def update(camera, obj: TrackedObject, current_frame_time):
|
def update(camera, obj: TrackedObject, current_frame_time):
|
||||||
after = obj.to_dict()
|
after = obj.to_dict()
|
||||||
message = { 'before': obj.previous, 'after': after, 'type': 'new' if obj.previous['false_positive'] else 'update' }
|
message = {
|
||||||
self.client.publish(f"{self.topic_prefix}/events", json.dumps(message), retain=False)
|
"before": obj.previous,
|
||||||
|
"after": after,
|
||||||
|
"type": "new" if obj.previous["false_positive"] else "update",
|
||||||
|
}
|
||||||
|
self.client.publish(
|
||||||
|
f"{self.topic_prefix}/events", json.dumps(message), retain=False
|
||||||
|
)
|
||||||
obj.previous = after
|
obj.previous = after
|
||||||
|
|
||||||
def end(camera, obj: TrackedObject, current_frame_time):
|
def end(camera, obj: TrackedObject, current_frame_time):
|
||||||
snapshot_config = self.config.cameras[camera].snapshots
|
snapshot_config = self.config.cameras[camera].snapshots
|
||||||
event_data = obj.to_dict(include_thumbnail=True)
|
event_data = obj.to_dict(include_thumbnail=True)
|
||||||
event_data['has_snapshot'] = False
|
event_data["has_snapshot"] = False
|
||||||
if not obj.false_positive:
|
if not obj.false_positive:
|
||||||
message = { 'before': obj.previous, 'after': obj.to_dict(), 'type': 'end' }
|
message = {
|
||||||
self.client.publish(f"{self.topic_prefix}/events", json.dumps(message), retain=False)
|
"before": obj.previous,
|
||||||
|
"after": obj.to_dict(),
|
||||||
|
"type": "end",
|
||||||
|
}
|
||||||
|
self.client.publish(
|
||||||
|
f"{self.topic_prefix}/events", json.dumps(message), retain=False
|
||||||
|
)
|
||||||
# write snapshot to disk if enabled
|
# write snapshot to disk if enabled
|
||||||
if snapshot_config.enabled and self.should_save_snapshot(camera, obj):
|
if snapshot_config.enabled and self.should_save_snapshot(camera, obj):
|
||||||
jpg_bytes = obj.get_jpg_bytes(
|
jpg_bytes = obj.get_jpg_bytes(
|
||||||
timestamp=snapshot_config.timestamp,
|
timestamp=snapshot_config.timestamp,
|
||||||
bounding_box=snapshot_config.bounding_box,
|
bounding_box=snapshot_config.bounding_box,
|
||||||
crop=snapshot_config.crop,
|
crop=snapshot_config.crop,
|
||||||
height=snapshot_config.height
|
height=snapshot_config.height,
|
||||||
)
|
)
|
||||||
if jpg_bytes is None:
|
if jpg_bytes is None:
|
||||||
logger.warning(f"Unable to save snapshot for {obj.obj_data['id']}.")
|
logger.warning(
|
||||||
|
f"Unable to save snapshot for {obj.obj_data['id']}."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
with open(os.path.join(CLIPS_DIR, f"{camera}-{obj.obj_data['id']}.jpg"), 'wb') as j:
|
with open(
|
||||||
|
os.path.join(
|
||||||
|
CLIPS_DIR, f"{camera}-{obj.obj_data['id']}.jpg"
|
||||||
|
),
|
||||||
|
"wb",
|
||||||
|
) as j:
|
||||||
j.write(jpg_bytes)
|
j.write(jpg_bytes)
|
||||||
event_data['has_snapshot'] = True
|
event_data["has_snapshot"] = True
|
||||||
self.event_queue.put(('end', camera, event_data))
|
self.event_queue.put(("end", camera, event_data))
|
||||||
|
|
||||||
def snapshot(camera, obj: TrackedObject, current_frame_time):
|
def snapshot(camera, obj: TrackedObject, current_frame_time):
|
||||||
mqtt_config = self.config.cameras[camera].mqtt
|
mqtt_config = self.config.cameras[camera].mqtt
|
||||||
@ -476,24 +622,32 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
timestamp=mqtt_config.timestamp,
|
timestamp=mqtt_config.timestamp,
|
||||||
bounding_box=mqtt_config.bounding_box,
|
bounding_box=mqtt_config.bounding_box,
|
||||||
crop=mqtt_config.crop,
|
crop=mqtt_config.crop,
|
||||||
height=mqtt_config.height
|
height=mqtt_config.height,
|
||||||
)
|
)
|
||||||
|
|
||||||
if jpg_bytes is None:
|
if jpg_bytes is None:
|
||||||
logger.warning(f"Unable to send mqtt snapshot for {obj.obj_data['id']}.")
|
logger.warning(
|
||||||
|
f"Unable to send mqtt snapshot for {obj.obj_data['id']}."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.client.publish(f"{self.topic_prefix}/{camera}/{obj.obj_data['label']}/snapshot", jpg_bytes, retain=True)
|
self.client.publish(
|
||||||
|
f"{self.topic_prefix}/{camera}/{obj.obj_data['label']}/snapshot",
|
||||||
|
jpg_bytes,
|
||||||
|
retain=True,
|
||||||
|
)
|
||||||
|
|
||||||
def object_status(camera, object_name, status):
|
def object_status(camera, object_name, status):
|
||||||
self.client.publish(f"{self.topic_prefix}/{camera}/{object_name}", status, retain=False)
|
self.client.publish(
|
||||||
|
f"{self.topic_prefix}/{camera}/{object_name}", status, retain=False
|
||||||
|
)
|
||||||
|
|
||||||
for camera in self.config.cameras.keys():
|
for camera in self.config.cameras.keys():
|
||||||
camera_state = CameraState(camera, self.config, self.frame_manager)
|
camera_state = CameraState(camera, self.config, self.frame_manager)
|
||||||
camera_state.on('start', start)
|
camera_state.on("start", start)
|
||||||
camera_state.on('update', update)
|
camera_state.on("update", update)
|
||||||
camera_state.on('end', end)
|
camera_state.on("end", end)
|
||||||
camera_state.on('snapshot', snapshot)
|
camera_state.on("snapshot", snapshot)
|
||||||
camera_state.on('object_status', object_status)
|
camera_state.on("object_status", object_status)
|
||||||
self.camera_states[camera] = camera_state
|
self.camera_states[camera] = camera_state
|
||||||
|
|
||||||
# {
|
# {
|
||||||
@ -504,13 +658,15 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
# }
|
# }
|
||||||
# }
|
# }
|
||||||
# }
|
# }
|
||||||
self.zone_data = defaultdict(lambda: defaultdict(lambda: {}))
|
self.zone_data = defaultdict(lambda: defaultdict(dict))
|
||||||
|
|
||||||
def should_save_snapshot(self, camera, obj: TrackedObject):
|
def should_save_snapshot(self, camera, obj: TrackedObject):
|
||||||
# if there are required zones and there is no overlap
|
# if there are required zones and there is no overlap
|
||||||
required_zones = self.config.cameras[camera].snapshots.required_zones
|
required_zones = self.config.cameras[camera].snapshots.required_zones
|
||||||
if len(required_zones) > 0 and not obj.entered_zones & set(required_zones):
|
if len(required_zones) > 0 and not obj.entered_zones & set(required_zones):
|
||||||
logger.debug(f"Not creating snapshot for {obj.obj_data['id']} because it did not enter required zones")
|
logger.debug(
|
||||||
|
f"Not creating snapshot for {obj.obj_data['id']} because it did not enter required zones"
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@ -519,7 +675,9 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
# if there are required zones and there is no overlap
|
# if there are required zones and there is no overlap
|
||||||
required_zones = self.config.cameras[camera].mqtt.required_zones
|
required_zones = self.config.cameras[camera].mqtt.required_zones
|
||||||
if len(required_zones) > 0 and not obj.entered_zones & set(required_zones):
|
if len(required_zones) > 0 and not obj.entered_zones & set(required_zones):
|
||||||
logger.debug(f"Not sending mqtt for {obj.obj_data['id']} because it did not enter required zones")
|
logger.debug(
|
||||||
|
f"Not sending mqtt for {obj.obj_data['id']} because it did not enter required zones"
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@ -530,7 +688,9 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
if label in camera_state.best_objects:
|
if label in camera_state.best_objects:
|
||||||
best_obj = camera_state.best_objects[label]
|
best_obj = camera_state.best_objects[label]
|
||||||
best = best_obj.thumbnail_data.copy()
|
best = best_obj.thumbnail_data.copy()
|
||||||
best['frame'] = camera_state.frame_cache.get(best_obj.thumbnail_data['frame_time'])
|
best["frame"] = camera_state.frame_cache.get(
|
||||||
|
best_obj.thumbnail_data["frame_time"]
|
||||||
|
)
|
||||||
return best
|
return best
|
||||||
else:
|
else:
|
||||||
return {}
|
return {}
|
||||||
@ -539,46 +699,63 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
return self.camera_states[camera].get_current_frame(draw_options)
|
return self.camera_states[camera].get_current_frame(draw_options)
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
while True:
|
while not self.stop_event.is_set():
|
||||||
if self.stop_event.is_set():
|
|
||||||
logger.info(f"Exiting object processor...")
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
camera, frame_time, current_tracked_objects, motion_boxes, regions = self.tracked_objects_queue.get(True, 10)
|
(
|
||||||
|
camera,
|
||||||
|
frame_time,
|
||||||
|
current_tracked_objects,
|
||||||
|
motion_boxes,
|
||||||
|
regions,
|
||||||
|
) = self.tracked_objects_queue.get(True, 10)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
camera_state = self.camera_states[camera]
|
camera_state = self.camera_states[camera]
|
||||||
|
|
||||||
camera_state.update(frame_time, current_tracked_objects, motion_boxes, regions)
|
camera_state.update(
|
||||||
|
frame_time, current_tracked_objects, motion_boxes, regions
|
||||||
|
)
|
||||||
|
|
||||||
# update zone counts for each label
|
# update zone counts for each label
|
||||||
# for each zone in the current camera
|
# for each zone in the current camera
|
||||||
for zone in self.config.cameras[camera].zones.keys():
|
for zone in self.config.cameras[camera].zones.keys():
|
||||||
# count labels for the camera in the zone
|
# count labels for the camera in the zone
|
||||||
obj_counter = Counter()
|
obj_counter = Counter(
|
||||||
for obj in camera_state.tracked_objects.values():
|
obj.obj_data["label"]
|
||||||
if zone in obj.current_zones and not obj.false_positive:
|
for obj in camera_state.tracked_objects.values()
|
||||||
obj_counter[obj.obj_data['label']] += 1
|
if zone in obj.current_zones and not obj.false_positive
|
||||||
|
)
|
||||||
|
|
||||||
# update counts and publish status
|
# update counts and publish status
|
||||||
for label in set(list(self.zone_data[zone].keys()) + list(obj_counter.keys())):
|
for label in set(self.zone_data[zone].keys()) | set(obj_counter.keys()):
|
||||||
# if we have previously published a count for this zone/label
|
# if we have previously published a count for this zone/label
|
||||||
zone_label = self.zone_data[zone][label]
|
zone_label = self.zone_data[zone][label]
|
||||||
if camera in zone_label:
|
if camera in zone_label:
|
||||||
current_count = sum(zone_label.values())
|
current_count = sum(zone_label.values())
|
||||||
zone_label[camera] = obj_counter[label] if label in obj_counter else 0
|
zone_label[camera] = (
|
||||||
|
obj_counter[label] if label in obj_counter else 0
|
||||||
|
)
|
||||||
new_count = sum(zone_label.values())
|
new_count = sum(zone_label.values())
|
||||||
if new_count != current_count:
|
if new_count != current_count:
|
||||||
self.client.publish(f"{self.topic_prefix}/{zone}/{label}", new_count, retain=False)
|
self.client.publish(
|
||||||
|
f"{self.topic_prefix}/{zone}/{label}",
|
||||||
|
new_count,
|
||||||
|
retain=False,
|
||||||
|
)
|
||||||
# if this is a new zone/label combo for this camera
|
# if this is a new zone/label combo for this camera
|
||||||
else:
|
else:
|
||||||
if label in obj_counter:
|
if label in obj_counter:
|
||||||
zone_label[camera] = obj_counter[label]
|
zone_label[camera] = obj_counter[label]
|
||||||
self.client.publish(f"{self.topic_prefix}/{zone}/{label}", obj_counter[label], retain=False)
|
self.client.publish(
|
||||||
|
f"{self.topic_prefix}/{zone}/{label}",
|
||||||
|
obj_counter[label],
|
||||||
|
retain=False,
|
||||||
|
)
|
||||||
|
|
||||||
# cleanup event finished queue
|
# cleanup event finished queue
|
||||||
while not self.event_processed_queue.empty():
|
while not self.event_processed_queue.empty():
|
||||||
event_id, camera = self.event_processed_queue.get()
|
event_id, camera = self.event_processed_queue.get()
|
||||||
self.camera_states[camera].finished(event_id)
|
self.camera_states[camera].finished(event_id)
|
||||||
|
|
||||||
|
logger.info(f"Exiting object processor...")
|
||||||
|
|||||||
@ -16,17 +16,17 @@ from frigate.config import DetectConfig
|
|||||||
from frigate.util import draw_box_with_label
|
from frigate.util import draw_box_with_label
|
||||||
|
|
||||||
|
|
||||||
class ObjectTracker():
|
class ObjectTracker:
|
||||||
def __init__(self, config: DetectConfig):
|
def __init__(self, config: DetectConfig):
|
||||||
self.tracked_objects = {}
|
self.tracked_objects = {}
|
||||||
self.disappeared = {}
|
self.disappeared = {}
|
||||||
self.max_disappeared = config.max_disappeared
|
self.max_disappeared = config.max_disappeared
|
||||||
|
|
||||||
def register(self, index, obj):
|
def register(self, index, obj):
|
||||||
rand_id = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||||
id = f"{obj['frame_time']}-{rand_id}"
|
id = f"{obj['frame_time']}-{rand_id}"
|
||||||
obj['id'] = id
|
obj["id"] = id
|
||||||
obj['start_time'] = obj['frame_time']
|
obj["start_time"] = obj["frame_time"]
|
||||||
self.tracked_objects[id] = obj
|
self.tracked_objects[id] = obj
|
||||||
self.disappeared[id] = 0
|
self.disappeared[id] = 0
|
||||||
|
|
||||||
@ -42,92 +42,85 @@ class ObjectTracker():
|
|||||||
# group by name
|
# group by name
|
||||||
new_object_groups = defaultdict(lambda: [])
|
new_object_groups = defaultdict(lambda: [])
|
||||||
for obj in new_objects:
|
for obj in new_objects:
|
||||||
new_object_groups[obj[0]].append({
|
new_object_groups[obj[0]].append(
|
||||||
'label': obj[0],
|
{
|
||||||
'score': obj[1],
|
"label": obj[0],
|
||||||
'box': obj[2],
|
"score": obj[1],
|
||||||
'area': obj[3],
|
"box": obj[2],
|
||||||
'region': obj[4],
|
"area": obj[3],
|
||||||
'frame_time': frame_time
|
"region": obj[4],
|
||||||
})
|
"frame_time": frame_time,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# update any tracked objects with labels that are not
|
# update any tracked objects with labels that are not
|
||||||
# seen in the current objects and deregister if needed
|
# seen in the current objects and deregister if needed
|
||||||
for obj in list(self.tracked_objects.values()):
|
for obj in list(self.tracked_objects.values()):
|
||||||
if not obj['label'] in new_object_groups:
|
if not obj["label"] in new_object_groups:
|
||||||
if self.disappeared[obj['id']] >= self.max_disappeared:
|
if self.disappeared[obj["id"]] >= self.max_disappeared:
|
||||||
self.deregister(obj['id'])
|
self.deregister(obj["id"])
|
||||||
else:
|
else:
|
||||||
self.disappeared[obj['id']] += 1
|
self.disappeared[obj["id"]] += 1
|
||||||
|
|
||||||
if len(new_objects) == 0:
|
if len(new_objects) == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
# track objects for each label type
|
# track objects for each label type
|
||||||
for label, group in new_object_groups.items():
|
for label, group in new_object_groups.items():
|
||||||
current_objects = [o for o in self.tracked_objects.values() if o['label'] == label]
|
current_objects = [
|
||||||
current_ids = [o['id'] for o in current_objects]
|
o for o in self.tracked_objects.values() if o["label"] == label
|
||||||
current_centroids = np.array([o['centroid'] for o in current_objects])
|
]
|
||||||
|
current_ids = [o["id"] for o in current_objects]
|
||||||
|
current_centroids = np.array([o["centroid"] for o in current_objects])
|
||||||
|
|
||||||
# compute centroids of new objects
|
# compute centroids of new objects
|
||||||
for obj in group:
|
for obj in group:
|
||||||
centroid_x = int((obj['box'][0]+obj['box'][2]) / 2.0)
|
centroid_x = int((obj["box"][0] + obj["box"][2]) / 2.0)
|
||||||
centroid_y = int((obj['box'][1]+obj['box'][3]) / 2.0)
|
centroid_y = int((obj["box"][1] + obj["box"][3]) / 2.0)
|
||||||
obj['centroid'] = (centroid_x, centroid_y)
|
obj["centroid"] = (centroid_x, centroid_y)
|
||||||
|
|
||||||
if len(current_objects) == 0:
|
if len(current_objects) == 0:
|
||||||
for index, obj in enumerate(group):
|
for index, obj in enumerate(group):
|
||||||
self.register(index, obj)
|
self.register(index, obj)
|
||||||
return
|
continue
|
||||||
|
|
||||||
new_centroids = np.array([o['centroid'] for o in group])
|
new_centroids = np.array([o["centroid"] for o in group])
|
||||||
|
|
||||||
# compute the distance between each pair of tracked
|
# compute the distance between each pair of tracked
|
||||||
# centroids and new centroids, respectively -- our
|
# centroids and new centroids, respectively -- our
|
||||||
# goal will be to match each new centroid to an existing
|
# goal will be to match each current centroid to a new
|
||||||
# object centroid
|
# object centroid
|
||||||
D = dist.cdist(current_centroids, new_centroids)
|
D = dist.cdist(current_centroids, new_centroids)
|
||||||
|
|
||||||
# in order to perform this matching we must (1) find the
|
# in order to perform this matching we must (1) find the smallest
|
||||||
# smallest value in each row and then (2) sort the row
|
# value in each row (i.e. the distance from each current object to
|
||||||
# indexes based on their minimum values so that the row
|
# the closest new object) and then (2) sort the row indexes based
|
||||||
# with the smallest value is at the *front* of the index
|
# on their minimum values so that the row with the smallest
|
||||||
# list
|
# distance (the best match) is at the *front* of the index list
|
||||||
rows = D.min(axis=1).argsort()
|
rows = D.min(axis=1).argsort()
|
||||||
|
|
||||||
# next, we perform a similar process on the columns by
|
# next, we determine which new object each existing object matched
|
||||||
# finding the smallest value in each column and then
|
# against, and apply the same sorting as was applied previously
|
||||||
# sorting using the previously computed row index list
|
|
||||||
cols = D.argmin(axis=1)[rows]
|
cols = D.argmin(axis=1)[rows]
|
||||||
|
|
||||||
# in order to determine if we need to update, register,
|
# many current objects may register with each new object, so only
|
||||||
# or deregister an object we need to keep track of which
|
# match the closest ones. unique returns the indices of the first
|
||||||
# of the rows and column indexes we have already examined
|
# occurrences of each value, and because the rows are sorted by
|
||||||
usedRows = set()
|
# distance, this will be index of the closest match
|
||||||
usedCols = set()
|
_, index = np.unique(cols, return_index=True)
|
||||||
|
rows = rows[index]
|
||||||
|
cols = cols[index]
|
||||||
|
|
||||||
# loop over the combination of the (row, column) index
|
# loop over the combination of the (row, column) index tuples
|
||||||
# tuples
|
for row, col in zip(rows, cols):
|
||||||
for (row, col) in zip(rows, cols):
|
# grab the object ID for the current row, set its new centroid,
|
||||||
# if we have already examined either the row or
|
# and reset the disappeared counter
|
||||||
# column value before, ignore it
|
|
||||||
if row in usedRows or col in usedCols:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# otherwise, grab the object ID for the current row,
|
|
||||||
# set its new centroid, and reset the disappeared
|
|
||||||
# counter
|
|
||||||
objectID = current_ids[row]
|
objectID = current_ids[row]
|
||||||
self.update(objectID, group[col])
|
self.update(objectID, group[col])
|
||||||
|
|
||||||
# indicate that we have examined each of the row and
|
# compute the row and column indices we have NOT yet examined
|
||||||
# column indexes, respectively
|
unusedRows = set(range(D.shape[0])).difference(rows)
|
||||||
usedRows.add(row)
|
unusedCols = set(range(D.shape[1])).difference(cols)
|
||||||
usedCols.add(col)
|
|
||||||
|
|
||||||
# compute the column index we have NOT yet examined
|
|
||||||
unusedRows = set(range(0, D.shape[0])).difference(usedRows)
|
|
||||||
unusedCols = set(range(0, D.shape[1])).difference(usedCols)
|
|
||||||
|
|
||||||
# in the event that the number of object centroids is
|
# in the event that the number of object centroids is
|
||||||
# equal or greater than the number of input centroids
|
# equal or greater than the number of input centroids
|
||||||
|
|||||||
@ -16,36 +16,38 @@ from frigate.edgetpu import LocalObjectDetector
|
|||||||
from frigate.motion import MotionDetector
|
from frigate.motion import MotionDetector
|
||||||
from frigate.object_processing import COLOR_MAP, CameraState
|
from frigate.object_processing import COLOR_MAP, CameraState
|
||||||
from frigate.objects import ObjectTracker
|
from frigate.objects import ObjectTracker
|
||||||
from frigate.util import (DictFrameManager, EventsPerSecond,
|
from frigate.util import (
|
||||||
SharedMemoryFrameManager, draw_box_with_label)
|
DictFrameManager,
|
||||||
from frigate.video import (capture_frames, process_frames,
|
EventsPerSecond,
|
||||||
start_or_restart_ffmpeg)
|
SharedMemoryFrameManager,
|
||||||
|
draw_box_with_label,
|
||||||
|
)
|
||||||
|
from frigate.video import capture_frames, process_frames, start_or_restart_ffmpeg
|
||||||
|
|
||||||
logging.basicConfig()
|
logging.basicConfig()
|
||||||
logging.root.setLevel(logging.DEBUG)
|
logging.root.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_frame_shape(source):
|
def get_frame_shape(source):
|
||||||
ffprobe_cmd = " ".join([
|
ffprobe_cmd = [
|
||||||
'ffprobe',
|
"ffprobe",
|
||||||
'-v',
|
"-v",
|
||||||
'panic',
|
"panic",
|
||||||
'-show_error',
|
"-show_error",
|
||||||
'-show_streams',
|
"-show_streams",
|
||||||
'-of',
|
"-of",
|
||||||
'json',
|
"json",
|
||||||
'"'+source+'"'
|
source,
|
||||||
])
|
]
|
||||||
p = sp.Popen(ffprobe_cmd, stdout=sp.PIPE, shell=True)
|
p = sp.run(ffprobe_cmd, capture_output=True)
|
||||||
(output, err) = p.communicate()
|
info = json.loads(p.stdout)
|
||||||
p_status = p.wait()
|
|
||||||
info = json.loads(output)
|
|
||||||
|
|
||||||
video_info = [s for s in info['streams'] if s['codec_type'] == 'video'][0]
|
video_info = [s for s in info["streams"] if s["codec_type"] == "video"][0]
|
||||||
|
|
||||||
if video_info['height'] != 0 and video_info['width'] != 0:
|
if video_info["height"] != 0 and video_info["width"] != 0:
|
||||||
return (video_info['height'], video_info['width'], 3)
|
return (video_info["height"], video_info["width"], 3)
|
||||||
|
|
||||||
# fallback to using opencv if ffprobe didnt succeed
|
# fallback to using opencv if ffprobe didnt succeed
|
||||||
video = cv2.VideoCapture(source)
|
video = cv2.VideoCapture(source)
|
||||||
@ -54,14 +56,17 @@ def get_frame_shape(source):
|
|||||||
video.release()
|
video.release()
|
||||||
return frame_shape
|
return frame_shape
|
||||||
|
|
||||||
class ProcessClip():
|
|
||||||
|
class ProcessClip:
|
||||||
def __init__(self, clip_path, frame_shape, config: FrigateConfig):
|
def __init__(self, clip_path, frame_shape, config: FrigateConfig):
|
||||||
self.clip_path = clip_path
|
self.clip_path = clip_path
|
||||||
self.camera_name = 'camera'
|
self.camera_name = "camera"
|
||||||
self.config = config
|
self.config = config
|
||||||
self.camera_config = self.config.cameras['camera']
|
self.camera_config = self.config.cameras["camera"]
|
||||||
self.frame_shape = self.camera_config.frame_shape
|
self.frame_shape = self.camera_config.frame_shape
|
||||||
self.ffmpeg_cmd = [c['cmd'] for c in self.camera_config.ffmpeg_cmds if 'detect' in c['roles']][0]
|
self.ffmpeg_cmd = [
|
||||||
|
c["cmd"] for c in self.camera_config.ffmpeg_cmds if "detect" in c["roles"]
|
||||||
|
][0]
|
||||||
self.frame_manager = SharedMemoryFrameManager()
|
self.frame_manager = SharedMemoryFrameManager()
|
||||||
self.frame_queue = mp.Queue()
|
self.frame_queue = mp.Queue()
|
||||||
self.detected_objects_queue = mp.Queue()
|
self.detected_objects_queue = mp.Queue()
|
||||||
@ -70,37 +75,66 @@ class ProcessClip():
|
|||||||
def load_frames(self):
|
def load_frames(self):
|
||||||
fps = EventsPerSecond()
|
fps = EventsPerSecond()
|
||||||
skipped_fps = EventsPerSecond()
|
skipped_fps = EventsPerSecond()
|
||||||
current_frame = mp.Value('d', 0.0)
|
current_frame = mp.Value("d", 0.0)
|
||||||
frame_size = self.camera_config.frame_shape_yuv[0] * self.camera_config.frame_shape_yuv[1]
|
frame_size = (
|
||||||
ffmpeg_process = start_or_restart_ffmpeg(self.ffmpeg_cmd, logger, sp.DEVNULL, frame_size)
|
self.camera_config.frame_shape_yuv[0]
|
||||||
capture_frames(ffmpeg_process, self.camera_name, self.camera_config.frame_shape_yuv, self.frame_manager,
|
* self.camera_config.frame_shape_yuv[1]
|
||||||
self.frame_queue, fps, skipped_fps, current_frame)
|
)
|
||||||
|
ffmpeg_process = start_or_restart_ffmpeg(
|
||||||
|
self.ffmpeg_cmd, logger, sp.DEVNULL, frame_size
|
||||||
|
)
|
||||||
|
capture_frames(
|
||||||
|
ffmpeg_process,
|
||||||
|
self.camera_name,
|
||||||
|
self.camera_config.frame_shape_yuv,
|
||||||
|
self.frame_manager,
|
||||||
|
self.frame_queue,
|
||||||
|
fps,
|
||||||
|
skipped_fps,
|
||||||
|
current_frame,
|
||||||
|
)
|
||||||
ffmpeg_process.wait()
|
ffmpeg_process.wait()
|
||||||
ffmpeg_process.communicate()
|
ffmpeg_process.communicate()
|
||||||
|
|
||||||
def process_frames(self, objects_to_track=['person'], object_filters={}):
|
def process_frames(self, objects_to_track=["person"], object_filters={}):
|
||||||
mask = np.zeros((self.frame_shape[0], self.frame_shape[1], 1), np.uint8)
|
mask = np.zeros((self.frame_shape[0], self.frame_shape[1], 1), np.uint8)
|
||||||
mask[:] = 255
|
mask[:] = 255
|
||||||
motion_detector = MotionDetector(self.frame_shape, mask, self.camera_config.motion)
|
motion_detector = MotionDetector(
|
||||||
|
self.frame_shape, mask, self.camera_config.motion
|
||||||
|
)
|
||||||
|
|
||||||
object_detector = LocalObjectDetector(labels='/labelmap.txt')
|
object_detector = LocalObjectDetector(labels="/labelmap.txt")
|
||||||
object_tracker = ObjectTracker(self.camera_config.detect)
|
object_tracker = ObjectTracker(self.camera_config.detect)
|
||||||
process_info = {
|
process_info = {
|
||||||
'process_fps': mp.Value('d', 0.0),
|
"process_fps": mp.Value("d", 0.0),
|
||||||
'detection_fps': mp.Value('d', 0.0),
|
"detection_fps": mp.Value("d", 0.0),
|
||||||
'detection_frame': mp.Value('d', 0.0)
|
"detection_frame": mp.Value("d", 0.0),
|
||||||
}
|
}
|
||||||
stop_event = mp.Event()
|
stop_event = mp.Event()
|
||||||
model_shape = (self.config.model.height, self.config.model.width)
|
model_shape = (self.config.model.height, self.config.model.width)
|
||||||
|
|
||||||
process_frames(self.camera_name, self.frame_queue, self.frame_shape, model_shape,
|
process_frames(
|
||||||
self.frame_manager, motion_detector, object_detector, object_tracker,
|
self.camera_name,
|
||||||
self.detected_objects_queue, process_info,
|
self.frame_queue,
|
||||||
objects_to_track, object_filters, mask, stop_event, exit_on_empty=True)
|
self.frame_shape,
|
||||||
|
model_shape,
|
||||||
|
self.frame_manager,
|
||||||
|
motion_detector,
|
||||||
|
object_detector,
|
||||||
|
object_tracker,
|
||||||
|
self.detected_objects_queue,
|
||||||
|
process_info,
|
||||||
|
objects_to_track,
|
||||||
|
object_filters,
|
||||||
|
mask,
|
||||||
|
stop_event,
|
||||||
|
exit_on_empty=True,
|
||||||
|
)
|
||||||
|
|
||||||
def top_object(self, debug_path=None):
|
def top_object(self, debug_path=None):
|
||||||
obj_detected = False
|
obj_detected = False
|
||||||
top_computed_score = 0.0
|
top_computed_score = 0.0
|
||||||
|
|
||||||
def handle_event(name, obj, frame_time):
|
def handle_event(name, obj, frame_time):
|
||||||
nonlocal obj_detected
|
nonlocal obj_detected
|
||||||
nonlocal top_computed_score
|
nonlocal top_computed_score
|
||||||
@ -108,48 +142,85 @@ class ProcessClip():
|
|||||||
top_computed_score = obj.computed_score
|
top_computed_score = obj.computed_score
|
||||||
if not obj.false_positive:
|
if not obj.false_positive:
|
||||||
obj_detected = True
|
obj_detected = True
|
||||||
self.camera_state.on('new', handle_event)
|
|
||||||
self.camera_state.on('update', handle_event)
|
|
||||||
|
|
||||||
while(not self.detected_objects_queue.empty()):
|
self.camera_state.on("new", handle_event)
|
||||||
camera_name, frame_time, current_tracked_objects, motion_boxes, regions = self.detected_objects_queue.get()
|
self.camera_state.on("update", handle_event)
|
||||||
|
|
||||||
|
while not self.detected_objects_queue.empty():
|
||||||
|
(
|
||||||
|
camera_name,
|
||||||
|
frame_time,
|
||||||
|
current_tracked_objects,
|
||||||
|
motion_boxes,
|
||||||
|
regions,
|
||||||
|
) = self.detected_objects_queue.get()
|
||||||
if not debug_path is None:
|
if not debug_path is None:
|
||||||
self.save_debug_frame(debug_path, frame_time, current_tracked_objects.values())
|
self.save_debug_frame(
|
||||||
|
debug_path, frame_time, current_tracked_objects.values()
|
||||||
|
)
|
||||||
|
|
||||||
self.camera_state.update(frame_time, current_tracked_objects, motion_boxes, regions)
|
self.camera_state.update(
|
||||||
|
frame_time, current_tracked_objects, motion_boxes, regions
|
||||||
|
)
|
||||||
|
|
||||||
self.frame_manager.delete(self.camera_state.previous_frame_id)
|
self.frame_manager.delete(self.camera_state.previous_frame_id)
|
||||||
|
|
||||||
return {
|
return {"object_detected": obj_detected, "top_score": top_computed_score}
|
||||||
'object_detected': obj_detected,
|
|
||||||
'top_score': top_computed_score
|
|
||||||
}
|
|
||||||
|
|
||||||
def save_debug_frame(self, debug_path, frame_time, tracked_objects):
|
def save_debug_frame(self, debug_path, frame_time, tracked_objects):
|
||||||
current_frame = cv2.cvtColor(self.frame_manager.get(f"{self.camera_name}{frame_time}", self.camera_config.frame_shape_yuv), cv2.COLOR_YUV2BGR_I420)
|
current_frame = cv2.cvtColor(
|
||||||
|
self.frame_manager.get(
|
||||||
|
f"{self.camera_name}{frame_time}", self.camera_config.frame_shape_yuv
|
||||||
|
),
|
||||||
|
cv2.COLOR_YUV2BGR_I420,
|
||||||
|
)
|
||||||
# draw the bounding boxes on the frame
|
# draw the bounding boxes on the frame
|
||||||
for obj in tracked_objects:
|
for obj in tracked_objects:
|
||||||
thickness = 2
|
thickness = 2
|
||||||
color = (0,0,175)
|
color = (0, 0, 175)
|
||||||
|
|
||||||
if obj['frame_time'] != frame_time:
|
if obj["frame_time"] != frame_time:
|
||||||
thickness = 1
|
thickness = 1
|
||||||
color = (255,0,0)
|
color = (255, 0, 0)
|
||||||
else:
|
else:
|
||||||
color = (255,255,0)
|
color = (255, 255, 0)
|
||||||
|
|
||||||
# draw the bounding boxes on the frame
|
# draw the bounding boxes on the frame
|
||||||
box = obj['box']
|
box = obj["box"]
|
||||||
draw_box_with_label(current_frame, box[0], box[1], box[2], box[3], obj['id'], f"{int(obj['score']*100)}% {int(obj['area'])}", thickness=thickness, color=color)
|
draw_box_with_label(
|
||||||
|
current_frame,
|
||||||
|
box[0],
|
||||||
|
box[1],
|
||||||
|
box[2],
|
||||||
|
box[3],
|
||||||
|
obj["id"],
|
||||||
|
f"{int(obj['score']*100)}% {int(obj['area'])}",
|
||||||
|
thickness=thickness,
|
||||||
|
color=color,
|
||||||
|
)
|
||||||
# draw the regions on the frame
|
# draw the regions on the frame
|
||||||
region = obj['region']
|
region = obj["region"]
|
||||||
draw_box_with_label(current_frame, region[0], region[1], region[2], region[3], 'region', "", thickness=1, color=(0,255,0))
|
draw_box_with_label(
|
||||||
|
current_frame,
|
||||||
|
region[0],
|
||||||
|
region[1],
|
||||||
|
region[2],
|
||||||
|
region[3],
|
||||||
|
"region",
|
||||||
|
"",
|
||||||
|
thickness=1,
|
||||||
|
color=(0, 255, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
cv2.imwrite(
|
||||||
|
f"{os.path.join(debug_path, os.path.basename(self.clip_path))}.{int(frame_time*1000000)}.jpg",
|
||||||
|
current_frame,
|
||||||
|
)
|
||||||
|
|
||||||
cv2.imwrite(f"{os.path.join(debug_path, os.path.basename(self.clip_path))}.{int(frame_time*1000000)}.jpg", current_frame)
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.option("-p", "--path", required=True, help="Path to clip or directory to test.")
|
@click.option("-p", "--path", required=True, help="Path to clip or directory to test.")
|
||||||
@click.option("-l", "--label", default='person', help="Label name to detect.")
|
@click.option("-l", "--label", default="person", help="Label name to detect.")
|
||||||
@click.option("-t", "--threshold", default=0.85, help="Threshold value for objects.")
|
@click.option("-t", "--threshold", default=0.85, help="Threshold value for objects.")
|
||||||
@click.option("-s", "--scores", default=None, help="File to save csv of top scores")
|
@click.option("-s", "--scores", default=None, help="File to save csv of top scores")
|
||||||
@click.option("--debug-path", default=None, help="Path to output frames for debugging.")
|
@click.option("--debug-path", default=None, help="Path to output frames for debugging.")
|
||||||
@ -163,20 +234,23 @@ def process(path, label, threshold, scores, debug_path):
|
|||||||
clips.append(path)
|
clips.append(path)
|
||||||
|
|
||||||
json_config = {
|
json_config = {
|
||||||
'mqtt': {
|
"mqtt": {"host": "mqtt"},
|
||||||
'host': 'mqtt'
|
"cameras": {
|
||||||
},
|
"camera": {
|
||||||
'cameras': {
|
"ffmpeg": {
|
||||||
'camera': {
|
"inputs": [
|
||||||
'ffmpeg': {
|
{
|
||||||
'inputs': [
|
"path": "path.mp4",
|
||||||
{ 'path': 'path.mp4', 'global_args': '', 'input_args': '', 'roles': ['detect'] }
|
"global_args": "",
|
||||||
|
"input_args": "",
|
||||||
|
"roles": ["detect"],
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
'height': 1920,
|
"height": 1920,
|
||||||
'width': 1080
|
"width": 1080,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
@ -184,9 +258,9 @@ def process(path, label, threshold, scores, debug_path):
|
|||||||
logger.info(c)
|
logger.info(c)
|
||||||
frame_shape = get_frame_shape(c)
|
frame_shape = get_frame_shape(c)
|
||||||
|
|
||||||
json_config['cameras']['camera']['height'] = frame_shape[0]
|
json_config["cameras"]["camera"]["height"] = frame_shape[0]
|
||||||
json_config['cameras']['camera']['width'] = frame_shape[1]
|
json_config["cameras"]["camera"]["width"] = frame_shape[1]
|
||||||
json_config['cameras']['camera']['ffmpeg']['inputs'][0]['path'] = c
|
json_config["cameras"]["camera"]["ffmpeg"]["inputs"][0]["path"] = c
|
||||||
|
|
||||||
config = FrigateConfig(config=FRIGATE_CONFIG_SCHEMA(json_config))
|
config = FrigateConfig(config=FRIGATE_CONFIG_SCHEMA(json_config))
|
||||||
|
|
||||||
@ -197,12 +271,15 @@ def process(path, label, threshold, scores, debug_path):
|
|||||||
results.append((c, process_clip.top_object(debug_path)))
|
results.append((c, process_clip.top_object(debug_path)))
|
||||||
|
|
||||||
if not scores is None:
|
if not scores is None:
|
||||||
with open(scores, 'w') as writer:
|
with open(scores, "w") as writer:
|
||||||
for result in results:
|
for result in results:
|
||||||
writer.write(f"{result[0]},{result[1]['top_score']}\n")
|
writer.write(f"{result[0]},{result[1]['top_score']}\n")
|
||||||
|
|
||||||
positive_count = sum(1 for result in results if result[1]['object_detected'])
|
positive_count = sum(1 for result in results if result[1]["object_detected"])
|
||||||
print(f"Objects were detected in {positive_count}/{len(results)}({positive_count/len(results)*100:.2f}%) clip(s).")
|
print(
|
||||||
|
f"Objects were detected in {positive_count}/{len(results)}({positive_count/len(results)*100:.2f}%) clip(s)."
|
||||||
|
)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
|
if __name__ == "__main__":
|
||||||
process()
|
process()
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import itertools
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@ -18,6 +19,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
SECONDS_IN_DAY = 60 * 60 * 24
|
SECONDS_IN_DAY = 60 * 60 * 24
|
||||||
|
|
||||||
|
|
||||||
def remove_empty_directories(directory):
|
def remove_empty_directories(directory):
|
||||||
# list all directories recursively and sort them by path,
|
# list all directories recursively and sort them by path,
|
||||||
# longest first
|
# longest first
|
||||||
@ -33,26 +35,31 @@ def remove_empty_directories(directory):
|
|||||||
if len(os.listdir(path)) == 0:
|
if len(os.listdir(path)) == 0:
|
||||||
os.rmdir(path)
|
os.rmdir(path)
|
||||||
|
|
||||||
|
|
||||||
class RecordingMaintainer(threading.Thread):
|
class RecordingMaintainer(threading.Thread):
|
||||||
def __init__(self, config: FrigateConfig, stop_event):
|
def __init__(self, config: FrigateConfig, stop_event):
|
||||||
threading.Thread.__init__(self)
|
threading.Thread.__init__(self)
|
||||||
self.name = 'recording_maint'
|
self.name = "recording_maint"
|
||||||
self.config = config
|
self.config = config
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
|
|
||||||
def move_files(self):
|
def move_files(self):
|
||||||
recordings = [d for d in os.listdir(RECORD_DIR) if os.path.isfile(os.path.join(RECORD_DIR, d)) and d.endswith(".mp4")]
|
recordings = [
|
||||||
|
d
|
||||||
|
for d in os.listdir(RECORD_DIR)
|
||||||
|
if os.path.isfile(os.path.join(RECORD_DIR, d)) and d.endswith(".mp4")
|
||||||
|
]
|
||||||
|
|
||||||
files_in_use = []
|
files_in_use = []
|
||||||
for process in psutil.process_iter():
|
for process in psutil.process_iter():
|
||||||
try:
|
try:
|
||||||
if process.name() != 'ffmpeg':
|
if process.name() != "ffmpeg":
|
||||||
continue
|
continue
|
||||||
flist = process.open_files()
|
flist = process.open_files()
|
||||||
if flist:
|
if flist:
|
||||||
for nt in flist:
|
for nt in flist:
|
||||||
if nt.path.startswith(RECORD_DIR):
|
if nt.path.startswith(RECORD_DIR):
|
||||||
files_in_use.append(nt.path.split('/')[-1])
|
files_in_use.append(nt.path.split("/")[-1])
|
||||||
except:
|
except:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -60,66 +67,62 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
if f in files_in_use:
|
if f in files_in_use:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
camera = '-'.join(f.split('-')[:-1])
|
basename = os.path.splitext(f)[0]
|
||||||
start_time = datetime.datetime.strptime(f.split('-')[-1].split('.')[0], '%Y%m%d%H%M%S')
|
camera, date = basename.rsplit("-", maxsplit=1)
|
||||||
|
start_time = datetime.datetime.strptime(date, "%Y%m%d%H%M%S")
|
||||||
|
|
||||||
ffprobe_cmd = " ".join([
|
ffprobe_cmd = [
|
||||||
'ffprobe',
|
"ffprobe",
|
||||||
'-v',
|
"-v",
|
||||||
'error',
|
"error",
|
||||||
'-show_entries',
|
"-show_entries",
|
||||||
'format=duration',
|
"format=duration",
|
||||||
'-of',
|
"-of",
|
||||||
'default=noprint_wrappers=1:nokey=1',
|
"default=noprint_wrappers=1:nokey=1",
|
||||||
f"{os.path.join(RECORD_DIR,f)}"
|
f"{os.path.join(RECORD_DIR, f)}",
|
||||||
])
|
]
|
||||||
p = sp.Popen(ffprobe_cmd, stdout=sp.PIPE, shell=True)
|
p = sp.run(ffprobe_cmd, capture_output=True)
|
||||||
(output, err) = p.communicate()
|
if p.returncode == 0:
|
||||||
p_status = p.wait()
|
duration = float(p.stdout.decode().strip())
|
||||||
if p_status == 0:
|
|
||||||
duration = float(output.decode('utf-8').strip())
|
|
||||||
else:
|
else:
|
||||||
logger.info(f"bad file: {f}")
|
logger.info(f"bad file: {f}")
|
||||||
os.remove(os.path.join(RECORD_DIR,f))
|
os.remove(os.path.join(RECORD_DIR, f))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
directory = os.path.join(RECORD_DIR, start_time.strftime('%Y-%m/%d/%H'), camera)
|
directory = os.path.join(
|
||||||
|
RECORD_DIR, start_time.strftime("%Y-%m/%d/%H"), camera
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.exists(directory):
|
if not os.path.exists(directory):
|
||||||
os.makedirs(directory)
|
os.makedirs(directory)
|
||||||
|
|
||||||
file_name = f"{start_time.strftime('%M.%S.mp4')}"
|
file_name = f"{start_time.strftime('%M.%S.mp4')}"
|
||||||
|
|
||||||
os.rename(os.path.join(RECORD_DIR,f), os.path.join(directory,file_name))
|
os.rename(os.path.join(RECORD_DIR, f), os.path.join(directory, file_name))
|
||||||
|
|
||||||
def expire_files(self):
|
def expire_files(self):
|
||||||
delete_before = {}
|
delete_before = {}
|
||||||
for name, camera in self.config.cameras.items():
|
for name, camera in self.config.cameras.items():
|
||||||
delete_before[name] = datetime.datetime.now().timestamp() - SECONDS_IN_DAY*camera.record.retain_days
|
delete_before[name] = (
|
||||||
|
datetime.datetime.now().timestamp()
|
||||||
|
- SECONDS_IN_DAY * camera.record.retain_days
|
||||||
|
)
|
||||||
|
|
||||||
for p in Path('/media/frigate/recordings').rglob("*.mp4"):
|
for p in Path("/media/frigate/recordings").rglob("*.mp4"):
|
||||||
if not p.parent.name in delete_before:
|
if not p.parent.name in delete_before:
|
||||||
continue
|
continue
|
||||||
if p.stat().st_mtime < delete_before[p.parent.name]:
|
if p.stat().st_mtime < delete_before[p.parent.name]:
|
||||||
p.unlink(missing_ok=True)
|
p.unlink(missing_ok=True)
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
counter = 0
|
for counter in itertools.cycle(range(60)):
|
||||||
self.expire_files()
|
if self.stop_event.wait(10):
|
||||||
while(True):
|
|
||||||
if self.stop_event.is_set():
|
|
||||||
logger.info(f"Exiting recording maintenance...")
|
logger.info(f"Exiting recording maintenance...")
|
||||||
break
|
break
|
||||||
|
|
||||||
# only expire events every 10 minutes, but check for new files every 10 seconds
|
# only expire events every 10 minutes, but check for new files every 10 seconds
|
||||||
time.sleep(10)
|
if counter == 0:
|
||||||
counter = counter + 1
|
|
||||||
if counter > 60:
|
|
||||||
self.expire_files()
|
self.expire_files()
|
||||||
remove_empty_directories(RECORD_DIR)
|
remove_empty_directories(RECORD_DIR)
|
||||||
counter = 0
|
|
||||||
|
|
||||||
self.move_files()
|
self.move_files()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -11,14 +11,16 @@ from frigate.version import VERSION
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def stats_init(camera_metrics, detectors):
|
def stats_init(camera_metrics, detectors):
|
||||||
stats_tracking = {
|
stats_tracking = {
|
||||||
'camera_metrics': camera_metrics,
|
"camera_metrics": camera_metrics,
|
||||||
'detectors': detectors,
|
"detectors": detectors,
|
||||||
'started': int(time.time())
|
"started": int(time.time()),
|
||||||
}
|
}
|
||||||
return stats_tracking
|
return stats_tracking
|
||||||
|
|
||||||
|
|
||||||
def get_fs_type(path):
|
def get_fs_type(path):
|
||||||
bestMatch = ""
|
bestMatch = ""
|
||||||
fsType = ""
|
fsType = ""
|
||||||
@ -28,53 +30,62 @@ def get_fs_type(path):
|
|||||||
bestMatch = part.mountpoint
|
bestMatch = part.mountpoint
|
||||||
return fsType
|
return fsType
|
||||||
|
|
||||||
|
|
||||||
def stats_snapshot(stats_tracking):
|
def stats_snapshot(stats_tracking):
|
||||||
camera_metrics = stats_tracking['camera_metrics']
|
camera_metrics = stats_tracking["camera_metrics"]
|
||||||
stats = {}
|
stats = {}
|
||||||
|
|
||||||
total_detection_fps = 0
|
total_detection_fps = 0
|
||||||
|
|
||||||
for name, camera_stats in camera_metrics.items():
|
for name, camera_stats in camera_metrics.items():
|
||||||
total_detection_fps += camera_stats['detection_fps'].value
|
total_detection_fps += camera_stats["detection_fps"].value
|
||||||
stats[name] = {
|
stats[name] = {
|
||||||
'camera_fps': round(camera_stats['camera_fps'].value, 2),
|
"camera_fps": round(camera_stats["camera_fps"].value, 2),
|
||||||
'process_fps': round(camera_stats['process_fps'].value, 2),
|
"process_fps": round(camera_stats["process_fps"].value, 2),
|
||||||
'skipped_fps': round(camera_stats['skipped_fps'].value, 2),
|
"skipped_fps": round(camera_stats["skipped_fps"].value, 2),
|
||||||
'detection_fps': round(camera_stats['detection_fps'].value, 2),
|
"detection_fps": round(camera_stats["detection_fps"].value, 2),
|
||||||
'pid': camera_stats['process'].pid,
|
"pid": camera_stats["process"].pid,
|
||||||
'capture_pid': camera_stats['capture_process'].pid
|
"capture_pid": camera_stats["capture_process"].pid,
|
||||||
}
|
}
|
||||||
|
|
||||||
stats['detectors'] = {}
|
stats["detectors"] = {}
|
||||||
for name, detector in stats_tracking["detectors"].items():
|
for name, detector in stats_tracking["detectors"].items():
|
||||||
stats['detectors'][name] = {
|
stats["detectors"][name] = {
|
||||||
'inference_speed': round(detector.avg_inference_speed.value * 1000, 2),
|
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2),
|
||||||
'detection_start': detector.detection_start.value,
|
"detection_start": detector.detection_start.value,
|
||||||
'pid': detector.detect_process.pid
|
"pid": detector.detect_process.pid,
|
||||||
}
|
}
|
||||||
stats['detection_fps'] = round(total_detection_fps, 2)
|
stats["detection_fps"] = round(total_detection_fps, 2)
|
||||||
|
|
||||||
stats['service'] = {
|
stats["service"] = {
|
||||||
'uptime': (int(time.time()) - stats_tracking['started']),
|
"uptime": (int(time.time()) - stats_tracking["started"]),
|
||||||
'version': VERSION,
|
"version": VERSION,
|
||||||
'storage': {}
|
"storage": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
for path in [RECORD_DIR, CLIPS_DIR, CACHE_DIR, "/dev/shm"]:
|
for path in [RECORD_DIR, CLIPS_DIR, CACHE_DIR, "/dev/shm"]:
|
||||||
storage_stats = shutil.disk_usage(path)
|
storage_stats = shutil.disk_usage(path)
|
||||||
stats['service']['storage'][path] = {
|
stats["service"]["storage"][path] = {
|
||||||
'total': round(storage_stats.total/1000000, 1),
|
"total": round(storage_stats.total / 1000000, 1),
|
||||||
'used': round(storage_stats.used/1000000, 1),
|
"used": round(storage_stats.used / 1000000, 1),
|
||||||
'free': round(storage_stats.free/1000000, 1),
|
"free": round(storage_stats.free / 1000000, 1),
|
||||||
'mount_type': get_fs_type(path)
|
"mount_type": get_fs_type(path),
|
||||||
}
|
}
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
|
||||||
class StatsEmitter(threading.Thread):
|
class StatsEmitter(threading.Thread):
|
||||||
def __init__(self, config: FrigateConfig, stats_tracking, mqtt_client, topic_prefix, stop_event):
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: FrigateConfig,
|
||||||
|
stats_tracking,
|
||||||
|
mqtt_client,
|
||||||
|
topic_prefix,
|
||||||
|
stop_event,
|
||||||
|
):
|
||||||
threading.Thread.__init__(self)
|
threading.Thread.__init__(self)
|
||||||
self.name = 'frigate_stats_emitter'
|
self.name = "frigate_stats_emitter"
|
||||||
self.config = config
|
self.config = config
|
||||||
self.stats_tracking = stats_tracking
|
self.stats_tracking = stats_tracking
|
||||||
self.mqtt_client = mqtt_client
|
self.mqtt_client = mqtt_client
|
||||||
@ -83,10 +94,9 @@ class StatsEmitter(threading.Thread):
|
|||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
while True:
|
while not self.stop_event.wait(self.config.mqtt.stats_interval):
|
||||||
if self.stop_event.is_set():
|
|
||||||
logger.info(f"Exiting watchdog...")
|
|
||||||
break
|
|
||||||
stats = stats_snapshot(self.stats_tracking)
|
stats = stats_snapshot(self.stats_tracking)
|
||||||
self.mqtt_client.publish(f"{self.topic_prefix}/stats", json.dumps(stats), retain=False)
|
self.mqtt_client.publish(
|
||||||
time.sleep(self.config.mqtt.stats_interval)
|
f"{self.topic_prefix}/stats", json.dumps(stats), retain=False
|
||||||
|
)
|
||||||
|
logger.info(f"Exiting watchdog...")
|
||||||
|
|||||||
@ -3,24 +3,24 @@ from unittest import TestCase, main
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from frigate.config import FRIGATE_CONFIG_SCHEMA, FrigateConfig
|
from frigate.config import FRIGATE_CONFIG_SCHEMA, FrigateConfig
|
||||||
|
|
||||||
|
|
||||||
class TestConfig(TestCase):
|
class TestConfig(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.minimal = {
|
self.minimal = {
|
||||||
'mqtt': {
|
"mqtt": {"host": "mqtt"},
|
||||||
'host': 'mqtt'
|
"cameras": {
|
||||||
},
|
"back": {
|
||||||
'cameras': {
|
"ffmpeg": {
|
||||||
'back': {
|
"inputs": [
|
||||||
'ffmpeg': {
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
'inputs': [
|
|
||||||
{ 'path': 'rtsp://10.0.0.1:554/video', 'roles': ['detect'] }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
'height': 1080,
|
"height": 1080,
|
||||||
'width': 1920
|
"width": 1920,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_empty(self):
|
def test_empty(self):
|
||||||
FRIGATE_CONFIG_SCHEMA({})
|
FRIGATE_CONFIG_SCHEMA({})
|
||||||
|
|
||||||
@ -32,402 +32,310 @@ class TestConfig(TestCase):
|
|||||||
|
|
||||||
def test_inherit_tracked_objects(self):
|
def test_inherit_tracked_objects(self):
|
||||||
config = {
|
config = {
|
||||||
'mqtt': {
|
"mqtt": {"host": "mqtt"},
|
||||||
'host': 'mqtt'
|
"objects": {"track": ["person", "dog"]},
|
||||||
},
|
"cameras": {
|
||||||
'objects': {
|
"back": {
|
||||||
'track': ['person', 'dog']
|
"ffmpeg": {
|
||||||
},
|
"inputs": [
|
||||||
'cameras': {
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
'back': {
|
|
||||||
'ffmpeg': {
|
|
||||||
'inputs': [
|
|
||||||
{ 'path': 'rtsp://10.0.0.1:554/video', 'roles': ['detect'] }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
'height': 1080,
|
"height": 1080,
|
||||||
'width': 1920
|
"width": 1920,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(config=config)
|
frigate_config = FrigateConfig(config=config)
|
||||||
assert('dog' in frigate_config.cameras['back'].objects.track)
|
assert "dog" in frigate_config.cameras["back"].objects.track
|
||||||
|
|
||||||
def test_override_tracked_objects(self):
|
def test_override_tracked_objects(self):
|
||||||
config = {
|
config = {
|
||||||
'mqtt': {
|
"mqtt": {"host": "mqtt"},
|
||||||
'host': 'mqtt'
|
"objects": {"track": ["person", "dog"]},
|
||||||
},
|
"cameras": {
|
||||||
'objects': {
|
"back": {
|
||||||
'track': ['person', 'dog']
|
"ffmpeg": {
|
||||||
},
|
"inputs": [
|
||||||
'cameras': {
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
'back': {
|
|
||||||
'ffmpeg': {
|
|
||||||
'inputs': [
|
|
||||||
{ 'path': 'rtsp://10.0.0.1:554/video', 'roles': ['detect'] }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
'height': 1080,
|
"height": 1080,
|
||||||
'width': 1920,
|
"width": 1920,
|
||||||
'objects': {
|
"objects": {"track": ["cat"]},
|
||||||
'track': ['cat']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(config=config)
|
frigate_config = FrigateConfig(config=config)
|
||||||
assert('cat' in frigate_config.cameras['back'].objects.track)
|
assert "cat" in frigate_config.cameras["back"].objects.track
|
||||||
|
|
||||||
def test_default_object_filters(self):
|
def test_default_object_filters(self):
|
||||||
config = {
|
config = {
|
||||||
'mqtt': {
|
"mqtt": {"host": "mqtt"},
|
||||||
'host': 'mqtt'
|
"objects": {"track": ["person", "dog"]},
|
||||||
},
|
"cameras": {
|
||||||
'objects': {
|
"back": {
|
||||||
'track': ['person', 'dog']
|
"ffmpeg": {
|
||||||
},
|
"inputs": [
|
||||||
'cameras': {
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
'back': {
|
|
||||||
'ffmpeg': {
|
|
||||||
'inputs': [
|
|
||||||
{ 'path': 'rtsp://10.0.0.1:554/video', 'roles': ['detect'] }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
'height': 1080,
|
"height": 1080,
|
||||||
'width': 1920
|
"width": 1920,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(config=config)
|
frigate_config = FrigateConfig(config=config)
|
||||||
assert('dog' in frigate_config.cameras['back'].objects.filters)
|
assert "dog" in frigate_config.cameras["back"].objects.filters
|
||||||
|
|
||||||
def test_inherit_object_filters(self):
|
def test_inherit_object_filters(self):
|
||||||
config = {
|
config = {
|
||||||
'mqtt': {
|
"mqtt": {"host": "mqtt"},
|
||||||
'host': 'mqtt'
|
"objects": {
|
||||||
|
"track": ["person", "dog"],
|
||||||
|
"filters": {"dog": {"threshold": 0.7}},
|
||||||
},
|
},
|
||||||
'objects': {
|
"cameras": {
|
||||||
'track': ['person', 'dog'],
|
"back": {
|
||||||
'filters': {
|
"ffmpeg": {
|
||||||
'dog': {
|
"inputs": [
|
||||||
'threshold': 0.7
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'cameras': {
|
|
||||||
'back': {
|
|
||||||
'ffmpeg': {
|
|
||||||
'inputs': [
|
|
||||||
{ 'path': 'rtsp://10.0.0.1:554/video', 'roles': ['detect'] }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
'height': 1080,
|
"height": 1080,
|
||||||
'width': 1920
|
"width": 1920,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(config=config)
|
frigate_config = FrigateConfig(config=config)
|
||||||
assert('dog' in frigate_config.cameras['back'].objects.filters)
|
assert "dog" in frigate_config.cameras["back"].objects.filters
|
||||||
assert(frigate_config.cameras['back'].objects.filters['dog'].threshold == 0.7)
|
assert frigate_config.cameras["back"].objects.filters["dog"].threshold == 0.7
|
||||||
|
|
||||||
def test_override_object_filters(self):
|
def test_override_object_filters(self):
|
||||||
config = {
|
config = {
|
||||||
'mqtt': {
|
"mqtt": {"host": "mqtt"},
|
||||||
'host': 'mqtt'
|
"cameras": {
|
||||||
},
|
"back": {
|
||||||
'cameras': {
|
"ffmpeg": {
|
||||||
'back': {
|
"inputs": [
|
||||||
'ffmpeg': {
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
'inputs': [
|
|
||||||
{ 'path': 'rtsp://10.0.0.1:554/video', 'roles': ['detect'] }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
'height': 1080,
|
"height": 1080,
|
||||||
'width': 1920,
|
"width": 1920,
|
||||||
'objects': {
|
"objects": {
|
||||||
'track': ['person', 'dog'],
|
"track": ["person", "dog"],
|
||||||
'filters': {
|
"filters": {"dog": {"threshold": 0.7}},
|
||||||
'dog': {
|
},
|
||||||
'threshold': 0.7
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(config=config)
|
frigate_config = FrigateConfig(config=config)
|
||||||
assert('dog' in frigate_config.cameras['back'].objects.filters)
|
assert "dog" in frigate_config.cameras["back"].objects.filters
|
||||||
assert(frigate_config.cameras['back'].objects.filters['dog'].threshold == 0.7)
|
assert frigate_config.cameras["back"].objects.filters["dog"].threshold == 0.7
|
||||||
|
|
||||||
def test_global_object_mask(self):
|
def test_global_object_mask(self):
|
||||||
config = {
|
config = {
|
||||||
'mqtt': {
|
"mqtt": {"host": "mqtt"},
|
||||||
'host': 'mqtt'
|
"objects": {"track": ["person", "dog"]},
|
||||||
},
|
"cameras": {
|
||||||
'objects': {
|
"back": {
|
||||||
'track': ['person', 'dog']
|
"ffmpeg": {
|
||||||
},
|
"inputs": [
|
||||||
'cameras': {
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
'back': {
|
|
||||||
'ffmpeg': {
|
|
||||||
'inputs': [
|
|
||||||
{ 'path': 'rtsp://10.0.0.1:554/video', 'roles': ['detect'] }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
'height': 1080,
|
"height": 1080,
|
||||||
'width': 1920,
|
"width": 1920,
|
||||||
'objects': {
|
"objects": {
|
||||||
'mask': '0,0,1,1,0,1',
|
"mask": "0,0,1,1,0,1",
|
||||||
'filters': {
|
"filters": {"dog": {"mask": "1,1,1,1,1,1"}},
|
||||||
'dog': {
|
},
|
||||||
'mask': '1,1,1,1,1,1'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(config=config)
|
frigate_config = FrigateConfig(config=config)
|
||||||
assert('dog' in frigate_config.cameras['back'].objects.filters)
|
assert "dog" in frigate_config.cameras["back"].objects.filters
|
||||||
assert(len(frigate_config.cameras['back'].objects.filters['dog']._raw_mask) == 2)
|
assert len(frigate_config.cameras["back"].objects.filters["dog"].raw_mask) == 2
|
||||||
assert(len(frigate_config.cameras['back'].objects.filters['person']._raw_mask) == 1)
|
assert (
|
||||||
|
len(frigate_config.cameras["back"].objects.filters["person"].raw_mask) == 1
|
||||||
|
)
|
||||||
|
|
||||||
def test_ffmpeg_params_global(self):
|
def test_ffmpeg_params_global(self):
|
||||||
config = {
|
config = {
|
||||||
'ffmpeg': {
|
"ffmpeg": {"input_args": ["-re"]},
|
||||||
'input_args': ['-re']
|
"mqtt": {"host": "mqtt"},
|
||||||
},
|
"cameras": {
|
||||||
'mqtt': {
|
"back": {
|
||||||
'host': 'mqtt'
|
"ffmpeg": {
|
||||||
},
|
"inputs": [
|
||||||
'cameras': {
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
'back': {
|
|
||||||
'ffmpeg': {
|
|
||||||
'inputs': [
|
|
||||||
{ 'path': 'rtsp://10.0.0.1:554/video', 'roles': ['detect'] }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
'height': 1080,
|
"height": 1080,
|
||||||
'width': 1920,
|
"width": 1920,
|
||||||
'objects': {
|
"objects": {
|
||||||
'track': ['person', 'dog'],
|
"track": ["person", "dog"],
|
||||||
'filters': {
|
"filters": {"dog": {"threshold": 0.7}},
|
||||||
'dog': {
|
},
|
||||||
'threshold': 0.7
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(config=config)
|
frigate_config = FrigateConfig(config=config)
|
||||||
assert('-re' in frigate_config.cameras['back'].ffmpeg_cmds[0]['cmd'])
|
assert "-re" in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
|
||||||
|
|
||||||
def test_ffmpeg_params_camera(self):
|
def test_ffmpeg_params_camera(self):
|
||||||
config = {
|
config = {
|
||||||
'mqtt': {
|
"mqtt": {"host": "mqtt"},
|
||||||
'host': 'mqtt'
|
"cameras": {
|
||||||
},
|
"back": {
|
||||||
'cameras': {
|
"ffmpeg": {
|
||||||
'back': {
|
"inputs": [
|
||||||
'ffmpeg': {
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
'inputs': [
|
|
||||||
{ 'path': 'rtsp://10.0.0.1:554/video', 'roles': ['detect'] }
|
|
||||||
],
|
],
|
||||||
'input_args': ['-re']
|
"input_args": ["-re"],
|
||||||
|
},
|
||||||
|
"height": 1080,
|
||||||
|
"width": 1920,
|
||||||
|
"objects": {
|
||||||
|
"track": ["person", "dog"],
|
||||||
|
"filters": {"dog": {"threshold": 0.7}},
|
||||||
},
|
},
|
||||||
'height': 1080,
|
|
||||||
'width': 1920,
|
|
||||||
'objects': {
|
|
||||||
'track': ['person', 'dog'],
|
|
||||||
'filters': {
|
|
||||||
'dog': {
|
|
||||||
'threshold': 0.7
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(config=config)
|
frigate_config = FrigateConfig(config=config)
|
||||||
assert('-re' in frigate_config.cameras['back'].ffmpeg_cmds[0]['cmd'])
|
assert "-re" in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
|
||||||
|
|
||||||
def test_ffmpeg_params_input(self):
|
def test_ffmpeg_params_input(self):
|
||||||
config = {
|
config = {
|
||||||
'mqtt': {
|
"mqtt": {"host": "mqtt"},
|
||||||
'host': 'mqtt'
|
"cameras": {
|
||||||
},
|
"back": {
|
||||||
'cameras': {
|
"ffmpeg": {
|
||||||
'back': {
|
"inputs": [
|
||||||
'ffmpeg': {
|
{
|
||||||
'inputs': [
|
"path": "rtsp://10.0.0.1:554/video",
|
||||||
{ 'path': 'rtsp://10.0.0.1:554/video', 'roles': ['detect'], 'input_args': ['-re'] }
|
"roles": ["detect"],
|
||||||
|
"input_args": ["-re"],
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
'height': 1080,
|
"height": 1080,
|
||||||
'width': 1920,
|
"width": 1920,
|
||||||
'objects': {
|
"objects": {
|
||||||
'track': ['person', 'dog'],
|
"track": ["person", "dog"],
|
||||||
'filters': {
|
"filters": {"dog": {"threshold": 0.7}},
|
||||||
'dog': {
|
},
|
||||||
'threshold': 0.7
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(config=config)
|
frigate_config = FrigateConfig(config=config)
|
||||||
assert('-re' in frigate_config.cameras['back'].ffmpeg_cmds[0]['cmd'])
|
assert "-re" in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
|
||||||
|
|
||||||
|
|
||||||
def test_inherit_clips_retention(self):
|
def test_inherit_clips_retention(self):
|
||||||
config = {
|
config = {
|
||||||
'mqtt': {
|
"mqtt": {"host": "mqtt"},
|
||||||
'host': 'mqtt'
|
"clips": {"retain": {"default": 20, "objects": {"person": 30}}},
|
||||||
},
|
"cameras": {
|
||||||
'clips': {
|
"back": {
|
||||||
'retain': {
|
"ffmpeg": {
|
||||||
'default': 20,
|
"inputs": [
|
||||||
'objects': {
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
'person': 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'cameras': {
|
|
||||||
'back': {
|
|
||||||
'ffmpeg': {
|
|
||||||
'inputs': [
|
|
||||||
{ 'path': 'rtsp://10.0.0.1:554/video', 'roles': ['detect'] }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
'height': 1080,
|
"height": 1080,
|
||||||
'width': 1920
|
"width": 1920,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
frigate_config = FrigateConfig(config=config)
|
frigate_config = FrigateConfig(config=config)
|
||||||
assert(frigate_config.cameras['back'].clips.retain.objects['person'] == 30)
|
assert frigate_config.cameras["back"].clips.retain.objects["person"] == 30
|
||||||
|
|
||||||
def test_roles_listed_twice_throws_error(self):
|
def test_roles_listed_twice_throws_error(self):
|
||||||
config = {
|
config = {
|
||||||
'mqtt': {
|
"mqtt": {"host": "mqtt"},
|
||||||
'host': 'mqtt'
|
"clips": {"retain": {"default": 20, "objects": {"person": 30}}},
|
||||||
},
|
"cameras": {
|
||||||
'clips': {
|
"back": {
|
||||||
'retain': {
|
"ffmpeg": {
|
||||||
'default': 20,
|
"inputs": [
|
||||||
'objects': {
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]},
|
||||||
'person': 30
|
{"path": "rtsp://10.0.0.1:554/video2", "roles": ["detect"]},
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'cameras': {
|
|
||||||
'back': {
|
|
||||||
'ffmpeg': {
|
|
||||||
'inputs': [
|
|
||||||
{ 'path': 'rtsp://10.0.0.1:554/video', 'roles': ['detect'] },
|
|
||||||
{ 'path': 'rtsp://10.0.0.1:554/video2', 'roles': ['detect'] }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
'height': 1080,
|
"height": 1080,
|
||||||
'width': 1920
|
"width": 1920,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
self.assertRaises(vol.MultipleInvalid, lambda: FrigateConfig(config=config))
|
self.assertRaises(vol.MultipleInvalid, lambda: FrigateConfig(config=config))
|
||||||
|
|
||||||
def test_zone_matching_camera_name_throws_error(self):
|
def test_zone_matching_camera_name_throws_error(self):
|
||||||
config = {
|
config = {
|
||||||
'mqtt': {
|
"mqtt": {"host": "mqtt"},
|
||||||
'host': 'mqtt'
|
"clips": {"retain": {"default": 20, "objects": {"person": 30}}},
|
||||||
},
|
"cameras": {
|
||||||
'clips': {
|
"back": {
|
||||||
'retain': {
|
"ffmpeg": {
|
||||||
'default': 20,
|
"inputs": [
|
||||||
'objects': {
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
'person': 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'cameras': {
|
|
||||||
'back': {
|
|
||||||
'ffmpeg': {
|
|
||||||
'inputs': [
|
|
||||||
{ 'path': 'rtsp://10.0.0.1:554/video', 'roles': ['detect'] }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
'height': 1080,
|
"height": 1080,
|
||||||
'width': 1920,
|
"width": 1920,
|
||||||
'zones': {
|
"zones": {"back": {"coordinates": "1,1,1,1,1,1"}},
|
||||||
'back': {
|
|
||||||
'coordinates': '1,1,1,1,1,1'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
self.assertRaises(vol.MultipleInvalid, lambda: FrigateConfig(config=config))
|
self.assertRaises(vol.MultipleInvalid, lambda: FrigateConfig(config=config))
|
||||||
|
|
||||||
def test_clips_should_default_to_global_objects(self):
|
def test_clips_should_default_to_global_objects(self):
|
||||||
config = {
|
config = {
|
||||||
'mqtt': {
|
"mqtt": {"host": "mqtt"},
|
||||||
'host': 'mqtt'
|
"clips": {"retain": {"default": 20, "objects": {"person": 30}}},
|
||||||
},
|
"objects": {"track": ["person", "dog"]},
|
||||||
'clips': {
|
"cameras": {
|
||||||
'retain': {
|
"back": {
|
||||||
'default': 20,
|
"ffmpeg": {
|
||||||
'objects': {
|
"inputs": [
|
||||||
'person': 30
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'objects': {
|
|
||||||
'track': ['person', 'dog']
|
|
||||||
},
|
|
||||||
'cameras': {
|
|
||||||
'back': {
|
|
||||||
'ffmpeg': {
|
|
||||||
'inputs': [
|
|
||||||
{ 'path': 'rtsp://10.0.0.1:554/video', 'roles': ['detect'] }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
'height': 1080,
|
"height": 1080,
|
||||||
'width': 1920,
|
"width": 1920,
|
||||||
'clips': {
|
"clips": {"enabled": True},
|
||||||
'enabled': True
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
config = FrigateConfig(config=config)
|
config = FrigateConfig(config=config)
|
||||||
assert(config.cameras['back'].clips.objects is None)
|
assert config.cameras["back"].clips.objects is None
|
||||||
|
|
||||||
def test_role_assigned_but_not_enabled(self):
|
def test_role_assigned_but_not_enabled(self):
|
||||||
json_config = {
|
json_config = {
|
||||||
'mqtt': {
|
"mqtt": {"host": "mqtt"},
|
||||||
'host': 'mqtt'
|
"cameras": {
|
||||||
|
"back": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"path": "rtsp://10.0.0.1:554/video",
|
||||||
|
"roles": ["detect", "rtmp"],
|
||||||
},
|
},
|
||||||
'cameras': {
|
{"path": "rtsp://10.0.0.1:554/record", "roles": ["record"]},
|
||||||
'back': {
|
|
||||||
'ffmpeg': {
|
|
||||||
'inputs': [
|
|
||||||
{ 'path': 'rtsp://10.0.0.1:554/video', 'roles': ['detect', 'rtmp'] },
|
|
||||||
{ 'path': 'rtsp://10.0.0.1:554/record', 'roles': ['record'] }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
'height': 1080,
|
"height": 1080,
|
||||||
'width': 1920
|
"width": 1920,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
config = FrigateConfig(config=json_config)
|
config = FrigateConfig(config=json_config)
|
||||||
ffmpeg_cmds = config.cameras['back'].ffmpeg_cmds
|
ffmpeg_cmds = config.cameras["back"].ffmpeg_cmds
|
||||||
assert(len(ffmpeg_cmds) == 1)
|
assert len(ffmpeg_cmds) == 1
|
||||||
assert(not 'clips' in ffmpeg_cmds[0]['roles'])
|
assert not "clips" in ffmpeg_cmds[0]["roles"]
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main(verbosity=2)
|
main(verbosity=2)
|
||||||
|
|||||||
@ -3,37 +3,39 @@ import numpy as np
|
|||||||
from unittest import TestCase, main
|
from unittest import TestCase, main
|
||||||
from frigate.util import yuv_region_2_rgb
|
from frigate.util import yuv_region_2_rgb
|
||||||
|
|
||||||
|
|
||||||
class TestYuvRegion2RGB(TestCase):
|
class TestYuvRegion2RGB(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.bgr_frame = np.zeros((100, 200, 3), np.uint8)
|
self.bgr_frame = np.zeros((100, 200, 3), np.uint8)
|
||||||
self.bgr_frame[:] = (0, 0, 255)
|
self.bgr_frame[:] = (0, 0, 255)
|
||||||
self.bgr_frame[5:55, 5:55] = (255,0,0)
|
self.bgr_frame[5:55, 5:55] = (255, 0, 0)
|
||||||
# cv2.imwrite(f"bgr_frame.jpg", self.bgr_frame)
|
# cv2.imwrite(f"bgr_frame.jpg", self.bgr_frame)
|
||||||
self.yuv_frame = cv2.cvtColor(self.bgr_frame, cv2.COLOR_BGR2YUV_I420)
|
self.yuv_frame = cv2.cvtColor(self.bgr_frame, cv2.COLOR_BGR2YUV_I420)
|
||||||
|
|
||||||
def test_crop_yuv(self):
|
def test_crop_yuv(self):
|
||||||
cropped = yuv_region_2_rgb(self.yuv_frame, (10,10,50,50))
|
cropped = yuv_region_2_rgb(self.yuv_frame, (10, 10, 50, 50))
|
||||||
# ensure the upper left pixel is blue
|
# ensure the upper left pixel is blue
|
||||||
assert(np.all(cropped[0, 0] == [0, 0, 255]))
|
assert np.all(cropped[0, 0] == [0, 0, 255])
|
||||||
|
|
||||||
def test_crop_yuv_out_of_bounds(self):
|
def test_crop_yuv_out_of_bounds(self):
|
||||||
cropped = yuv_region_2_rgb(self.yuv_frame, (0,0,200,200))
|
cropped = yuv_region_2_rgb(self.yuv_frame, (0, 0, 200, 200))
|
||||||
# cv2.imwrite(f"cropped.jpg", cv2.cvtColor(cropped, cv2.COLOR_RGB2BGR))
|
# cv2.imwrite(f"cropped.jpg", cv2.cvtColor(cropped, cv2.COLOR_RGB2BGR))
|
||||||
# ensure the upper left pixel is red
|
# ensure the upper left pixel is red
|
||||||
# the yuv conversion has some noise
|
# the yuv conversion has some noise
|
||||||
assert(np.all(cropped[0, 0] == [255, 1, 0]))
|
assert np.all(cropped[0, 0] == [255, 1, 0])
|
||||||
# ensure the bottom right is black
|
# ensure the bottom right is black
|
||||||
assert(np.all(cropped[199, 199] == [0, 0, 0]))
|
assert np.all(cropped[199, 199] == [0, 0, 0])
|
||||||
|
|
||||||
def test_crop_yuv_portrait(self):
|
def test_crop_yuv_portrait(self):
|
||||||
bgr_frame = np.zeros((1920, 1080, 3), np.uint8)
|
bgr_frame = np.zeros((1920, 1080, 3), np.uint8)
|
||||||
bgr_frame[:] = (0, 0, 255)
|
bgr_frame[:] = (0, 0, 255)
|
||||||
bgr_frame[5:55, 5:55] = (255,0,0)
|
bgr_frame[5:55, 5:55] = (255, 0, 0)
|
||||||
# cv2.imwrite(f"bgr_frame.jpg", self.bgr_frame)
|
# cv2.imwrite(f"bgr_frame.jpg", self.bgr_frame)
|
||||||
yuv_frame = cv2.cvtColor(bgr_frame, cv2.COLOR_BGR2YUV_I420)
|
yuv_frame = cv2.cvtColor(bgr_frame, cv2.COLOR_BGR2YUV_I420)
|
||||||
|
|
||||||
cropped = yuv_region_2_rgb(yuv_frame, (0, 852, 648, 1500))
|
cropped = yuv_region_2_rgb(yuv_frame, (0, 852, 648, 1500))
|
||||||
# cv2.imwrite(f"cropped.jpg", cv2.cvtColor(cropped, cv2.COLOR_RGB2BGR))
|
# cv2.imwrite(f"cropped.jpg", cv2.cvtColor(cropped, cv2.COLOR_RGB2BGR))
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
|
if __name__ == "__main__":
|
||||||
main(verbosity=2)
|
main(verbosity=2)
|
||||||
213
frigate/util.py
213
frigate/util.py
@ -19,9 +19,20 @@ import numpy as np
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def draw_box_with_label(frame, x_min, y_min, x_max, y_max, label, info, thickness=2, color=None, position='ul'):
|
def draw_box_with_label(
|
||||||
|
frame,
|
||||||
|
x_min,
|
||||||
|
y_min,
|
||||||
|
x_max,
|
||||||
|
y_max,
|
||||||
|
label,
|
||||||
|
info,
|
||||||
|
thickness=2,
|
||||||
|
color=None,
|
||||||
|
position="ul",
|
||||||
|
):
|
||||||
if color is None:
|
if color is None:
|
||||||
color = (0,0,255)
|
color = (0, 0, 255)
|
||||||
display_text = "{}: {}".format(label, info)
|
display_text = "{}: {}".format(label, info)
|
||||||
cv2.rectangle(frame, (x_min, y_min), (x_max, y_max), color, thickness)
|
cv2.rectangle(frame, (x_min, y_min), (x_max, y_max), color, thickness)
|
||||||
font_scale = 0.5
|
font_scale = 0.5
|
||||||
@ -32,106 +43,115 @@ def draw_box_with_label(frame, x_min, y_min, x_max, y_max, label, info, thicknes
|
|||||||
text_height = size[0][1]
|
text_height = size[0][1]
|
||||||
line_height = text_height + size[1]
|
line_height = text_height + size[1]
|
||||||
# set the text start position
|
# set the text start position
|
||||||
if position == 'ul':
|
if position == "ul":
|
||||||
text_offset_x = x_min
|
text_offset_x = x_min
|
||||||
text_offset_y = 0 if y_min < line_height else y_min - (line_height+8)
|
text_offset_y = 0 if y_min < line_height else y_min - (line_height + 8)
|
||||||
elif position == 'ur':
|
elif position == "ur":
|
||||||
text_offset_x = x_max - (text_width+8)
|
text_offset_x = x_max - (text_width + 8)
|
||||||
text_offset_y = 0 if y_min < line_height else y_min - (line_height+8)
|
text_offset_y = 0 if y_min < line_height else y_min - (line_height + 8)
|
||||||
elif position == 'bl':
|
elif position == "bl":
|
||||||
text_offset_x = x_min
|
text_offset_x = x_min
|
||||||
text_offset_y = y_max
|
text_offset_y = y_max
|
||||||
elif position == 'br':
|
elif position == "br":
|
||||||
text_offset_x = x_max - (text_width+8)
|
text_offset_x = x_max - (text_width + 8)
|
||||||
text_offset_y = y_max
|
text_offset_y = y_max
|
||||||
# make the coords of the box with a small padding of two pixels
|
# make the coords of the box with a small padding of two pixels
|
||||||
textbox_coords = ((text_offset_x, text_offset_y), (text_offset_x + text_width + 2, text_offset_y + line_height))
|
textbox_coords = (
|
||||||
|
(text_offset_x, text_offset_y),
|
||||||
|
(text_offset_x + text_width + 2, text_offset_y + line_height),
|
||||||
|
)
|
||||||
cv2.rectangle(frame, textbox_coords[0], textbox_coords[1], color, cv2.FILLED)
|
cv2.rectangle(frame, textbox_coords[0], textbox_coords[1], color, cv2.FILLED)
|
||||||
cv2.putText(frame, display_text, (text_offset_x, text_offset_y + line_height - 3), font, fontScale=font_scale, color=(0, 0, 0), thickness=2)
|
cv2.putText(
|
||||||
|
frame,
|
||||||
|
display_text,
|
||||||
|
(text_offset_x, text_offset_y + line_height - 3),
|
||||||
|
font,
|
||||||
|
fontScale=font_scale,
|
||||||
|
color=(0, 0, 0),
|
||||||
|
thickness=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def calculate_region(frame_shape, xmin, ymin, xmax, ymax, multiplier=2):
|
def calculate_region(frame_shape, xmin, ymin, xmax, ymax, multiplier=2):
|
||||||
# size is the longest edge and divisible by 4
|
# size is the longest edge and divisible by 4
|
||||||
size = int(max(xmax-xmin, ymax-ymin)//4*4*multiplier)
|
size = int(max(xmax - xmin, ymax - ymin) // 4 * 4 * multiplier)
|
||||||
# dont go any smaller than 300
|
# dont go any smaller than 300
|
||||||
if size < 300:
|
if size < 300:
|
||||||
size = 300
|
size = 300
|
||||||
|
|
||||||
# x_offset is midpoint of bounding box minus half the size
|
# x_offset is midpoint of bounding box minus half the size
|
||||||
x_offset = int((xmax-xmin)/2.0+xmin-size/2.0)
|
x_offset = int((xmax - xmin) / 2.0 + xmin - size / 2.0)
|
||||||
# if outside the image
|
# if outside the image
|
||||||
if x_offset < 0:
|
if x_offset < 0:
|
||||||
x_offset = 0
|
x_offset = 0
|
||||||
elif x_offset > (frame_shape[1]-size):
|
elif x_offset > (frame_shape[1] - size):
|
||||||
x_offset = max(0, (frame_shape[1]-size))
|
x_offset = max(0, (frame_shape[1] - size))
|
||||||
|
|
||||||
# y_offset is midpoint of bounding box minus half the size
|
# y_offset is midpoint of bounding box minus half the size
|
||||||
y_offset = int((ymax-ymin)/2.0+ymin-size/2.0)
|
y_offset = int((ymax - ymin) / 2.0 + ymin - size / 2.0)
|
||||||
# # if outside the image
|
# # if outside the image
|
||||||
if y_offset < 0:
|
if y_offset < 0:
|
||||||
y_offset = 0
|
y_offset = 0
|
||||||
elif y_offset > (frame_shape[0]-size):
|
elif y_offset > (frame_shape[0] - size):
|
||||||
y_offset = max(0, (frame_shape[0]-size))
|
y_offset = max(0, (frame_shape[0] - size))
|
||||||
|
|
||||||
|
return (x_offset, y_offset, x_offset + size, y_offset + size)
|
||||||
|
|
||||||
return (x_offset, y_offset, x_offset+size, y_offset+size)
|
|
||||||
|
|
||||||
def get_yuv_crop(frame_shape, crop):
|
def get_yuv_crop(frame_shape, crop):
|
||||||
# crop should be (x1,y1,x2,y2)
|
# crop should be (x1,y1,x2,y2)
|
||||||
frame_height = frame_shape[0]//3*2
|
frame_height = frame_shape[0] // 3 * 2
|
||||||
frame_width = frame_shape[1]
|
frame_width = frame_shape[1]
|
||||||
|
|
||||||
# compute the width/height of the uv channels
|
# compute the width/height of the uv channels
|
||||||
uv_width = frame_width//2 # width of the uv channels
|
uv_width = frame_width // 2 # width of the uv channels
|
||||||
uv_height = frame_height//4 # height of the uv channels
|
uv_height = frame_height // 4 # height of the uv channels
|
||||||
|
|
||||||
# compute the offset for upper left corner of the uv channels
|
# compute the offset for upper left corner of the uv channels
|
||||||
uv_x_offset = crop[0]//2 # x offset of the uv channels
|
uv_x_offset = crop[0] // 2 # x offset of the uv channels
|
||||||
uv_y_offset = crop[1]//4 # y offset of the uv channels
|
uv_y_offset = crop[1] // 4 # y offset of the uv channels
|
||||||
|
|
||||||
# compute the width/height of the uv crops
|
# compute the width/height of the uv crops
|
||||||
uv_crop_width = (crop[2] - crop[0])//2 # width of the cropped uv channels
|
uv_crop_width = (crop[2] - crop[0]) // 2 # width of the cropped uv channels
|
||||||
uv_crop_height = (crop[3] - crop[1])//4 # height of the cropped uv channels
|
uv_crop_height = (crop[3] - crop[1]) // 4 # height of the cropped uv channels
|
||||||
|
|
||||||
# ensure crop dimensions are multiples of 2 and 4
|
# ensure crop dimensions are multiples of 2 and 4
|
||||||
y = (
|
y = (crop[0], crop[1], crop[0] + uv_crop_width * 2, crop[1] + uv_crop_height * 4)
|
||||||
crop[0],
|
|
||||||
crop[1],
|
|
||||||
crop[0] + uv_crop_width*2,
|
|
||||||
crop[1] + uv_crop_height*4
|
|
||||||
)
|
|
||||||
|
|
||||||
u1 = (
|
u1 = (
|
||||||
0 + uv_x_offset,
|
0 + uv_x_offset,
|
||||||
frame_height + uv_y_offset,
|
frame_height + uv_y_offset,
|
||||||
0 + uv_x_offset + uv_crop_width,
|
0 + uv_x_offset + uv_crop_width,
|
||||||
frame_height + uv_y_offset + uv_crop_height
|
frame_height + uv_y_offset + uv_crop_height,
|
||||||
)
|
)
|
||||||
|
|
||||||
u2 = (
|
u2 = (
|
||||||
uv_width + uv_x_offset,
|
uv_width + uv_x_offset,
|
||||||
frame_height + uv_y_offset,
|
frame_height + uv_y_offset,
|
||||||
uv_width + uv_x_offset + uv_crop_width,
|
uv_width + uv_x_offset + uv_crop_width,
|
||||||
frame_height + uv_y_offset + uv_crop_height
|
frame_height + uv_y_offset + uv_crop_height,
|
||||||
)
|
)
|
||||||
|
|
||||||
v1 = (
|
v1 = (
|
||||||
0 + uv_x_offset,
|
0 + uv_x_offset,
|
||||||
frame_height + uv_height + uv_y_offset,
|
frame_height + uv_height + uv_y_offset,
|
||||||
0 + uv_x_offset + uv_crop_width,
|
0 + uv_x_offset + uv_crop_width,
|
||||||
frame_height + uv_height + uv_y_offset + uv_crop_height
|
frame_height + uv_height + uv_y_offset + uv_crop_height,
|
||||||
)
|
)
|
||||||
|
|
||||||
v2 = (
|
v2 = (
|
||||||
uv_width + uv_x_offset,
|
uv_width + uv_x_offset,
|
||||||
frame_height + uv_height + uv_y_offset,
|
frame_height + uv_height + uv_y_offset,
|
||||||
uv_width + uv_x_offset + uv_crop_width,
|
uv_width + uv_x_offset + uv_crop_width,
|
||||||
frame_height + uv_height + uv_y_offset + uv_crop_height
|
frame_height + uv_height + uv_y_offset + uv_crop_height,
|
||||||
)
|
)
|
||||||
|
|
||||||
return y, u1, u2, v1, v2
|
return y, u1, u2, v1, v2
|
||||||
|
|
||||||
|
|
||||||
def yuv_region_2_rgb(frame, region):
|
def yuv_region_2_rgb(frame, region):
|
||||||
try:
|
try:
|
||||||
height = frame.shape[0]//3*2
|
height = frame.shape[0] // 3 * 2
|
||||||
width = frame.shape[1]
|
width = frame.shape[1]
|
||||||
|
|
||||||
# get the crop box if the region extends beyond the frame
|
# get the crop box if the region extends beyond the frame
|
||||||
@ -148,64 +168,65 @@ def yuv_region_2_rgb(frame, region):
|
|||||||
y_channel_x_offset = abs(min(0, region[0]))
|
y_channel_x_offset = abs(min(0, region[0]))
|
||||||
y_channel_y_offset = abs(min(0, region[1]))
|
y_channel_y_offset = abs(min(0, region[1]))
|
||||||
|
|
||||||
uv_channel_x_offset = y_channel_x_offset//2
|
uv_channel_x_offset = y_channel_x_offset // 2
|
||||||
uv_channel_y_offset = y_channel_y_offset//4
|
uv_channel_y_offset = y_channel_y_offset // 4
|
||||||
|
|
||||||
# create the yuv region frame
|
# create the yuv region frame
|
||||||
# make sure the size is a multiple of 4
|
# make sure the size is a multiple of 4
|
||||||
size = (region[3] - region[1])//4*4
|
size = (region[3] - region[1]) // 4 * 4
|
||||||
yuv_cropped_frame = np.zeros((size+size//2, size), np.uint8)
|
yuv_cropped_frame = np.zeros((size + size // 2, size), np.uint8)
|
||||||
# fill in black
|
# fill in black
|
||||||
yuv_cropped_frame[:] = 128
|
yuv_cropped_frame[:] = 128
|
||||||
yuv_cropped_frame[0:size,0:size] = 16
|
yuv_cropped_frame[0:size, 0:size] = 16
|
||||||
|
|
||||||
# copy the y channel
|
# copy the y channel
|
||||||
yuv_cropped_frame[
|
yuv_cropped_frame[
|
||||||
y_channel_y_offset:y_channel_y_offset + y[3] - y[1],
|
y_channel_y_offset : y_channel_y_offset + y[3] - y[1],
|
||||||
y_channel_x_offset:y_channel_x_offset + y[2] - y[0]
|
y_channel_x_offset : y_channel_x_offset + y[2] - y[0],
|
||||||
] = frame[
|
] = frame[y[1] : y[3], y[0] : y[2]]
|
||||||
y[1]:y[3],
|
|
||||||
y[0]:y[2]
|
|
||||||
]
|
|
||||||
|
|
||||||
uv_crop_width = u1[2] - u1[0]
|
uv_crop_width = u1[2] - u1[0]
|
||||||
uv_crop_height = u1[3] - u1[1]
|
uv_crop_height = u1[3] - u1[1]
|
||||||
|
|
||||||
# copy u1
|
# copy u1
|
||||||
yuv_cropped_frame[
|
yuv_cropped_frame[
|
||||||
size + uv_channel_y_offset:size + uv_channel_y_offset + uv_crop_height,
|
size + uv_channel_y_offset : size + uv_channel_y_offset + uv_crop_height,
|
||||||
0 + uv_channel_x_offset:0 + uv_channel_x_offset + uv_crop_width
|
0 + uv_channel_x_offset : 0 + uv_channel_x_offset + uv_crop_width,
|
||||||
] = frame[
|
] = frame[u1[1] : u1[3], u1[0] : u1[2]]
|
||||||
u1[1]:u1[3],
|
|
||||||
u1[0]:u1[2]
|
|
||||||
]
|
|
||||||
|
|
||||||
# copy u2
|
# copy u2
|
||||||
yuv_cropped_frame[
|
yuv_cropped_frame[
|
||||||
size + uv_channel_y_offset:size + uv_channel_y_offset + uv_crop_height,
|
size + uv_channel_y_offset : size + uv_channel_y_offset + uv_crop_height,
|
||||||
size//2 + uv_channel_x_offset:size//2 + uv_channel_x_offset + uv_crop_width
|
size // 2
|
||||||
] = frame[
|
+ uv_channel_x_offset : size // 2
|
||||||
u2[1]:u2[3],
|
+ uv_channel_x_offset
|
||||||
u2[0]:u2[2]
|
+ uv_crop_width,
|
||||||
]
|
] = frame[u2[1] : u2[3], u2[0] : u2[2]]
|
||||||
|
|
||||||
# copy v1
|
# copy v1
|
||||||
yuv_cropped_frame[
|
yuv_cropped_frame[
|
||||||
size+size//4 + uv_channel_y_offset:size+size//4 + uv_channel_y_offset + uv_crop_height,
|
size
|
||||||
0 + uv_channel_x_offset:0 + uv_channel_x_offset + uv_crop_width
|
+ size // 4
|
||||||
] = frame[
|
+ uv_channel_y_offset : size
|
||||||
v1[1]:v1[3],
|
+ size // 4
|
||||||
v1[0]:v1[2]
|
+ uv_channel_y_offset
|
||||||
]
|
+ uv_crop_height,
|
||||||
|
0 + uv_channel_x_offset : 0 + uv_channel_x_offset + uv_crop_width,
|
||||||
|
] = frame[v1[1] : v1[3], v1[0] : v1[2]]
|
||||||
|
|
||||||
# copy v2
|
# copy v2
|
||||||
yuv_cropped_frame[
|
yuv_cropped_frame[
|
||||||
size+size//4 + uv_channel_y_offset:size+size//4 + uv_channel_y_offset + uv_crop_height,
|
size
|
||||||
size//2 + uv_channel_x_offset:size//2 + uv_channel_x_offset + uv_crop_width
|
+ size // 4
|
||||||
] = frame[
|
+ uv_channel_y_offset : size
|
||||||
v2[1]:v2[3],
|
+ size // 4
|
||||||
v2[0]:v2[2]
|
+ uv_channel_y_offset
|
||||||
]
|
+ uv_crop_height,
|
||||||
|
size // 2
|
||||||
|
+ uv_channel_x_offset : size // 2
|
||||||
|
+ uv_channel_x_offset
|
||||||
|
+ uv_crop_width,
|
||||||
|
] = frame[v2[1] : v2[3], v2[0] : v2[2]]
|
||||||
|
|
||||||
return cv2.cvtColor(yuv_cropped_frame, cv2.COLOR_YUV2RGB_I420)
|
return cv2.cvtColor(yuv_cropped_frame, cv2.COLOR_YUV2RGB_I420)
|
||||||
except:
|
except:
|
||||||
@ -213,23 +234,28 @@ def yuv_region_2_rgb(frame, region):
|
|||||||
print(f"region: {region}")
|
print(f"region: {region}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def intersection(box_a, box_b):
|
def intersection(box_a, box_b):
|
||||||
return (
|
return (
|
||||||
max(box_a[0], box_b[0]),
|
max(box_a[0], box_b[0]),
|
||||||
max(box_a[1], box_b[1]),
|
max(box_a[1], box_b[1]),
|
||||||
min(box_a[2], box_b[2]),
|
min(box_a[2], box_b[2]),
|
||||||
min(box_a[3], box_b[3])
|
min(box_a[3], box_b[3]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def area(box):
|
def area(box):
|
||||||
return (box[2]-box[0] + 1)*(box[3]-box[1] + 1)
|
return (box[2] - box[0] + 1) * (box[3] - box[1] + 1)
|
||||||
|
|
||||||
|
|
||||||
def intersection_over_union(box_a, box_b):
|
def intersection_over_union(box_a, box_b):
|
||||||
# determine the (x, y)-coordinates of the intersection rectangle
|
# determine the (x, y)-coordinates of the intersection rectangle
|
||||||
intersect = intersection(box_a, box_b)
|
intersect = intersection(box_a, box_b)
|
||||||
|
|
||||||
# compute the area of intersection rectangle
|
# compute the area of intersection rectangle
|
||||||
inter_area = max(0, intersect[2] - intersect[0] + 1) * max(0, intersect[3] - intersect[1] + 1)
|
inter_area = max(0, intersect[2] - intersect[0] + 1) * max(
|
||||||
|
0, intersect[3] - intersect[1] + 1
|
||||||
|
)
|
||||||
|
|
||||||
if inter_area == 0:
|
if inter_area == 0:
|
||||||
return 0.0
|
return 0.0
|
||||||
@ -247,19 +273,23 @@ def intersection_over_union(box_a, box_b):
|
|||||||
# return the intersection over union value
|
# return the intersection over union value
|
||||||
return iou
|
return iou
|
||||||
|
|
||||||
|
|
||||||
def clipped(obj, frame_shape):
|
def clipped(obj, frame_shape):
|
||||||
# if the object is within 5 pixels of the region border, and the region is not on the edge
|
# if the object is within 5 pixels of the region border, and the region is not on the edge
|
||||||
# consider the object to be clipped
|
# consider the object to be clipped
|
||||||
box = obj[2]
|
box = obj[2]
|
||||||
region = obj[4]
|
region = obj[4]
|
||||||
if ((region[0] > 5 and box[0]-region[0] <= 5) or
|
if (
|
||||||
(region[1] > 5 and box[1]-region[1] <= 5) or
|
(region[0] > 5 and box[0] - region[0] <= 5)
|
||||||
(frame_shape[1]-region[2] > 5 and region[2]-box[2] <= 5) or
|
or (region[1] > 5 and box[1] - region[1] <= 5)
|
||||||
(frame_shape[0]-region[3] > 5 and region[3]-box[3] <= 5)):
|
or (frame_shape[1] - region[2] > 5 and region[2] - box[2] <= 5)
|
||||||
|
or (frame_shape[0] - region[3] > 5 and region[3] - box[3] <= 5)
|
||||||
|
):
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class EventsPerSecond:
|
class EventsPerSecond:
|
||||||
def __init__(self, max_events=1000):
|
def __init__(self, max_events=1000):
|
||||||
self._start = None
|
self._start = None
|
||||||
@ -274,23 +304,28 @@ class EventsPerSecond:
|
|||||||
self.start()
|
self.start()
|
||||||
self._timestamps.append(datetime.datetime.now().timestamp())
|
self._timestamps.append(datetime.datetime.now().timestamp())
|
||||||
# truncate the list when it goes 100 over the max_size
|
# truncate the list when it goes 100 over the max_size
|
||||||
if len(self._timestamps) > self._max_events+100:
|
if len(self._timestamps) > self._max_events + 100:
|
||||||
self._timestamps = self._timestamps[(1-self._max_events):]
|
self._timestamps = self._timestamps[(1 - self._max_events) :]
|
||||||
|
|
||||||
def eps(self, last_n_seconds=10):
|
def eps(self, last_n_seconds=10):
|
||||||
if self._start is None:
|
if self._start is None:
|
||||||
self.start()
|
self.start()
|
||||||
# compute the (approximate) events in the last n seconds
|
# compute the (approximate) events in the last n seconds
|
||||||
now = datetime.datetime.now().timestamp()
|
now = datetime.datetime.now().timestamp()
|
||||||
seconds = min(now-self._start, last_n_seconds)
|
seconds = min(now - self._start, last_n_seconds)
|
||||||
return len([t for t in self._timestamps if t > (now-last_n_seconds)]) / seconds
|
return (
|
||||||
|
len([t for t in self._timestamps if t > (now - last_n_seconds)]) / seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def print_stack(sig, frame):
|
def print_stack(sig, frame):
|
||||||
traceback.print_stack(frame)
|
traceback.print_stack(frame)
|
||||||
|
|
||||||
|
|
||||||
def listen():
|
def listen():
|
||||||
signal.signal(signal.SIGUSR1, print_stack)
|
signal.signal(signal.SIGUSR1, print_stack)
|
||||||
|
|
||||||
|
|
||||||
def create_mask(frame_shape, mask):
|
def create_mask(frame_shape, mask):
|
||||||
mask_img = np.zeros(frame_shape, np.uint8)
|
mask_img = np.zeros(frame_shape, np.uint8)
|
||||||
mask_img[:] = 255
|
mask_img[:] = 255
|
||||||
@ -304,11 +339,15 @@ def create_mask(frame_shape, mask):
|
|||||||
|
|
||||||
return mask_img
|
return mask_img
|
||||||
|
|
||||||
|
|
||||||
def add_mask(mask, mask_img):
|
def add_mask(mask, mask_img):
|
||||||
points = mask.split(',')
|
points = mask.split(",")
|
||||||
contour = np.array([[int(points[i]), int(points[i+1])] for i in range(0, len(points), 2)])
|
contour = np.array(
|
||||||
|
[[int(points[i]), int(points[i + 1])] for i in range(0, len(points), 2)]
|
||||||
|
)
|
||||||
cv2.fillPoly(mask_img, pts=[contour], color=(0))
|
cv2.fillPoly(mask_img, pts=[contour], color=(0))
|
||||||
|
|
||||||
|
|
||||||
class FrameManager(ABC):
|
class FrameManager(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def create(self, name, size) -> AnyStr:
|
def create(self, name, size) -> AnyStr:
|
||||||
@ -326,6 +365,7 @@ class FrameManager(ABC):
|
|||||||
def delete(self, name):
|
def delete(self, name):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class DictFrameManager(FrameManager):
|
class DictFrameManager(FrameManager):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.frames = {}
|
self.frames = {}
|
||||||
@ -345,6 +385,7 @@ class DictFrameManager(FrameManager):
|
|||||||
def delete(self, name):
|
def delete(self, name):
|
||||||
del self.frames[name]
|
del self.frames[name]
|
||||||
|
|
||||||
|
|
||||||
class SharedMemoryFrameManager(FrameManager):
|
class SharedMemoryFrameManager(FrameManager):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.shm_store = {}
|
self.shm_store = {}
|
||||||
|
|||||||
350
frigate/video.py
350
frigate/video.py
@ -1,12 +1,7 @@
|
|||||||
import base64
|
|
||||||
import copy
|
|
||||||
import ctypes
|
|
||||||
import datetime
|
import datetime
|
||||||
import itertools
|
import itertools
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing as mp
|
import multiprocessing as mp
|
||||||
import os
|
|
||||||
import queue
|
import queue
|
||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
import signal
|
import signal
|
||||||
@ -16,7 +11,7 @@ from collections import defaultdict
|
|||||||
from setproctitle import setproctitle
|
from setproctitle import setproctitle
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
|
||||||
import cv2
|
from cv2 import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from frigate.config import CameraConfig
|
from frigate.config import CameraConfig
|
||||||
@ -24,13 +19,19 @@ from frigate.edgetpu import RemoteObjectDetector
|
|||||||
from frigate.log import LogPipe
|
from frigate.log import LogPipe
|
||||||
from frigate.motion import MotionDetector
|
from frigate.motion import MotionDetector
|
||||||
from frigate.objects import ObjectTracker
|
from frigate.objects import ObjectTracker
|
||||||
from frigate.util import (EventsPerSecond, FrameManager,
|
from frigate.util import (
|
||||||
SharedMemoryFrameManager, area, calculate_region,
|
EventsPerSecond,
|
||||||
clipped, draw_box_with_label, intersection,
|
FrameManager,
|
||||||
intersection_over_union, listen, yuv_region_2_rgb)
|
SharedMemoryFrameManager,
|
||||||
|
calculate_region,
|
||||||
|
clipped,
|
||||||
|
listen,
|
||||||
|
yuv_region_2_rgb,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def filtered(obj, objects_to_track, object_filters):
|
def filtered(obj, objects_to_track, object_filters):
|
||||||
object_name = obj[0]
|
object_name = obj[0]
|
||||||
|
|
||||||
@ -57,8 +58,11 @@ def filtered(obj, objects_to_track, object_filters):
|
|||||||
if not obj_settings.mask is None:
|
if not obj_settings.mask is None:
|
||||||
# compute the coordinates of the object and make sure
|
# compute the coordinates of the object and make sure
|
||||||
# the location isnt outside the bounds of the image (can happen from rounding)
|
# the location isnt outside the bounds of the image (can happen from rounding)
|
||||||
y_location = min(int(obj[2][3]), len(obj_settings.mask)-1)
|
y_location = min(int(obj[2][3]), len(obj_settings.mask) - 1)
|
||||||
x_location = min(int((obj[2][2]-obj[2][0])/2.0)+obj[2][0], len(obj_settings.mask[0])-1)
|
x_location = min(
|
||||||
|
int((obj[2][2] - obj[2][0]) / 2.0) + obj[2][0],
|
||||||
|
len(obj_settings.mask[0]) - 1,
|
||||||
|
)
|
||||||
|
|
||||||
# if the object is in a masked location, don't add it to detected objects
|
# if the object is in a masked location, don't add it to detected objects
|
||||||
if obj_settings.mask[y_location][x_location] == 0:
|
if obj_settings.mask[y_location][x_location] == 0:
|
||||||
@ -66,16 +70,20 @@ def filtered(obj, objects_to_track, object_filters):
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def create_tensor_input(frame, model_shape, region):
|
def create_tensor_input(frame, model_shape, region):
|
||||||
cropped_frame = yuv_region_2_rgb(frame, region)
|
cropped_frame = yuv_region_2_rgb(frame, region)
|
||||||
|
|
||||||
# Resize to 300x300 if needed
|
# Resize to 300x300 if needed
|
||||||
if cropped_frame.shape != (model_shape[0], model_shape[1], 3):
|
if cropped_frame.shape != (model_shape[0], model_shape[1], 3):
|
||||||
cropped_frame = cv2.resize(cropped_frame, dsize=model_shape, interpolation=cv2.INTER_LINEAR)
|
cropped_frame = cv2.resize(
|
||||||
|
cropped_frame, dsize=model_shape, interpolation=cv2.INTER_LINEAR
|
||||||
|
)
|
||||||
|
|
||||||
# Expand dimensions since the model expects images to have shape: [1, height, width, 3]
|
# Expand dimensions since the model expects images to have shape: [1, height, width, 3]
|
||||||
return np.expand_dims(cropped_frame, axis=0)
|
return np.expand_dims(cropped_frame, axis=0)
|
||||||
|
|
||||||
|
|
||||||
def stop_ffmpeg(ffmpeg_process, logger):
|
def stop_ffmpeg(ffmpeg_process, logger):
|
||||||
logger.info("Terminating the existing ffmpeg process...")
|
logger.info("Terminating the existing ffmpeg process...")
|
||||||
ffmpeg_process.terminate()
|
ffmpeg_process.terminate()
|
||||||
@ -88,18 +96,43 @@ def stop_ffmpeg(ffmpeg_process, logger):
|
|||||||
ffmpeg_process.communicate()
|
ffmpeg_process.communicate()
|
||||||
ffmpeg_process = None
|
ffmpeg_process = None
|
||||||
|
|
||||||
def start_or_restart_ffmpeg(ffmpeg_cmd, logger, logpipe: LogPipe, frame_size=None, ffmpeg_process=None):
|
|
||||||
if not ffmpeg_process is None:
|
def start_or_restart_ffmpeg(
|
||||||
|
ffmpeg_cmd, logger, logpipe: LogPipe, frame_size=None, ffmpeg_process=None
|
||||||
|
):
|
||||||
|
if ffmpeg_process is not None:
|
||||||
stop_ffmpeg(ffmpeg_process, logger)
|
stop_ffmpeg(ffmpeg_process, logger)
|
||||||
|
|
||||||
if frame_size is None:
|
if frame_size is None:
|
||||||
process = sp.Popen(ffmpeg_cmd, stdout = sp.DEVNULL, stderr=logpipe, stdin = sp.DEVNULL, start_new_session=True)
|
process = sp.Popen(
|
||||||
|
ffmpeg_cmd,
|
||||||
|
stdout=sp.DEVNULL,
|
||||||
|
stderr=logpipe,
|
||||||
|
stdin=sp.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
process = sp.Popen(ffmpeg_cmd, stdout = sp.PIPE, stderr=logpipe, stdin = sp.DEVNULL, bufsize=frame_size*10, start_new_session=True)
|
process = sp.Popen(
|
||||||
|
ffmpeg_cmd,
|
||||||
|
stdout=sp.PIPE,
|
||||||
|
stderr=logpipe,
|
||||||
|
stdin=sp.DEVNULL,
|
||||||
|
bufsize=frame_size * 10,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
return process
|
return process
|
||||||
|
|
||||||
def capture_frames(ffmpeg_process, camera_name, frame_shape, frame_manager: FrameManager,
|
|
||||||
frame_queue, fps:mp.Value, skipped_fps: mp.Value, current_frame: mp.Value):
|
def capture_frames(
|
||||||
|
ffmpeg_process,
|
||||||
|
camera_name,
|
||||||
|
frame_shape,
|
||||||
|
frame_manager: FrameManager,
|
||||||
|
frame_queue,
|
||||||
|
fps: mp.Value,
|
||||||
|
skipped_fps: mp.Value,
|
||||||
|
current_frame: mp.Value,
|
||||||
|
):
|
||||||
|
|
||||||
frame_size = frame_shape[0] * frame_shape[1]
|
frame_size = frame_shape[0] * frame_shape[1]
|
||||||
frame_rate = EventsPerSecond()
|
frame_rate = EventsPerSecond()
|
||||||
@ -119,7 +152,9 @@ def capture_frames(ffmpeg_process, camera_name, frame_shape, frame_manager: Fram
|
|||||||
logger.info(f"{camera_name}: ffmpeg sent a broken frame. {e}")
|
logger.info(f"{camera_name}: ffmpeg sent a broken frame. {e}")
|
||||||
|
|
||||||
if ffmpeg_process.poll() != None:
|
if ffmpeg_process.poll() != None:
|
||||||
logger.info(f"{camera_name}: ffmpeg process is not running. exiting capture thread...")
|
logger.info(
|
||||||
|
f"{camera_name}: ffmpeg process is not running. exiting capture thread..."
|
||||||
|
)
|
||||||
frame_manager.delete(frame_name)
|
frame_manager.delete(frame_name)
|
||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
@ -138,8 +173,11 @@ def capture_frames(ffmpeg_process, camera_name, frame_shape, frame_manager: Fram
|
|||||||
# add to the queue
|
# add to the queue
|
||||||
frame_queue.put(current_frame.value)
|
frame_queue.put(current_frame.value)
|
||||||
|
|
||||||
|
|
||||||
class CameraWatchdog(threading.Thread):
|
class CameraWatchdog(threading.Thread):
|
||||||
def __init__(self, camera_name, config, frame_queue, camera_fps, ffmpeg_pid, stop_event):
|
def __init__(
|
||||||
|
self, camera_name, config, frame_queue, camera_fps, ffmpeg_pid, stop_event
|
||||||
|
):
|
||||||
threading.Thread.__init__(self)
|
threading.Thread.__init__(self)
|
||||||
self.logger = logging.getLogger(f"watchdog.{camera_name}")
|
self.logger = logging.getLogger(f"watchdog.{camera_name}")
|
||||||
self.camera_name = camera_name
|
self.camera_name = camera_name
|
||||||
@ -159,32 +197,31 @@ class CameraWatchdog(threading.Thread):
|
|||||||
self.start_ffmpeg_detect()
|
self.start_ffmpeg_detect()
|
||||||
|
|
||||||
for c in self.config.ffmpeg_cmds:
|
for c in self.config.ffmpeg_cmds:
|
||||||
if 'detect' in c['roles']:
|
if "detect" in c["roles"]:
|
||||||
continue
|
continue
|
||||||
logpipe = LogPipe(f"ffmpeg.{self.camera_name}.{'_'.join(sorted(c['roles']))}", logging.ERROR)
|
logpipe = LogPipe(
|
||||||
self.ffmpeg_other_processes.append({
|
f"ffmpeg.{self.camera_name}.{'_'.join(sorted(c['roles']))}",
|
||||||
'cmd': c['cmd'],
|
logging.ERROR,
|
||||||
'logpipe': logpipe,
|
)
|
||||||
'process': start_or_restart_ffmpeg(c['cmd'], self.logger, logpipe)
|
self.ffmpeg_other_processes.append(
|
||||||
})
|
{
|
||||||
|
"cmd": c["cmd"],
|
||||||
|
"logpipe": logpipe,
|
||||||
|
"process": start_or_restart_ffmpeg(c["cmd"], self.logger, logpipe),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
while True:
|
while not self.stop_event.wait(10):
|
||||||
if self.stop_event.is_set():
|
|
||||||
stop_ffmpeg(self.ffmpeg_detect_process, self.logger)
|
|
||||||
for p in self.ffmpeg_other_processes:
|
|
||||||
stop_ffmpeg(p['process'], self.logger)
|
|
||||||
p['logpipe'].close()
|
|
||||||
self.logpipe.close()
|
|
||||||
break
|
|
||||||
|
|
||||||
now = datetime.datetime.now().timestamp()
|
now = datetime.datetime.now().timestamp()
|
||||||
|
|
||||||
if not self.capture_thread.is_alive():
|
if not self.capture_thread.is_alive():
|
||||||
self.logpipe.dump()
|
self.logpipe.dump()
|
||||||
self.start_ffmpeg_detect()
|
self.start_ffmpeg_detect()
|
||||||
elif now - self.capture_thread.current_frame.value > 20:
|
elif now - self.capture_thread.current_frame.value > 20:
|
||||||
self.logger.info(f"No frames received from {self.camera_name} in 20 seconds. Exiting ffmpeg...")
|
self.logger.info(
|
||||||
|
f"No frames received from {self.camera_name} in 20 seconds. Exiting ffmpeg..."
|
||||||
|
)
|
||||||
self.ffmpeg_detect_process.terminate()
|
self.ffmpeg_detect_process.terminate()
|
||||||
try:
|
try:
|
||||||
self.logger.info("Waiting for ffmpeg to exit gracefully...")
|
self.logger.info("Waiting for ffmpeg to exit gracefully...")
|
||||||
@ -195,23 +232,38 @@ class CameraWatchdog(threading.Thread):
|
|||||||
self.ffmpeg_detect_process.communicate()
|
self.ffmpeg_detect_process.communicate()
|
||||||
|
|
||||||
for p in self.ffmpeg_other_processes:
|
for p in self.ffmpeg_other_processes:
|
||||||
poll = p['process'].poll()
|
poll = p["process"].poll()
|
||||||
if poll == None:
|
if poll == None:
|
||||||
continue
|
continue
|
||||||
p['logpipe'].dump()
|
p["logpipe"].dump()
|
||||||
p['process'] = start_or_restart_ffmpeg(p['cmd'], self.logger, p['logpipe'], ffmpeg_process=p['process'])
|
p["process"] = start_or_restart_ffmpeg(
|
||||||
|
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
|
||||||
|
)
|
||||||
|
|
||||||
# wait a bit before checking again
|
stop_ffmpeg(self.ffmpeg_detect_process, self.logger)
|
||||||
time.sleep(10)
|
for p in self.ffmpeg_other_processes:
|
||||||
|
stop_ffmpeg(p["process"], self.logger)
|
||||||
|
p["logpipe"].close()
|
||||||
|
self.logpipe.close()
|
||||||
|
|
||||||
def start_ffmpeg_detect(self):
|
def start_ffmpeg_detect(self):
|
||||||
ffmpeg_cmd = [c['cmd'] for c in self.config.ffmpeg_cmds if 'detect' in c['roles']][0]
|
ffmpeg_cmd = [
|
||||||
self.ffmpeg_detect_process = start_or_restart_ffmpeg(ffmpeg_cmd, self.logger, self.logpipe, self.frame_size)
|
c["cmd"] for c in self.config.ffmpeg_cmds if "detect" in c["roles"]
|
||||||
|
][0]
|
||||||
|
self.ffmpeg_detect_process = start_or_restart_ffmpeg(
|
||||||
|
ffmpeg_cmd, self.logger, self.logpipe, self.frame_size
|
||||||
|
)
|
||||||
self.ffmpeg_pid.value = self.ffmpeg_detect_process.pid
|
self.ffmpeg_pid.value = self.ffmpeg_detect_process.pid
|
||||||
self.capture_thread = CameraCapture(self.camera_name, self.ffmpeg_detect_process, self.frame_shape, self.frame_queue,
|
self.capture_thread = CameraCapture(
|
||||||
self.camera_fps)
|
self.camera_name,
|
||||||
|
self.ffmpeg_detect_process,
|
||||||
|
self.frame_shape,
|
||||||
|
self.frame_queue,
|
||||||
|
self.camera_fps,
|
||||||
|
)
|
||||||
self.capture_thread.start()
|
self.capture_thread.start()
|
||||||
|
|
||||||
|
|
||||||
class CameraCapture(threading.Thread):
|
class CameraCapture(threading.Thread):
|
||||||
def __init__(self, camera_name, ffmpeg_process, frame_shape, frame_queue, fps):
|
def __init__(self, camera_name, ffmpeg_process, frame_shape, frame_queue, fps):
|
||||||
threading.Thread.__init__(self)
|
threading.Thread.__init__(self)
|
||||||
@ -223,29 +275,56 @@ class CameraCapture(threading.Thread):
|
|||||||
self.skipped_fps = EventsPerSecond()
|
self.skipped_fps = EventsPerSecond()
|
||||||
self.frame_manager = SharedMemoryFrameManager()
|
self.frame_manager = SharedMemoryFrameManager()
|
||||||
self.ffmpeg_process = ffmpeg_process
|
self.ffmpeg_process = ffmpeg_process
|
||||||
self.current_frame = mp.Value('d', 0.0)
|
self.current_frame = mp.Value("d", 0.0)
|
||||||
self.last_frame = 0
|
self.last_frame = 0
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
self.skipped_fps.start()
|
self.skipped_fps.start()
|
||||||
capture_frames(self.ffmpeg_process, self.camera_name, self.frame_shape, self.frame_manager, self.frame_queue,
|
capture_frames(
|
||||||
self.fps, self.skipped_fps, self.current_frame)
|
self.ffmpeg_process,
|
||||||
|
self.camera_name,
|
||||||
|
self.frame_shape,
|
||||||
|
self.frame_manager,
|
||||||
|
self.frame_queue,
|
||||||
|
self.fps,
|
||||||
|
self.skipped_fps,
|
||||||
|
self.current_frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def capture_camera(name, config: CameraConfig, process_info):
|
def capture_camera(name, config: CameraConfig, process_info):
|
||||||
stop_event = mp.Event()
|
stop_event = mp.Event()
|
||||||
|
|
||||||
def receiveSignal(signalNumber, frame):
|
def receiveSignal(signalNumber, frame):
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
|
|
||||||
signal.signal(signal.SIGTERM, receiveSignal)
|
signal.signal(signal.SIGTERM, receiveSignal)
|
||||||
signal.signal(signal.SIGINT, receiveSignal)
|
signal.signal(signal.SIGINT, receiveSignal)
|
||||||
|
|
||||||
frame_queue = process_info['frame_queue']
|
frame_queue = process_info["frame_queue"]
|
||||||
camera_watchdog = CameraWatchdog(name, config, frame_queue, process_info['camera_fps'], process_info['ffmpeg_pid'], stop_event)
|
camera_watchdog = CameraWatchdog(
|
||||||
|
name,
|
||||||
|
config,
|
||||||
|
frame_queue,
|
||||||
|
process_info["camera_fps"],
|
||||||
|
process_info["ffmpeg_pid"],
|
||||||
|
stop_event,
|
||||||
|
)
|
||||||
camera_watchdog.start()
|
camera_watchdog.start()
|
||||||
camera_watchdog.join()
|
camera_watchdog.join()
|
||||||
|
|
||||||
def track_camera(name, config: CameraConfig, model_shape, detection_queue, result_connection, detected_objects_queue, process_info):
|
|
||||||
|
def track_camera(
|
||||||
|
name,
|
||||||
|
config: CameraConfig,
|
||||||
|
model_shape,
|
||||||
|
detection_queue,
|
||||||
|
result_connection,
|
||||||
|
detected_objects_queue,
|
||||||
|
process_info,
|
||||||
|
):
|
||||||
stop_event = mp.Event()
|
stop_event = mp.Event()
|
||||||
|
|
||||||
def receiveSignal(signalNumber, frame):
|
def receiveSignal(signalNumber, frame):
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
|
|
||||||
@ -256,79 +335,118 @@ def track_camera(name, config: CameraConfig, model_shape, detection_queue, resul
|
|||||||
setproctitle(f"frigate.process:{name}")
|
setproctitle(f"frigate.process:{name}")
|
||||||
listen()
|
listen()
|
||||||
|
|
||||||
frame_queue = process_info['frame_queue']
|
frame_queue = process_info["frame_queue"]
|
||||||
detection_enabled = process_info['detection_enabled']
|
detection_enabled = process_info["detection_enabled"]
|
||||||
|
|
||||||
frame_shape = config.frame_shape
|
frame_shape = config.frame_shape
|
||||||
objects_to_track = config.objects.track
|
objects_to_track = config.objects.track
|
||||||
object_filters = config.objects.filters
|
object_filters = config.objects.filters
|
||||||
|
|
||||||
motion_detector = MotionDetector(frame_shape, config.motion)
|
motion_detector = MotionDetector(frame_shape, config.motion)
|
||||||
object_detector = RemoteObjectDetector(name, '/labelmap.txt', detection_queue, result_connection, model_shape)
|
object_detector = RemoteObjectDetector(
|
||||||
|
name, "/labelmap.txt", detection_queue, result_connection, model_shape
|
||||||
|
)
|
||||||
|
|
||||||
object_tracker = ObjectTracker(config.detect)
|
object_tracker = ObjectTracker(config.detect)
|
||||||
|
|
||||||
frame_manager = SharedMemoryFrameManager()
|
frame_manager = SharedMemoryFrameManager()
|
||||||
|
|
||||||
process_frames(name, frame_queue, frame_shape, model_shape, frame_manager, motion_detector, object_detector,
|
process_frames(
|
||||||
object_tracker, detected_objects_queue, process_info, objects_to_track, object_filters, detection_enabled, stop_event)
|
name,
|
||||||
|
frame_queue,
|
||||||
|
frame_shape,
|
||||||
|
model_shape,
|
||||||
|
frame_manager,
|
||||||
|
motion_detector,
|
||||||
|
object_detector,
|
||||||
|
object_tracker,
|
||||||
|
detected_objects_queue,
|
||||||
|
process_info,
|
||||||
|
objects_to_track,
|
||||||
|
object_filters,
|
||||||
|
detection_enabled,
|
||||||
|
stop_event,
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"{name}: exiting subprocess")
|
logger.info(f"{name}: exiting subprocess")
|
||||||
|
|
||||||
|
|
||||||
def reduce_boxes(boxes):
|
def reduce_boxes(boxes):
|
||||||
if len(boxes) == 0:
|
if len(boxes) == 0:
|
||||||
return []
|
return []
|
||||||
reduced_boxes = cv2.groupRectangles([list(b) for b in itertools.chain(boxes, boxes)], 1, 0.2)[0]
|
reduced_boxes = cv2.groupRectangles(
|
||||||
|
[list(b) for b in itertools.chain(boxes, boxes)], 1, 0.2
|
||||||
|
)[0]
|
||||||
return [tuple(b) for b in reduced_boxes]
|
return [tuple(b) for b in reduced_boxes]
|
||||||
|
|
||||||
|
|
||||||
# modified from https://stackoverflow.com/a/40795835
|
# modified from https://stackoverflow.com/a/40795835
|
||||||
def intersects_any(box_a, boxes):
|
def intersects_any(box_a, boxes):
|
||||||
for box in boxes:
|
for box in boxes:
|
||||||
if box_a[2] < box[0] or box_a[0] > box[2] or box_a[1] > box[3] or box_a[3] < box[1]:
|
if (
|
||||||
|
box_a[2] < box[0]
|
||||||
|
or box_a[0] > box[2]
|
||||||
|
or box_a[1] > box[3]
|
||||||
|
or box_a[3] < box[1]
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def detect(object_detector, frame, model_shape, region, objects_to_track, object_filters):
|
|
||||||
|
def detect(
|
||||||
|
object_detector, frame, model_shape, region, objects_to_track, object_filters
|
||||||
|
):
|
||||||
tensor_input = create_tensor_input(frame, model_shape, region)
|
tensor_input = create_tensor_input(frame, model_shape, region)
|
||||||
|
|
||||||
detections = []
|
detections = []
|
||||||
region_detections = object_detector.detect(tensor_input)
|
region_detections = object_detector.detect(tensor_input)
|
||||||
for d in region_detections:
|
for d in region_detections:
|
||||||
box = d[2]
|
box = d[2]
|
||||||
size = region[2]-region[0]
|
size = region[2] - region[0]
|
||||||
x_min = int((box[1] * size) + region[0])
|
x_min = int((box[1] * size) + region[0])
|
||||||
y_min = int((box[0] * size) + region[1])
|
y_min = int((box[0] * size) + region[1])
|
||||||
x_max = int((box[3] * size) + region[0])
|
x_max = int((box[3] * size) + region[0])
|
||||||
y_max = int((box[2] * size) + region[1])
|
y_max = int((box[2] * size) + region[1])
|
||||||
det = (d[0],
|
det = (
|
||||||
|
d[0],
|
||||||
d[1],
|
d[1],
|
||||||
(x_min, y_min, x_max, y_max),
|
(x_min, y_min, x_max, y_max),
|
||||||
(x_max-x_min)*(y_max-y_min),
|
(x_max - x_min) * (y_max - y_min),
|
||||||
region)
|
region,
|
||||||
|
)
|
||||||
# apply object filters
|
# apply object filters
|
||||||
if filtered(det, objects_to_track, object_filters):
|
if filtered(det, objects_to_track, object_filters):
|
||||||
continue
|
continue
|
||||||
detections.append(det)
|
detections.append(det)
|
||||||
return detections
|
return detections
|
||||||
|
|
||||||
def process_frames(camera_name: str, frame_queue: mp.Queue, frame_shape, model_shape,
|
|
||||||
frame_manager: FrameManager, motion_detector: MotionDetector,
|
|
||||||
object_detector: RemoteObjectDetector, object_tracker: ObjectTracker,
|
|
||||||
detected_objects_queue: mp.Queue, process_info: Dict,
|
|
||||||
objects_to_track: List[str], object_filters, detection_enabled: mp.Value, stop_event,
|
|
||||||
exit_on_empty: bool = False):
|
|
||||||
|
|
||||||
fps = process_info['process_fps']
|
def process_frames(
|
||||||
detection_fps = process_info['detection_fps']
|
camera_name: str,
|
||||||
current_frame_time = process_info['detection_frame']
|
frame_queue: mp.Queue,
|
||||||
|
frame_shape,
|
||||||
|
model_shape,
|
||||||
|
frame_manager: FrameManager,
|
||||||
|
motion_detector: MotionDetector,
|
||||||
|
object_detector: RemoteObjectDetector,
|
||||||
|
object_tracker: ObjectTracker,
|
||||||
|
detected_objects_queue: mp.Queue,
|
||||||
|
process_info: Dict,
|
||||||
|
objects_to_track: List[str],
|
||||||
|
object_filters,
|
||||||
|
detection_enabled: mp.Value,
|
||||||
|
stop_event,
|
||||||
|
exit_on_empty: bool = False,
|
||||||
|
):
|
||||||
|
|
||||||
|
fps = process_info["process_fps"]
|
||||||
|
detection_fps = process_info["detection_fps"]
|
||||||
|
current_frame_time = process_info["detection_frame"]
|
||||||
|
|
||||||
fps_tracker = EventsPerSecond()
|
fps_tracker = EventsPerSecond()
|
||||||
fps_tracker.start()
|
fps_tracker.start()
|
||||||
|
|
||||||
while True:
|
while not stop_event.is_set():
|
||||||
if stop_event.is_set():
|
|
||||||
break
|
|
||||||
|
|
||||||
if exit_on_empty and frame_queue.empty():
|
if exit_on_empty and frame_queue.empty():
|
||||||
logger.info(f"Exiting track_objects...")
|
logger.info(f"Exiting track_objects...")
|
||||||
break
|
break
|
||||||
@ -340,7 +458,9 @@ def process_frames(camera_name: str, frame_queue: mp.Queue, frame_shape, model_s
|
|||||||
|
|
||||||
current_frame_time.value = frame_time
|
current_frame_time.value = frame_time
|
||||||
|
|
||||||
frame = frame_manager.get(f"{camera_name}{frame_time}", (frame_shape[0]*3//2, frame_shape[1]))
|
frame = frame_manager.get(
|
||||||
|
f"{camera_name}{frame_time}", (frame_shape[0] * 3 // 2, frame_shape[1])
|
||||||
|
)
|
||||||
|
|
||||||
if frame is None:
|
if frame is None:
|
||||||
logger.info(f"{camera_name}: frame {frame_time} is not in memory store.")
|
logger.info(f"{camera_name}: frame {frame_time} is not in memory store.")
|
||||||
@ -349,7 +469,9 @@ def process_frames(camera_name: str, frame_queue: mp.Queue, frame_shape, model_s
|
|||||||
if not detection_enabled.value:
|
if not detection_enabled.value:
|
||||||
fps.value = fps_tracker.eps()
|
fps.value = fps_tracker.eps()
|
||||||
object_tracker.match_and_update(frame_time, [])
|
object_tracker.match_and_update(frame_time, [])
|
||||||
detected_objects_queue.put((camera_name, frame_time, object_tracker.tracked_objects, [], []))
|
detected_objects_queue.put(
|
||||||
|
(camera_name, frame_time, object_tracker.tracked_objects, [], [])
|
||||||
|
)
|
||||||
detection_fps.value = object_detector.fps.eps()
|
detection_fps.value = object_detector.fps.eps()
|
||||||
frame_manager.close(f"{camera_name}{frame_time}")
|
frame_manager.close(f"{camera_name}{frame_time}")
|
||||||
continue
|
continue
|
||||||
@ -358,26 +480,43 @@ def process_frames(camera_name: str, frame_queue: mp.Queue, frame_shape, model_s
|
|||||||
motion_boxes = motion_detector.detect(frame)
|
motion_boxes = motion_detector.detect(frame)
|
||||||
|
|
||||||
# only get the tracked object boxes that intersect with motion
|
# only get the tracked object boxes that intersect with motion
|
||||||
tracked_object_boxes = [obj['box'] for obj in object_tracker.tracked_objects.values() if intersects_any(obj['box'], motion_boxes)]
|
tracked_object_boxes = [
|
||||||
|
obj["box"]
|
||||||
|
for obj in object_tracker.tracked_objects.values()
|
||||||
|
if intersects_any(obj["box"], motion_boxes)
|
||||||
|
]
|
||||||
|
|
||||||
# combine motion boxes with known locations of existing objects
|
# combine motion boxes with known locations of existing objects
|
||||||
combined_boxes = reduce_boxes(motion_boxes + tracked_object_boxes)
|
combined_boxes = reduce_boxes(motion_boxes + tracked_object_boxes)
|
||||||
|
|
||||||
# compute regions
|
# compute regions
|
||||||
regions = [calculate_region(frame_shape, a[0], a[1], a[2], a[3], 1.2)
|
regions = [
|
||||||
for a in combined_boxes]
|
calculate_region(frame_shape, a[0], a[1], a[2], a[3], 1.2)
|
||||||
|
for a in combined_boxes
|
||||||
|
]
|
||||||
|
|
||||||
# combine overlapping regions
|
# combine overlapping regions
|
||||||
combined_regions = reduce_boxes(regions)
|
combined_regions = reduce_boxes(regions)
|
||||||
|
|
||||||
# re-compute regions
|
# re-compute regions
|
||||||
regions = [calculate_region(frame_shape, a[0], a[1], a[2], a[3], 1.0)
|
regions = [
|
||||||
for a in combined_regions]
|
calculate_region(frame_shape, a[0], a[1], a[2], a[3], 1.0)
|
||||||
|
for a in combined_regions
|
||||||
|
]
|
||||||
|
|
||||||
# resize regions and detect
|
# resize regions and detect
|
||||||
detections = []
|
detections = []
|
||||||
for region in regions:
|
for region in regions:
|
||||||
detections.extend(detect(object_detector, frame, model_shape, region, objects_to_track, object_filters))
|
detections.extend(
|
||||||
|
detect(
|
||||||
|
object_detector,
|
||||||
|
frame,
|
||||||
|
model_shape,
|
||||||
|
region,
|
||||||
|
objects_to_track,
|
||||||
|
object_filters,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
#########
|
#########
|
||||||
# merge objects, check for clipped objects and look again up to 4 times
|
# merge objects, check for clipped objects and look again up to 4 times
|
||||||
@ -396,8 +535,10 @@ def process_frames(camera_name: str, frame_queue: mp.Queue, frame_shape, model_s
|
|||||||
for group in detected_object_groups.values():
|
for group in detected_object_groups.values():
|
||||||
|
|
||||||
# apply non-maxima suppression to suppress weak, overlapping bounding boxes
|
# apply non-maxima suppression to suppress weak, overlapping bounding boxes
|
||||||
boxes = [(o[2][0], o[2][1], o[2][2]-o[2][0], o[2][3]-o[2][1])
|
boxes = [
|
||||||
for o in group]
|
(o[2][0], o[2][1], o[2][2] - o[2][0], o[2][3] - o[2][1])
|
||||||
|
for o in group
|
||||||
|
]
|
||||||
confidences = [o[1] for o in group]
|
confidences = [o[1] for o in group]
|
||||||
idxs = cv2.dnn.NMSBoxes(boxes, confidences, 0.5, 0.4)
|
idxs = cv2.dnn.NMSBoxes(boxes, confidences, 0.5, 0.4)
|
||||||
|
|
||||||
@ -406,13 +547,22 @@ def process_frames(camera_name: str, frame_queue: mp.Queue, frame_shape, model_s
|
|||||||
if clipped(obj, frame_shape):
|
if clipped(obj, frame_shape):
|
||||||
box = obj[2]
|
box = obj[2]
|
||||||
# calculate a new region that will hopefully get the entire object
|
# calculate a new region that will hopefully get the entire object
|
||||||
region = calculate_region(frame_shape,
|
region = calculate_region(
|
||||||
box[0], box[1],
|
frame_shape, box[0], box[1], box[2], box[3]
|
||||||
box[2], box[3])
|
)
|
||||||
|
|
||||||
regions.append(region)
|
regions.append(region)
|
||||||
|
|
||||||
selected_objects.extend(detect(object_detector, frame, model_shape, region, objects_to_track, object_filters))
|
selected_objects.extend(
|
||||||
|
detect(
|
||||||
|
object_detector,
|
||||||
|
frame,
|
||||||
|
model_shape,
|
||||||
|
region,
|
||||||
|
objects_to_track,
|
||||||
|
object_filters,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
refining = True
|
refining = True
|
||||||
else:
|
else:
|
||||||
@ -426,18 +576,28 @@ def process_frames(camera_name: str, frame_queue: mp.Queue, frame_shape, model_s
|
|||||||
|
|
||||||
# Limit to the detections overlapping with motion areas
|
# Limit to the detections overlapping with motion areas
|
||||||
# to avoid picking up stationary background objects
|
# to avoid picking up stationary background objects
|
||||||
detections_with_motion = [d for d in detections if intersects_any(d[2], motion_boxes)]
|
detections_with_motion = [
|
||||||
|
d for d in detections if intersects_any(d[2], motion_boxes)
|
||||||
|
]
|
||||||
|
|
||||||
# now that we have refined our detections, we need to track objects
|
# now that we have refined our detections, we need to track objects
|
||||||
object_tracker.match_and_update(frame_time, detections_with_motion)
|
object_tracker.match_and_update(frame_time, detections_with_motion)
|
||||||
|
|
||||||
# add to the queue if not full
|
# add to the queue if not full
|
||||||
if(detected_objects_queue.full()):
|
if detected_objects_queue.full():
|
||||||
frame_manager.delete(f"{camera_name}{frame_time}")
|
frame_manager.delete(f"{camera_name}{frame_time}")
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
fps_tracker.update()
|
fps_tracker.update()
|
||||||
fps.value = fps_tracker.eps()
|
fps.value = fps_tracker.eps()
|
||||||
detected_objects_queue.put((camera_name, frame_time, object_tracker.tracked_objects, motion_boxes, regions))
|
detected_objects_queue.put(
|
||||||
|
(
|
||||||
|
camera_name,
|
||||||
|
frame_time,
|
||||||
|
object_tracker.tracked_objects,
|
||||||
|
motion_boxes,
|
||||||
|
regions,
|
||||||
|
)
|
||||||
|
)
|
||||||
detection_fps.value = object_detector.fps.eps()
|
detection_fps.value = object_detector.fps.eps()
|
||||||
frame_manager.close(f"{camera_name}{frame_time}")
|
frame_manager.close(f"{camera_name}{frame_time}")
|
||||||
|
|||||||
@ -7,32 +7,29 @@ import signal
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class FrigateWatchdog(threading.Thread):
|
class FrigateWatchdog(threading.Thread):
|
||||||
def __init__(self, detectors, stop_event):
|
def __init__(self, detectors, stop_event):
|
||||||
threading.Thread.__init__(self)
|
threading.Thread.__init__(self)
|
||||||
self.name = 'frigate_watchdog'
|
self.name = "frigate_watchdog"
|
||||||
self.detectors = detectors
|
self.detectors = detectors
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
while True:
|
while not self.stop_event.wait(10):
|
||||||
# wait a bit before checking
|
|
||||||
time.sleep(10)
|
|
||||||
|
|
||||||
if self.stop_event.is_set():
|
|
||||||
logger.info(f"Exiting watchdog...")
|
|
||||||
break
|
|
||||||
|
|
||||||
now = datetime.datetime.now().timestamp()
|
now = datetime.datetime.now().timestamp()
|
||||||
|
|
||||||
# check the detection processes
|
# check the detection processes
|
||||||
for detector in self.detectors.values():
|
for detector in self.detectors.values():
|
||||||
detection_start = detector.detection_start.value
|
detection_start = detector.detection_start.value
|
||||||
if (detection_start > 0.0 and
|
if detection_start > 0.0 and now - detection_start > 10:
|
||||||
now - detection_start > 10):
|
logger.info(
|
||||||
logger.info("Detection appears to be stuck. Restarting detection process...")
|
"Detection appears to be stuck. Restarting detection process..."
|
||||||
|
)
|
||||||
detector.start_or_restart()
|
detector.start_or_restart()
|
||||||
elif not detector.detect_process.is_alive():
|
elif not detector.detect_process.is_alive():
|
||||||
logger.info("Detection appears to have stopped. Exiting frigate...")
|
logger.info("Detection appears to have stopped. Exiting frigate...")
|
||||||
os.kill(os.getpid(), signal.SIGTERM)
|
os.kill(os.getpid(), signal.SIGTERM)
|
||||||
|
|
||||||
|
logger.info(f"Exiting watchdog...")
|
||||||
|
|||||||
@ -31,6 +31,7 @@ def get_local_ip() -> str:
|
|||||||
finally:
|
finally:
|
||||||
sock.close()
|
sock.close()
|
||||||
|
|
||||||
|
|
||||||
def broadcast_zeroconf(frigate_id):
|
def broadcast_zeroconf(frigate_id):
|
||||||
zeroconf = Zeroconf(interfaces=InterfaceChoice.Default, ip_version=IPVersion.V4Only)
|
zeroconf = Zeroconf(interfaces=InterfaceChoice.Default, ip_version=IPVersion.V4Only)
|
||||||
|
|
||||||
|
|||||||
@ -32,10 +32,14 @@ except ImportError:
|
|||||||
|
|
||||||
SQL = pw.SQL
|
SQL = pw.SQL
|
||||||
|
|
||||||
|
|
||||||
def migrate(migrator, database, fake=False, **kwargs):
|
def migrate(migrator, database, fake=False, **kwargs):
|
||||||
migrator.sql('CREATE TABLE IF NOT EXISTS "event" ("id" VARCHAR(30) NOT NULL PRIMARY KEY, "label" VARCHAR(20) NOT NULL, "camera" VARCHAR(20) NOT NULL, "start_time" DATETIME NOT NULL, "end_time" DATETIME NOT NULL, "top_score" REAL NOT NULL, "false_positive" INTEGER NOT NULL, "zones" JSON NOT NULL, "thumbnail" TEXT NOT NULL)')
|
migrator.sql(
|
||||||
|
'CREATE TABLE IF NOT EXISTS "event" ("id" VARCHAR(30) NOT NULL PRIMARY KEY, "label" VARCHAR(20) NOT NULL, "camera" VARCHAR(20) NOT NULL, "start_time" DATETIME NOT NULL, "end_time" DATETIME NOT NULL, "top_score" REAL NOT NULL, "false_positive" INTEGER NOT NULL, "zones" JSON NOT NULL, "thumbnail" TEXT NOT NULL)'
|
||||||
|
)
|
||||||
migrator.sql('CREATE INDEX IF NOT EXISTS "event_label" ON "event" ("label")')
|
migrator.sql('CREATE INDEX IF NOT EXISTS "event_label" ON "event" ("label")')
|
||||||
migrator.sql('CREATE INDEX IF NOT EXISTS "event_camera" ON "event" ("camera")')
|
migrator.sql('CREATE INDEX IF NOT EXISTS "event_camera" ON "event" ("camera")')
|
||||||
|
|
||||||
|
|
||||||
def rollback(migrator, database, fake=False, **kwargs):
|
def rollback(migrator, database, fake=False, **kwargs):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -35,7 +35,12 @@ SQL = pw.SQL
|
|||||||
|
|
||||||
|
|
||||||
def migrate(migrator, database, fake=False, **kwargs):
|
def migrate(migrator, database, fake=False, **kwargs):
|
||||||
migrator.add_fields(Event, has_clip=pw.BooleanField(default=True), has_snapshot=pw.BooleanField(default=True))
|
migrator.add_fields(
|
||||||
|
Event,
|
||||||
|
has_clip=pw.BooleanField(default=True),
|
||||||
|
has_snapshot=pw.BooleanField(default=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def rollback(migrator, database, fake=False, **kwargs):
|
def rollback(migrator, database, fake=False, **kwargs):
|
||||||
migrator.remove_fields(Event, ['has_clip', 'has_snapshot'])
|
migrator.remove_fields(Event, ["has_clip", "has_snapshot"])
|
||||||
|
|||||||
@ -1,23 +1,21 @@
|
|||||||
worker_processes 1;
|
worker_processes 1;
|
||||||
|
|
||||||
error_log /var/log/nginx/error.log warn;
|
error_log /usr/local/nginx/logs/error.log warn;
|
||||||
pid /var/run/nginx.pid;
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
load_module "modules/ngx_rtmp_module.so";
|
|
||||||
|
|
||||||
events {
|
events {
|
||||||
worker_connections 1024;
|
worker_connections 1024;
|
||||||
}
|
}
|
||||||
|
|
||||||
http {
|
http {
|
||||||
include /etc/nginx/mime.types;
|
include mime.types;
|
||||||
default_type application/octet-stream;
|
default_type application/octet-stream;
|
||||||
|
|
||||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
'$status $body_bytes_sent "$http_referer" '
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
access_log /var/log/nginx/access.log main;
|
access_log /usr/local/nginx/logs/access.log main;
|
||||||
|
|
||||||
sendfile on;
|
sendfile on;
|
||||||
|
|
||||||
@ -37,6 +35,38 @@ http {
|
|||||||
server {
|
server {
|
||||||
listen 5000;
|
listen 5000;
|
||||||
|
|
||||||
|
# vod settings
|
||||||
|
vod_base_url '';
|
||||||
|
vod_segments_base_url '';
|
||||||
|
vod_mode mapped;
|
||||||
|
vod_max_mapping_response_size 1m;
|
||||||
|
vod_upstream_location /api;
|
||||||
|
|
||||||
|
# vod caches
|
||||||
|
vod_metadata_cache metadata_cache 512m;
|
||||||
|
vod_mapping_cache mapping_cache 5m;
|
||||||
|
|
||||||
|
# gzip manifests
|
||||||
|
gzip on;
|
||||||
|
gzip_types application/vnd.apple.mpegurl;
|
||||||
|
|
||||||
|
# file handle caching / aio
|
||||||
|
open_file_cache max=1000 inactive=5m;
|
||||||
|
open_file_cache_valid 2m;
|
||||||
|
open_file_cache_min_uses 1;
|
||||||
|
open_file_cache_errors on;
|
||||||
|
aio on;
|
||||||
|
|
||||||
|
location /vod/ {
|
||||||
|
vod hls;
|
||||||
|
|
||||||
|
add_header Access-Control-Allow-Headers '*';
|
||||||
|
add_header Access-Control-Expose-Headers 'Server,range,Content-Length,Content-Range';
|
||||||
|
add_header Access-Control-Allow-Methods 'GET, HEAD, OPTIONS';
|
||||||
|
add_header Access-Control-Allow-Origin '*';
|
||||||
|
expires -1;
|
||||||
|
}
|
||||||
|
|
||||||
location /stream/ {
|
location /stream/ {
|
||||||
add_header 'Cache-Control' 'no-cache';
|
add_header 'Cache-Control' 'no-cache';
|
||||||
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
|
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
|
||||||
@ -112,6 +142,7 @@ http {
|
|||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
add_header 'Access-Control-Allow-Origin' '*';
|
add_header 'Access-Control-Allow-Origin' '*';
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
|
||||||
add_header Cache-Control "no-store";
|
add_header Cache-Control "no-store";
|
||||||
proxy_pass http://frigate_api/;
|
proxy_pass http://frigate_api/;
|
||||||
proxy_pass_request_headers on;
|
proxy_pass_request_headers on;
|
||||||
|
|||||||
2
run.sh
2
run.sh
@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
service nginx start
|
/usr/local/nginx/sbin/nginx
|
||||||
exec python3 -u -m frigate
|
exec python3 -u -m frigate
|
||||||
459
web/package-lock.json
generated
459
web/package-lock.json
generated
@ -2873,7 +2873,6 @@
|
|||||||
"version": "7.12.13",
|
"version": "7.12.13",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.13.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.13.tgz",
|
||||||
"integrity": "sha512-8+3UMPBrjFa/6TtKi/7sehPKqfAm4g6K+YQjyyFOLUTxzOngcRZTlAVY8sc2CORJYqdHQY8gRPHmn+qo15rCBw==",
|
"integrity": "sha512-8+3UMPBrjFa/6TtKi/7sehPKqfAm4g6K+YQjyyFOLUTxzOngcRZTlAVY8sc2CORJYqdHQY8gRPHmn+qo15rCBw==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"regenerator-runtime": "^0.13.4"
|
"regenerator-runtime": "^0.13.4"
|
||||||
}
|
}
|
||||||
@ -4118,6 +4117,74 @@
|
|||||||
"integrity": "sha512-GmVAWB+JuFKqSbzlofYK4qxk955gEv4Kd9/aj2hLOxneXMAm/J7OXcl5DlElS9tmkqwCcxGysSZGOrjzNvmjFQ==",
|
"integrity": "sha512-GmVAWB+JuFKqSbzlofYK4qxk955gEv4Kd9/aj2hLOxneXMAm/J7OXcl5DlElS9tmkqwCcxGysSZGOrjzNvmjFQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@videojs/http-streaming": {
|
||||||
|
"version": "2.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-2.6.4.tgz",
|
||||||
|
"integrity": "sha512-sFVE0MVXhawAkET8EgiUSMvDDv6u3uGidtO0BvNXG0/qKWlze/zEzhvLsyPU4HmLFRnffKeHK5RE2XpO5vHY8Q==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@videojs/vhs-utils": "^3.0.0",
|
||||||
|
"aes-decrypter": "3.1.2",
|
||||||
|
"global": "^4.4.0",
|
||||||
|
"m3u8-parser": "4.5.2",
|
||||||
|
"mpd-parser": "0.15.4",
|
||||||
|
"mux.js": "5.10.0",
|
||||||
|
"video.js": "^6 || ^7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"global": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
|
||||||
|
"requires": {
|
||||||
|
"min-document": "^2.19.0",
|
||||||
|
"process": "^0.11.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@videojs/vhs-utils": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-r8Yas1/tNGsGRNoIaDJuiWiQgM0P2yaEnobgzw5JcBiEqxnS8EXoUm4QtKH7nJtnppZ1yqBx1agBZCvBMKXA2w==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"global": "^4.4.0",
|
||||||
|
"url-toolkit": "^2.2.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"global": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
|
||||||
|
"requires": {
|
||||||
|
"min-document": "^2.19.0",
|
||||||
|
"process": "^0.11.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@videojs/xhr": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-wV9nGESHseSK+S9ePEru2+OJZ1jq/ZbbzniGQ4weAmTIepuBMSYPx5zrxxQA0E786T5ykpO8ts+LayV+3/oI2w==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.5.5",
|
||||||
|
"global": "~4.4.0",
|
||||||
|
"is-function": "^1.0.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"global": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
|
||||||
|
"requires": {
|
||||||
|
"min-document": "^2.19.0",
|
||||||
|
"process": "^0.11.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"abab": {
|
"abab": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.5.tgz",
|
||||||
@ -4163,6 +4230,28 @@
|
|||||||
"integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==",
|
"integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"aes-decrypter": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-42nRwfQuPRj9R1zqZBdoxnaAmnIFyDi0MNyTVhjdFOd8fifXKKRfwIHIZ6AMn1or4x5WONzjwRTbTWcsIQ0O4A==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@videojs/vhs-utils": "^3.0.0",
|
||||||
|
"global": "^4.4.0",
|
||||||
|
"pkcs7": "^1.0.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"global": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
|
||||||
|
"requires": {
|
||||||
|
"min-document": "^2.19.0",
|
||||||
|
"process": "^0.11.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ajv": {
|
"ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
@ -4658,16 +4747,42 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"version": "4.16.1",
|
"version": "4.16.6",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.6.tgz",
|
||||||
"integrity": "sha512-UXhDrwqsNcpTYJBTZsbGATDxZbiVDsx6UjpmRUmtnP10pr8wAYr5LgFoEFw9ixriQH2mv/NX2SfGzE/o8GndLA==",
|
"integrity": "sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"caniuse-lite": "^1.0.30001173",
|
"caniuse-lite": "^1.0.30001219",
|
||||||
"colorette": "^1.2.1",
|
"colorette": "^1.2.2",
|
||||||
"electron-to-chromium": "^1.3.634",
|
"electron-to-chromium": "^1.3.723",
|
||||||
"escalade": "^3.1.1",
|
"escalade": "^3.1.1",
|
||||||
"node-releases": "^1.1.69"
|
"node-releases": "^1.1.71"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"caniuse-lite": {
|
||||||
|
"version": "1.0.30001230",
|
||||||
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001230.tgz",
|
||||||
|
"integrity": "sha512-5yBd5nWCBS+jWKTcHOzXwo5xzcj4ePE/yjtkZyUV1BTUmrBaA9MRGC+e7mxnqXSA90CmCA8L3eKLaSUkt099IQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"colorette": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"electron-to-chromium": {
|
||||||
|
"version": "1.3.739",
|
||||||
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.739.tgz",
|
||||||
|
"integrity": "sha512-+LPJVRsN7hGZ9EIUUiWCpO7l4E3qBYHNadazlucBfsXBbccDFNKUBAgzE68FnkWGJPwD/AfKhSzL+G+Iqb8A4A==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node-releases": {
|
||||||
|
"version": "1.1.72",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.72.tgz",
|
||||||
|
"integrity": "sha512-LLUo+PpH3dU6XizX3iVoubUNheF/owjXCZZ5yACDxNnPtgFuludV1ZL3ayK1kVep42Rmm0+R9/Y60NQbZ2bifw==",
|
||||||
|
"dev": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bser": {
|
"bser": {
|
||||||
@ -4763,6 +4878,14 @@
|
|||||||
"integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=",
|
"integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"chainsaw": {
|
||||||
|
"version": "0.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.0.9.tgz",
|
||||||
|
"integrity": "sha1-EaBRAtHEx4W20EFdM21aOhYSkT4=",
|
||||||
|
"requires": {
|
||||||
|
"traverse": ">=0.3.0 <0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"chalk": {
|
"chalk": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
||||||
@ -5103,6 +5226,11 @@
|
|||||||
"whatwg-url": "^8.0.0"
|
"whatwg-url": "^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"date-fns": {
|
||||||
|
"version": "2.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.21.3.tgz",
|
||||||
|
"integrity": "sha512-HeYdzCaFflc1i4tGbj7JKMjM4cKGYoyxwcIIkHzNgCkX8xXDNJDZXgDDVchIWpN4eQc3lH37WarduXFZJOtxfw=="
|
||||||
|
},
|
||||||
"debug": {
|
"debug": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
|
||||||
@ -5271,6 +5399,11 @@
|
|||||||
"integrity": "sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ==",
|
"integrity": "sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"dom-walk": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
|
||||||
|
},
|
||||||
"domconstants": {
|
"domconstants": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/domconstants/-/domconstants-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/domconstants/-/domconstants-0.1.2.tgz",
|
||||||
@ -5328,12 +5461,6 @@
|
|||||||
"safer-buffer": "^2.1.0"
|
"safer-buffer": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"electron-to-chromium": {
|
|
||||||
"version": "1.3.641",
|
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.641.tgz",
|
|
||||||
"integrity": "sha512-b0DLhsHSHESC1I+Nx6n4w4Lr61chMd3m/av1rZQhS2IXTzaS5BMM5N+ldWdMIlni9CITMRM09m8He4+YV/92TA==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"emittery": {
|
"emittery": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/emittery/-/emittery-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/emittery/-/emittery-0.7.2.tgz",
|
||||||
@ -5964,6 +6091,11 @@
|
|||||||
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
|
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"estree-walker": {
|
||||||
|
"version": "0.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz",
|
||||||
|
"integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="
|
||||||
|
},
|
||||||
"esutils": {
|
"esutils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||||
@ -6435,6 +6567,22 @@
|
|||||||
"is-glob": "^4.0.1"
|
"is-glob": "^4.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"global": {
|
||||||
|
"version": "4.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/global/-/global-4.3.2.tgz",
|
||||||
|
"integrity": "sha1-52mJJopsdMOJCLEwWxD8DjlOnQ8=",
|
||||||
|
"requires": {
|
||||||
|
"min-document": "^2.19.0",
|
||||||
|
"process": "~0.5.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"process": {
|
||||||
|
"version": "0.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/process/-/process-0.5.2.tgz",
|
||||||
|
"integrity": "sha1-FjjYqONML0QKkduVq5rrZ3/Bhc8="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"globals": {
|
"globals": {
|
||||||
"version": "11.12.0",
|
"version": "11.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
|
||||||
@ -6537,6 +6685,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"hashish": {
|
||||||
|
"version": "0.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/hashish/-/hashish-0.0.4.tgz",
|
||||||
|
"integrity": "sha1-bWC8b/r3Ebav1g5CbQd5iAFOZVQ=",
|
||||||
|
"requires": {
|
||||||
|
"traverse": ">=0.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"himalaya": {
|
"himalaya": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/himalaya/-/himalaya-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/himalaya/-/himalaya-1.1.0.tgz",
|
||||||
@ -6544,9 +6700,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"hosted-git-info": {
|
"hosted-git-info": {
|
||||||
"version": "2.8.8",
|
"version": "2.8.9",
|
||||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz",
|
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
|
||||||
"integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==",
|
"integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"html-encoding-sniffer": {
|
"html-encoding-sniffer": {
|
||||||
@ -6693,6 +6849,11 @@
|
|||||||
"integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=",
|
"integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"individual": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/individual/-/individual-2.0.0.tgz",
|
||||||
|
"integrity": "sha1-gzsJfa0jKU52EXqY+zjg2a1hu5c="
|
||||||
|
},
|
||||||
"inflight": {
|
"inflight": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
@ -6876,6 +7037,11 @@
|
|||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"is-function": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ=="
|
||||||
|
},
|
||||||
"is-generator-fn": {
|
"is-generator-fn": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz",
|
||||||
@ -8596,6 +8762,11 @@
|
|||||||
"object.assign": "^4.1.2"
|
"object.assign": "^4.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"keycode": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.0.tgz",
|
||||||
|
"integrity": "sha1-PQr1bce4uOXLqNCpfxByBO7CKwQ="
|
||||||
|
},
|
||||||
"kind-of": {
|
"kind-of": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||||
@ -8680,9 +8851,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lodash": {
|
"lodash": {
|
||||||
"version": "4.17.20",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"lodash.difference": {
|
"lodash.difference": {
|
||||||
@ -8757,11 +8928,31 @@
|
|||||||
"integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=",
|
"integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"m3u8-parser": {
|
||||||
|
"version": "4.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-4.5.2.tgz",
|
||||||
|
"integrity": "sha512-sN/lu3TiRxmG2RFjZxo5c0/7Dr4RrEztl43jXrWwj5gFZ7vfa2iIxGfiPx485dm5QCazaIcKk+vNkUso8Aq0Ag==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@videojs/vhs-utils": "^3.0.0",
|
||||||
|
"global": "^4.4.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"global": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
|
||||||
|
"requires": {
|
||||||
|
"min-document": "^2.19.0",
|
||||||
|
"process": "^0.11.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"magic-string": {
|
"magic-string": {
|
||||||
"version": "0.25.7",
|
"version": "0.25.7",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz",
|
||||||
"integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==",
|
"integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"sourcemap-codec": "^1.4.4"
|
"sourcemap-codec": "^1.4.4"
|
||||||
}
|
}
|
||||||
@ -8856,6 +9047,14 @@
|
|||||||
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
|
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"min-document": {
|
||||||
|
"version": "2.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
|
||||||
|
"integrity": "sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=",
|
||||||
|
"requires": {
|
||||||
|
"dom-walk": "^0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"min-indent": {
|
"min-indent": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||||
@ -8916,6 +9115,28 @@
|
|||||||
"integrity": "sha512-Xm9jdWvqFrlV0k965eY5AlCpWIIUBY2ExzGbEG+byMs+mZI4J7zvaUOLpQ8MTFgkpgyEnu4qUhuZT/Or3QeRiA==",
|
"integrity": "sha512-Xm9jdWvqFrlV0k965eY5AlCpWIIUBY2ExzGbEG+byMs+mZI4J7zvaUOLpQ8MTFgkpgyEnu4qUhuZT/Or3QeRiA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"mpd-parser": {
|
||||||
|
"version": "0.15.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-0.15.4.tgz",
|
||||||
|
"integrity": "sha512-YcOclxKc5gnT87UQYwRoPJpWOFvQORwN+bXYmTWCJ4U2pCSS7jjtPrIhoOLHFAyekj48CHTX4hjGBV/VSNsUsg==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@videojs/vhs-utils": "^3.0.0",
|
||||||
|
"global": "^4.4.0",
|
||||||
|
"xmldom": "^0.4.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"global": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
|
||||||
|
"requires": {
|
||||||
|
"min-document": "^2.19.0",
|
||||||
|
"process": "^0.11.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ms": {
|
"ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
@ -8935,10 +9156,18 @@
|
|||||||
"minimatch": "^3.0.4"
|
"minimatch": "^3.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"mux.js": {
|
||||||
|
"version": "5.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mux.js/-/mux.js-5.10.0.tgz",
|
||||||
|
"integrity": "sha512-kLzvYsHYBwNa+ckkmpxWV3eImwntJbrwd1KbN4WR0hLe+dK/KB82aCuC0fQzAI2hkjYszdlSGsAWFgYdiFBUuA==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.11.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nanoid": {
|
"nanoid": {
|
||||||
"version": "3.1.20",
|
"version": "3.1.23",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.20.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
|
||||||
"integrity": "sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw==",
|
"integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"nanomatch": {
|
"nanomatch": {
|
||||||
@ -9014,12 +9243,6 @@
|
|||||||
"which": "^2.0.2"
|
"which": "^2.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node-releases": {
|
|
||||||
"version": "1.1.69",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.69.tgz",
|
|
||||||
"integrity": "sha512-DGIjo79VDEyAnRlfSqYTsy+yoHd2IOjJiKUozD2MV2D85Vso6Bug56mb9tT/fY5Urt0iqk01H7x+llAruDR2zA==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"normalize-package-data": {
|
"normalize-package-data": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
|
||||||
@ -9381,6 +9604,14 @@
|
|||||||
"node-modules-regexp": "^1.0.0"
|
"node-modules-regexp": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"pkcs7": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"pkg-dir": {
|
"pkg-dir": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz",
|
||||||
@ -9448,20 +9679,20 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"postcss": {
|
"postcss": {
|
||||||
"version": "8.2.2",
|
"version": "8.2.10",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.2.10.tgz",
|
||||||
"integrity": "sha512-HM1NDNWLgglJPQQMNwvLxgH2KcrKZklKLi/xXYIOaqQB57p/pDWEJNS83PVICYsn1Dg/9C26TiejNr422/ePaQ==",
|
"integrity": "sha512-b/h7CPV7QEdrqIxtAf2j31U5ef05uBDuvoXv6L51Q4rcS1jdlXAVKJv+atCFdUXYl9dyTHGyoMzIepwowRJjFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"colorette": "^1.2.1",
|
"colorette": "^1.2.2",
|
||||||
"nanoid": "^3.1.20",
|
"nanoid": "^3.1.22",
|
||||||
"source-map": "^0.6.1"
|
"source-map": "^0.6.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"source-map": {
|
"colorette": {
|
||||||
"version": "0.6.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz",
|
||||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
"integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==",
|
||||||
"dev": true
|
"dev": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -9743,6 +9974,11 @@
|
|||||||
"integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=",
|
"integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"process": {
|
||||||
|
"version": "0.11.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||||
|
"integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI="
|
||||||
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||||
@ -9971,8 +10207,7 @@
|
|||||||
"regenerator-runtime": {
|
"regenerator-runtime": {
|
||||||
"version": "0.13.7",
|
"version": "0.13.7",
|
||||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
|
||||||
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==",
|
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"regenerator-transform": {
|
"regenerator-transform": {
|
||||||
"version": "0.14.5",
|
"version": "0.14.5",
|
||||||
@ -10046,6 +10281,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"remove": {
|
||||||
|
"version": "0.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/remove/-/remove-0.1.5.tgz",
|
||||||
|
"integrity": "sha1-CV/9gn1lyfQa2X0z5BanWBEHmVU=",
|
||||||
|
"requires": {
|
||||||
|
"seq": ">= 0.3.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"remove-trailing-separator": {
|
"remove-trailing-separator": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
|
||||||
@ -10230,6 +10473,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"rollup-plugin-replace": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/rollup-plugin-replace/-/rollup-plugin-replace-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-/5bxtUPkDHyBJAKketb4NfaeZjL5yLZdeUihSfbF2PQMz+rSTEb8ARKoOl3UBT4m7/X+QOXJo3sLTcq+yMMYTA==",
|
||||||
|
"requires": {
|
||||||
|
"magic-string": "^0.25.2",
|
||||||
|
"rollup-pluginutils": "^2.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rollup-pluginutils": {
|
||||||
|
"version": "2.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz",
|
||||||
|
"integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==",
|
||||||
|
"requires": {
|
||||||
|
"estree-walker": "^0.6.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"rsvp": {
|
"rsvp": {
|
||||||
"version": "4.8.5",
|
"version": "4.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz",
|
||||||
@ -10242,12 +10502,28 @@
|
|||||||
"integrity": "sha512-zb/1OuZ6flOlH6tQyMPUrE3x3Ulxjlo9WIVXR4yVYi4H9UXQaeIsPbLn2R3O3vQCnDKkAl2qHiuocKKX4Tz/Sw==",
|
"integrity": "sha512-zb/1OuZ6flOlH6tQyMPUrE3x3Ulxjlo9WIVXR4yVYi4H9UXQaeIsPbLn2R3O3vQCnDKkAl2qHiuocKKX4Tz/Sw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"rust-result": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/rust-result/-/rust-result-1.0.0.tgz",
|
||||||
|
"integrity": "sha1-NMdbLm3Dn+WHXlveyFteD5FTb3I=",
|
||||||
|
"requires": {
|
||||||
|
"individual": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"safe-buffer": {
|
"safe-buffer": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"safe-json-parse": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-json-parse/-/safe-json-parse-4.0.0.tgz",
|
||||||
|
"integrity": "sha1-fA9XjPzNEtM6ccDgVBPi7KFx6qw=",
|
||||||
|
"requires": {
|
||||||
|
"rust-result": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"safe-regex": {
|
"safe-regex": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
|
||||||
@ -10490,6 +10766,15 @@
|
|||||||
"lru-cache": "^6.0.0"
|
"lru-cache": "^6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"seq": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/seq/-/seq-0.3.5.tgz",
|
||||||
|
"integrity": "sha1-rgKvOkJHk9jMvyEtaRdODFTf/jg=",
|
||||||
|
"requires": {
|
||||||
|
"chainsaw": ">=0.0.7 <0.1",
|
||||||
|
"hashish": ">=0.0.2 <0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"set-blocking": {
|
"set-blocking": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
@ -10809,8 +11094,7 @@
|
|||||||
"sourcemap-codec": {
|
"sourcemap-codec": {
|
||||||
"version": "1.4.8",
|
"version": "1.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
|
"resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
|
||||||
"integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
|
"integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"spdx-correct": {
|
"spdx-correct": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
@ -11291,6 +11575,11 @@
|
|||||||
"punycode": "^2.1.1"
|
"punycode": "^2.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"traverse": {
|
||||||
|
"version": "0.3.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz",
|
||||||
|
"integrity": "sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk="
|
||||||
|
},
|
||||||
"ts-morph": {
|
"ts-morph": {
|
||||||
"version": "9.1.0",
|
"version": "9.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-9.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-9.1.0.tgz",
|
||||||
@ -11557,6 +11846,11 @@
|
|||||||
"integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=",
|
"integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"url-toolkit": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-l25w6Sy+Iy3/IbogunxhWwljPaDnqpiKvrQRoLBm6DfISco7NyRIS7Zf6+Oxhy1T8kHxWdwLND7ZZba6NjXMug=="
|
||||||
|
},
|
||||||
"use": {
|
"use": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
|
||||||
@ -11631,6 +11925,74 @@
|
|||||||
"extsprintf": "^1.2.0"
|
"extsprintf": "^1.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"video.js": {
|
||||||
|
"version": "7.11.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/video.js/-/video.js-7.11.8.tgz",
|
||||||
|
"integrity": "sha512-iQmNYB+pdgu8b45Za1AKSa5J7uDyHIqfJy+picw4voKfjErXK/BEvs+A3f99Ck7SCZU4cmMmX/s17AwaaNs+1w==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.9.2",
|
||||||
|
"@videojs/http-streaming": "2.6.4",
|
||||||
|
"@videojs/xhr": "2.5.1",
|
||||||
|
"global": "4.3.2",
|
||||||
|
"keycode": "^2.2.0",
|
||||||
|
"remove": "^0.1.5",
|
||||||
|
"rollup-plugin-replace": "^2.2.0",
|
||||||
|
"safe-json-parse": "4.0.0",
|
||||||
|
"videojs-font": "3.2.0",
|
||||||
|
"videojs-vtt.js": "^0.15.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"videojs-font": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-g8vHMKK2/JGorSfqAZQUmYYNnXmfec4MLhwtEFS+mMs2IDY398GLysy6BH6K+aS1KMNu/xWZ8Sue/X/mdQPliA=="
|
||||||
|
},
|
||||||
|
"videojs-mobile-ui": {
|
||||||
|
"version": "0.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/videojs-mobile-ui/-/videojs-mobile-ui-0.5.3.tgz",
|
||||||
|
"integrity": "sha512-rY+JFLUq2edqoWB4CHVxPLYQEYhSNdGylGe44MEdfxzqYaEgkf/qyDlmmpdN9BFIQ6vJ7eaQBxgTOHha8UpOGA==",
|
||||||
|
"requires": {
|
||||||
|
"global": "^4.3.2",
|
||||||
|
"video.js": "^5.19.2 || ^6.6.0 || ^7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"videojs-playlist": {
|
||||||
|
"version": "4.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/videojs-playlist/-/videojs-playlist-4.3.1.tgz",
|
||||||
|
"integrity": "sha512-fxI3T6mWHKaXRwTQyJeq5I0b8GM9Q4S/p92Aq7O1xAT+X8jYxYSIN15xi32a1F5adEGPRqct+yMl5MkXO9x9cQ==",
|
||||||
|
"requires": {
|
||||||
|
"global": "^4.3.2",
|
||||||
|
"video.js": "^6 || ^7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"videojs-seek-buttons": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/videojs-seek-buttons/-/videojs-seek-buttons-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-fSq2COvwTT5OwD5urc3E+ktQRwdjptXNaeuv1Tld2yfoV1ep9Am9gE/O07ADgHJVedFatVUXnifTh6wlUWSyTA==",
|
||||||
|
"requires": {
|
||||||
|
"global": "^4.4.0",
|
||||||
|
"video.js": "^6 || ^7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"global": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
|
||||||
|
"requires": {
|
||||||
|
"min-document": "^2.19.0",
|
||||||
|
"process": "^0.11.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"videojs-vtt.js": {
|
||||||
|
"version": "0.15.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.3.tgz",
|
||||||
|
"integrity": "sha512-5FvVsICuMRx6Hd7H/Y9s9GDeEtYcXQWzGMS+sl4UX3t/zoHp3y+isSfIPRochnTH7h+Bh1ILyC639xy9Z6kPag==",
|
||||||
|
"requires": {
|
||||||
|
"global": "^4.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"w3c-hr-time": {
|
"w3c-hr-time": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
|
||||||
@ -11767,9 +12129,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ws": {
|
"ws": {
|
||||||
"version": "7.4.3",
|
"version": "7.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz",
|
||||||
"integrity": "sha512-hr6vCR76GsossIRsr8OLR9acVVm1jyfEWvhbNjtgPOrfvAlKzvyeg/P6r8RuDjRyrcQoPQT7K0DGEPc7Ae6jzA==",
|
"integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"xml-name-validator": {
|
"xml-name-validator": {
|
||||||
@ -11784,6 +12146,11 @@
|
|||||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"xmldom": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-2E93k08T30Ugs+34HBSTQLVtpi6mCddaY8uO+pMNk1pqSjV5vElzn4mmh6KLxN3hki8rNcHSYzILoh3TEWORvA=="
|
||||||
|
},
|
||||||
"xtend": {
|
"xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
@ -11,11 +11,16 @@
|
|||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"date-fns": "^2.21.3",
|
||||||
"idb-keyval": "^5.0.2",
|
"idb-keyval": "^5.0.2",
|
||||||
"immer": "^8.0.1",
|
"immer": "^8.0.1",
|
||||||
"preact": "^10.5.9",
|
"preact": "^10.5.9",
|
||||||
"preact-async-route": "^2.2.1",
|
"preact-async-route": "^2.2.1",
|
||||||
"preact-router": "^3.2.1"
|
"preact-router": "^3.2.1",
|
||||||
|
"video.js": "^7.11.8",
|
||||||
|
"videojs-mobile-ui": "^0.5.3",
|
||||||
|
"videojs-playlist": "^4.3.1",
|
||||||
|
"videojs-seek-buttons": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/eslint-parser": "^7.12.13",
|
"@babel/eslint-parser": "^7.12.13",
|
||||||
@ -35,7 +40,7 @@
|
|||||||
"eslint-plugin-jest": "^24.1.3",
|
"eslint-plugin-jest": "^24.1.3",
|
||||||
"eslint-plugin-testing-library": "^3.10.1",
|
"eslint-plugin-testing-library": "^3.10.1",
|
||||||
"jest": "^26.6.3",
|
"jest": "^26.6.3",
|
||||||
"postcss": "^8.2.2",
|
"postcss": "^8.2.10",
|
||||||
"postcss-cli": "^8.3.1",
|
"postcss-cli": "^8.3.1",
|
||||||
"prettier": "^2.2.1",
|
"prettier": "^2.2.1",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root" class="z-0"></div>
|
<div id="root" class="z-0"></div>
|
||||||
|
<div id="dialogs" class="z-0"></div>
|
||||||
<div id="menus" class="z-0"></div>
|
<div id="menus" class="z-0"></div>
|
||||||
<div id="tooltips" class="z-0"></div>
|
<div id="tooltips" class="z-0"></div>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
|||||||
@ -29,6 +29,7 @@ export default function App() {
|
|||||||
<AsyncRoute path="/cameras/:camera" getComponent={Routes.getCamera} />
|
<AsyncRoute path="/cameras/:camera" getComponent={Routes.getCamera} />
|
||||||
<AsyncRoute path="/events/:eventId" getComponent={Routes.getEvent} />
|
<AsyncRoute path="/events/:eventId" getComponent={Routes.getEvent} />
|
||||||
<AsyncRoute path="/events" getComponent={Routes.getEvents} />
|
<AsyncRoute path="/events" getComponent={Routes.getEvents} />
|
||||||
|
<AsyncRoute path="/recording/:camera/:date?/:hour?/:seconds?" getComponent={Routes.getRecording} />
|
||||||
<AsyncRoute path="/debug" getComponent={Routes.getDebug} />
|
<AsyncRoute path="/debug" getComponent={Routes.getDebug} />
|
||||||
<AsyncRoute path="/styleguide" getComponent={Routes.getStyleGuide} />
|
<AsyncRoute path="/styleguide" getComponent={Routes.getStyleGuide} />
|
||||||
<Cameras default path="/" />
|
<Cameras default path="/" />
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import NavigationDrawer, { Destination, Separator } from './components/Navigatio
|
|||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const { data: config } = useConfig();
|
const { data: config } = useConfig();
|
||||||
const cameras = useMemo(() => Object.keys(config.cameras), [config]);
|
const cameras = useMemo(() => Object.entries(config.cameras), [config]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationDrawer header={<Header />}>
|
<NavigationDrawer header={<Header />}>
|
||||||
@ -19,7 +19,7 @@ export default function Sidebar() {
|
|||||||
matches ? (
|
matches ? (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<Separator />
|
<Separator />
|
||||||
{cameras.map((camera) => (
|
{cameras.map(([camera]) => (
|
||||||
<Destination href={`/cameras/${camera}`} text={camera} />
|
<Destination href={`/cameras/${camera}`} text={camera} />
|
||||||
))}
|
))}
|
||||||
<Separator />
|
<Separator />
|
||||||
@ -27,6 +27,28 @@ export default function Sidebar() {
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
</Match>
|
</Match>
|
||||||
|
<Match path="/recording/:camera/:date?/:hour?/:seconds?">
|
||||||
|
{({ matches }) =>
|
||||||
|
matches ? (
|
||||||
|
<Fragment>
|
||||||
|
<Separator />
|
||||||
|
{cameras.map(([camera, conf]) => {
|
||||||
|
if (conf.record.enabled) {
|
||||||
|
return (
|
||||||
|
<Destination
|
||||||
|
path={`/recording/${camera}/:date?/:hour?/:seconds?`}
|
||||||
|
href={`/recording/${camera}`}
|
||||||
|
text={camera}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
<Separator />
|
||||||
|
</Fragment>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
</Match>
|
||||||
<Destination href="/events" text="Events" />
|
<Destination href="/events" text="Events" />
|
||||||
<Destination href="/debug" text="Debug" />
|
<Destination href="/debug" text="Debug" />
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|||||||
@ -9,8 +9,8 @@ describe('Sidebar', () => {
|
|||||||
jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
|
jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
|
||||||
data: {
|
data: {
|
||||||
cameras: {
|
cameras: {
|
||||||
front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] } },
|
front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] }, record: { enabled: true } },
|
||||||
side: { name: 'side', objects: { track: ['taco', 'cat', 'dog'] } },
|
side: { name: 'side', objects: { track: ['taco', 'cat', 'dog'] }, record: { enabled: false } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@ -30,4 +30,11 @@ describe('Sidebar', () => {
|
|||||||
expect(screen.queryByRole('link', { name: 'front' })).toBeInTheDocument();
|
expect(screen.queryByRole('link', { name: 'front' })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('link', { name: 'side' })).toBeInTheDocument();
|
expect(screen.queryByRole('link', { name: 'side' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('render cameras if in record route', async () => {
|
||||||
|
window.history.replaceState({}, 'Front Recordings', '/recording/front');
|
||||||
|
render(<Sidebar />);
|
||||||
|
expect(screen.queryByRole('link', { name: 'front' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('link', { name: 'side' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -110,6 +110,11 @@ export function useEvent(eventId, fetchId) {
|
|||||||
return useFetch(url, fetchId);
|
return useFetch(url, fetchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useRecording(camera, fetchId) {
|
||||||
|
const url = `/api/${camera}/recordings`;
|
||||||
|
return useFetch(url, fetchId);
|
||||||
|
}
|
||||||
|
|
||||||
export function useConfig(searchParams, fetchId) {
|
export function useConfig(searchParams, fetchId) {
|
||||||
const url = `/api/config${searchParams ? `?${searchParams.toString()}` : ''}`;
|
const url = `/api/config${searchParams ? `?${searchParams.toString()}` : ''}`;
|
||||||
return useFetch(url, fetchId);
|
return useFetch(url, fetchId);
|
||||||
|
|||||||
@ -66,7 +66,7 @@ export default function Button({
|
|||||||
|
|
||||||
let classes = `whitespace-nowrap flex items-center space-x-1 ${className} ${ButtonTypes[type]} ${
|
let classes = `whitespace-nowrap flex items-center space-x-1 ${className} ${ButtonTypes[type]} ${
|
||||||
ButtonColors[disabled ? 'disabled' : color][type]
|
ButtonColors[disabled ? 'disabled' : color][type]
|
||||||
} font-sans inline-flex font-bold uppercase text-xs px-2 py-2 rounded outline-none focus:outline-none ring-opacity-50 transition-shadow transition-colors ${
|
} font-sans inline-flex font-bold uppercase text-xs px-1.5 md:px-2 py-2 rounded outline-none focus:outline-none ring-opacity-50 transition-shadow transition-colors ${
|
||||||
disabled ? 'cursor-not-allowed' : 'focus:ring-2 cursor-pointer'
|
disabled ? 'cursor-not-allowed' : 'focus:ring-2 cursor-pointer'
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
|||||||
47
web/src/components/Dialog.jsx
Normal file
47
web/src/components/Dialog.jsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { h, Fragment } from 'preact';
|
||||||
|
import Button from './Button';
|
||||||
|
import Heading from './Heading';
|
||||||
|
import { createPortal } from 'preact/compat';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
|
export default function Dialog({ actions = [], portalRootID = 'dialogs', title, text }) {
|
||||||
|
const portalRoot = portalRootID && document.getElementById(portalRootID);
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
setShow(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dialog = (
|
||||||
|
<Fragment>
|
||||||
|
<div
|
||||||
|
data-testid="scrim"
|
||||||
|
key="scrim"
|
||||||
|
className="absolute inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="modal"
|
||||||
|
className={`absolute rounded shadow-2xl bg-white dark:bg-gray-700 max-w-sm text-gray-900 dark:text-white transition-transform transition-opacity duration-75 transform scale-90 opacity-0 ${
|
||||||
|
show ? 'scale-100 opacity-100' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="p-4">
|
||||||
|
<Heading size="lg">{title}</Heading>
|
||||||
|
<p>{text}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
|
||||||
|
{actions.map(({ color, text, onClick, ...props }, i) => (
|
||||||
|
<Button className="ml-2" color={color} key={i} onClick={onClick} type="text" {...props}>
|
||||||
|
{text}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
|
||||||
|
return portalRoot ? createPortal(dialog, portalRoot) : dialog;
|
||||||
|
}
|
||||||
111
web/src/components/RecordingPlaylist.jsx
Normal file
111
web/src/components/RecordingPlaylist.jsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import { addSeconds, differenceInSeconds, fromUnixTime, format, parseISO, startOfHour } from 'date-fns';
|
||||||
|
import ArrowDropdown from '../icons/ArrowDropdown';
|
||||||
|
import ArrowDropup from '../icons/ArrowDropup';
|
||||||
|
import Link from '../components/Link';
|
||||||
|
import Menu from '../icons/Menu';
|
||||||
|
import MenuOpen from '../icons/MenuOpen';
|
||||||
|
import { useApiHost } from '../api';
|
||||||
|
|
||||||
|
export default function RecordingPlaylist({ camera, recordings, selectedDate, selectedHour }) {
|
||||||
|
const [active, setActive] = useState(true);
|
||||||
|
const toggle = () => setActive(!active);
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
for (const recording of recordings.slice().reverse()) {
|
||||||
|
const date = parseISO(recording.date);
|
||||||
|
result.push(
|
||||||
|
<ExpandableList
|
||||||
|
title={format(date, 'MMM d, yyyy')}
|
||||||
|
events={recording.events}
|
||||||
|
selected={recording.date === selectedDate}
|
||||||
|
>
|
||||||
|
{recording.recordings.map((item, i) => (
|
||||||
|
<div className="mb-2 w-full">
|
||||||
|
<div
|
||||||
|
className={`flex w-full text-md text-white px-8 py-2 mb-2 ${
|
||||||
|
i === 0 ? 'border-t border-white border-opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Link href={`/recording/${camera}/${recording.date}/${item.hour}`} type="text">
|
||||||
|
{item.hour}:00
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-right">{item.events.length} Events</div>
|
||||||
|
</div>
|
||||||
|
{item.events.map((event) => (
|
||||||
|
<EventCard camera={camera} event={event} delay={item.delay} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ExpandableList>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openClass = active ? '-left-6' : 'right-0';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex absolute inset-y-0 right-0 w-9/12 md:w-1/2 lg:w-3/5 max-w-md text-base text-white font-sans">
|
||||||
|
<div
|
||||||
|
onClick={toggle}
|
||||||
|
className={`absolute ${openClass} cursor-pointer items-center self-center rounded-tl-lg rounded-bl-lg border border-r-0 w-6 h-20 py-7 bg-gray-800 bg-opacity-70`}
|
||||||
|
>
|
||||||
|
{active ? <Menu /> : <MenuOpen />}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`w-full h-full bg-gray-800 bg-opacity-70 border-l overflow-x-hidden overflow-y-auto${
|
||||||
|
active ? '' : ' hidden'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{result}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpandableList({ title, events = 0, children, selected = false }) {
|
||||||
|
const [active, setActive] = useState(selected);
|
||||||
|
const toggle = () => setActive(!active);
|
||||||
|
return (
|
||||||
|
<div className={`w-full text-sm ${active ? 'border-b border-white border-opacity-50' : ''}`}>
|
||||||
|
<div className="flex items-center w-full p-2 cursor-pointer md:text-lg" onClick={toggle}>
|
||||||
|
<div className="flex-1 font-bold">{title}</div>
|
||||||
|
<div className="flex-1 text-right mr-4">{events} Events</div>
|
||||||
|
<div className="w-6 md:w-10 h-6 md:h-10">{active ? <ArrowDropup /> : <ArrowDropdown />}</div>
|
||||||
|
</div>
|
||||||
|
<div className={`bg-gray-800 bg-opacity-50 ${active ? '' : 'hidden'}`}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventCard({ camera, event, delay }) {
|
||||||
|
const apiHost = useApiHost();
|
||||||
|
const start = fromUnixTime(event.start_time);
|
||||||
|
const end = fromUnixTime(event.end_time);
|
||||||
|
const duration = addSeconds(new Date(0), differenceInSeconds(end, start));
|
||||||
|
const seconds = Math.max(differenceInSeconds(start, startOfHour(start)) - delay - 10, 0);
|
||||||
|
return (
|
||||||
|
<Link className="" href={`/recording/${camera}/${format(start, 'yyyy-MM-dd')}/${format(start, 'HH')}/${seconds}`}>
|
||||||
|
<div className="flex flex-row mb-2">
|
||||||
|
<div className="w-28 mr-4">
|
||||||
|
<img className="antialiased" src={`${apiHost}/api/events/${event.id}/thumbnail.jpg`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row w-full border-b">
|
||||||
|
<div className="w-full text-gray-700 font-semibold relative pt-0">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-2xl text-white leading-tight capitalize">{event.label}</div>
|
||||||
|
<div className="text-xs md:text-normal text-gray-300">Start: {format(start, 'HH:mm:ss')}</div>
|
||||||
|
<div className="text-xs md:text-normal text-gray-300">Duration: {format(duration, 'mm:ss')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-lg text-white text-right leading-tight">{(event.top_score * 100).toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-6" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
web/src/components/VideoPlayer.jsx
Normal file
59
web/src/components/VideoPlayer.jsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { h, Component } from 'preact';
|
||||||
|
import videojs from 'video.js';
|
||||||
|
import 'videojs-mobile-ui';
|
||||||
|
import 'videojs-playlist';
|
||||||
|
import 'videojs-seek-buttons';
|
||||||
|
import 'video.js/dist/video-js.css';
|
||||||
|
import 'videojs-seek-buttons/dist/videojs-seek-buttons.css';
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
controls: true,
|
||||||
|
playbackRates: [0.5, 1, 2, 4, 8],
|
||||||
|
fluid: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class VideoPlayer extends Component {
|
||||||
|
componentDidMount() {
|
||||||
|
const { options, onReady = () => {} } = this.props;
|
||||||
|
const videoJsOptions = {
|
||||||
|
...defaultOptions,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
this.player = videojs(this.videoNode, videoJsOptions, function onPlayerReady() {
|
||||||
|
onReady(this);
|
||||||
|
});
|
||||||
|
this.player.seekButtons({
|
||||||
|
forward: 30,
|
||||||
|
back: 10,
|
||||||
|
});
|
||||||
|
this.player.mobileUi({
|
||||||
|
fullscreen: {
|
||||||
|
iOS: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
const { onDispose = () => {} } = this.props;
|
||||||
|
if (this.player) {
|
||||||
|
this.player.dispose();
|
||||||
|
onDispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldComponentUpdate() {
|
||||||
|
// return false;
|
||||||
|
// }
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { style, children } = this.props;
|
||||||
|
return (
|
||||||
|
<div style={style}>
|
||||||
|
<div data-vjs-player>
|
||||||
|
<video ref={(node) => (this.videoNode = node)} className="video-js vjs-default-skin" controls playsinline />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
web/src/components/__tests__/Dialog.test.jsx
Normal file
38
web/src/components/__tests__/Dialog.test.jsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import Dialog from '../Dialog';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||||
|
|
||||||
|
describe('Dialog', () => {
|
||||||
|
let portal;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
portal = document.createElement('div');
|
||||||
|
portal.id = 'dialogs';
|
||||||
|
document.body.appendChild(portal);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
document.body.removeChild(portal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders to a portal', async () => {
|
||||||
|
render(<Dialog title="Tacos" text="This is the dialog" />);
|
||||||
|
expect(screen.getByText('Tacos')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('modal').closest('#dialogs')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders action buttons', async () => {
|
||||||
|
const handleClick = jest.fn();
|
||||||
|
render(
|
||||||
|
<Dialog
|
||||||
|
actions={[
|
||||||
|
{ color: 'red', text: 'Delete' },
|
||||||
|
{ text: 'Okay', onClick: handleClick },
|
||||||
|
]}
|
||||||
|
title="Tacos"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Okay' }));
|
||||||
|
expect(handleClick).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
13
web/src/icons/Delete.jsx
Normal file
13
web/src/icons/Delete.jsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Delete({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
||||||
|
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||||
|
<path d="M6 21h12V7H6v14zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Delete);
|
||||||
@ -25,3 +25,7 @@
|
|||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-js.vjs-has-started .vjs-touch-overlay {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
@ -16,19 +16,25 @@ export default function Cameras() {
|
|||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4">
|
||||||
{Object.keys(config.cameras).map((camera) => (
|
{Object.entries(config.cameras).map(([camera, conf]) => (
|
||||||
<Camera name={camera} />
|
<Camera name={camera} conf={conf} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Camera({ name }) {
|
function Camera({ name, conf }) {
|
||||||
const { payload: detectValue, send: sendDetect } = useDetectState(name);
|
const { payload: detectValue, send: sendDetect } = useDetectState(name);
|
||||||
const { payload: clipValue, send: sendClips } = useClipsState(name);
|
const { payload: clipValue, send: sendClips } = useClipsState(name);
|
||||||
const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name);
|
const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name);
|
||||||
const href = `/cameras/${name}`;
|
const href = `/cameras/${name}`;
|
||||||
const buttons = useMemo(() => [{ name: 'Events', href: `/events?camera=${name}` }], [name]);
|
const buttons = useMemo(() => {
|
||||||
|
const result = [{ name: 'Events', href: `/events?camera=${name}` }];
|
||||||
|
if (conf.record.enabled) {
|
||||||
|
result.push({ name: 'Recordings', href: `/recording/${name}` });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [name, conf.record.enabled]);
|
||||||
const icons = useMemo(
|
const icons = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import { h, Fragment } from 'preact';
|
import { h, Fragment } from 'preact';
|
||||||
|
import { useCallback, useState } from 'preact/hooks';
|
||||||
|
import { route } from 'preact-router';
|
||||||
import ActivityIndicator from '../components/ActivityIndicator';
|
import ActivityIndicator from '../components/ActivityIndicator';
|
||||||
|
import Button from '../components/Button';
|
||||||
|
import Delete from '../icons/Delete'
|
||||||
|
import Dialog from '../components/Dialog';
|
||||||
import Heading from '../components/Heading';
|
import Heading from '../components/Heading';
|
||||||
import Link from '../components/Link';
|
import Link from '../components/Link';
|
||||||
import { FetchStatus, useApiHost, useEvent } from '../api';
|
import { FetchStatus, useApiHost, useEvent } from '../api';
|
||||||
@ -8,9 +13,39 @@ import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table';
|
|||||||
export default function Event({ eventId }) {
|
export default function Event({ eventId }) {
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const { data, status } = useEvent(eventId);
|
const { data, status } = useEvent(eventId);
|
||||||
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
const [deleteStatus, setDeleteStatus] = useState(FetchStatus.NONE);
|
||||||
|
|
||||||
|
const handleClickDelete = () => {
|
||||||
|
setShowDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismissDeleteDialog = () => {
|
||||||
|
setShowDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleClickDeleteDialog = useCallback(async () => {
|
||||||
|
|
||||||
|
let success;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiHost}/api/events/${eventId}`, { method: 'DELETE' });
|
||||||
|
success = await (response.status < 300 ? response.json() : { success: true });
|
||||||
|
setDeleteStatus(success ? FetchStatus.LOADED : FetchStatus.ERROR);
|
||||||
|
} catch (e) {
|
||||||
|
setDeleteStatus(FetchStatus.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
setDeleteStatus(FetchStatus.LOADED);
|
||||||
|
setShowDialog(false);
|
||||||
|
route('/events', true);
|
||||||
|
|
||||||
|
}
|
||||||
|
}, [apiHost, eventId, setShowDialog]);
|
||||||
|
|
||||||
if (status !== FetchStatus.LOADED) {
|
if (status !== FetchStatus.LOADED) {
|
||||||
return <ActivityIndicator />;
|
return <ActivityIndicator />
|
||||||
}
|
}
|
||||||
|
|
||||||
const startime = new Date(data.start_time * 1000);
|
const startime = new Date(data.start_time * 1000);
|
||||||
@ -18,9 +53,27 @@ export default function Event({ eventId }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Heading>
|
<div className="flex">
|
||||||
|
<Heading className="flex-grow">
|
||||||
{data.camera} {data.label} <span className="text-sm">{startime.toLocaleString()}</span>
|
{data.camera} {data.label} <span className="text-sm">{startime.toLocaleString()}</span>
|
||||||
</Heading>
|
</Heading>
|
||||||
|
<Button className="self-start" color="red" onClick={handleClickDelete}>
|
||||||
|
<Delete className="w-6" /> Delete event
|
||||||
|
</Button>
|
||||||
|
{showDialog ? (
|
||||||
|
<Dialog
|
||||||
|
onDismiss={handleDismissDeleteDialog}
|
||||||
|
title="Delete Event?"
|
||||||
|
text="This event will be permanently deleted along with any related clips and snapshots"
|
||||||
|
actions={[
|
||||||
|
deleteStatus !== FetchStatus.LOADING
|
||||||
|
? { text: 'Delete', color: 'red', onClick: handleClickDeleteDialog }
|
||||||
|
: { text: 'Deleting…', color: 'red', disabled: true },
|
||||||
|
{ text: 'Cancel', onClick: handleDismissDeleteDialog },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Table class="w-full">
|
<Table class="w-full">
|
||||||
<Thead>
|
<Thead>
|
||||||
|
|||||||
97
web/src/routes/Recording.jsx
Normal file
97
web/src/routes/Recording.jsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { closestTo, format, parseISO } from 'date-fns';
|
||||||
|
import ActivityIndicator from '../components/ActivityIndicator';
|
||||||
|
import Heading from '../components/Heading';
|
||||||
|
import RecordingPlaylist from '../components/RecordingPlaylist';
|
||||||
|
import VideoPlayer from '../components/VideoPlayer';
|
||||||
|
import { FetchStatus, useApiHost, useRecording } from '../api';
|
||||||
|
|
||||||
|
export default function Recording({ camera, date, hour, seconds }) {
|
||||||
|
const apiHost = useApiHost();
|
||||||
|
const { data, status } = useRecording(camera);
|
||||||
|
|
||||||
|
if (status !== FetchStatus.LOADED) {
|
||||||
|
return <ActivityIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Heading>{camera} Recordings</Heading>
|
||||||
|
<div class="bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4" role="alert">
|
||||||
|
<p class="font-bold">No Recordings Found</p>
|
||||||
|
<p>Make sure you have enabled the record role in your configuration for the {camera} camera.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordingDates = data.map((item) => item.date);
|
||||||
|
const selectedDate = closestTo(
|
||||||
|
date ? parseISO(date) : new Date(),
|
||||||
|
recordingDates.map((i) => parseISO(i))
|
||||||
|
);
|
||||||
|
const selectedKey = format(selectedDate, 'yyyy-MM-dd');
|
||||||
|
const [year, month, day] = selectedKey.split('-');
|
||||||
|
const playlist = [];
|
||||||
|
const hours = [];
|
||||||
|
|
||||||
|
for (const item of data) {
|
||||||
|
if (item.date === selectedKey) {
|
||||||
|
for (const recording of item.recordings) {
|
||||||
|
playlist.push({
|
||||||
|
name: `${selectedKey} ${recording.hour}:00`,
|
||||||
|
description: `${camera} recording @ ${recording.hour}:00.`,
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
src: `${apiHost}/vod/${year}-${month}/${day}/${recording.hour}/${camera}/index.m3u8`,
|
||||||
|
type: 'application/vnd.apple.mpegurl',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
hours.push(recording.hour);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedHour = hours.indexOf(hour);
|
||||||
|
|
||||||
|
if (this.player) {
|
||||||
|
this.player.playlist([]);
|
||||||
|
this.player.playlist(playlist);
|
||||||
|
this.player.playlist.autoadvance(0);
|
||||||
|
if (selectedHour !== -1) {
|
||||||
|
this.player.playlist.currentItem(selectedHour);
|
||||||
|
if (seconds !== undefined) {
|
||||||
|
this.player.currentTime(seconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Heading>{camera} Recordings</Heading>
|
||||||
|
|
||||||
|
<VideoPlayer
|
||||||
|
onReady={(player) => {
|
||||||
|
if (player.playlist) {
|
||||||
|
player.playlist(playlist);
|
||||||
|
player.playlist.autoadvance(0);
|
||||||
|
if (selectedHour !== -1) {
|
||||||
|
player.playlist.currentItem(selectedHour);
|
||||||
|
if (seconds !== undefined) {
|
||||||
|
player.currentTime(seconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.player = player;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDispose={() => {
|
||||||
|
this.player = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RecordingPlaylist camera={camera} recordings={data} selectedDate={selectedKey} selectedHour={hour} />
|
||||||
|
</VideoPlayer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { h } from 'preact';
|
|||||||
import ArrowDropdown from '../icons/ArrowDropdown';
|
import ArrowDropdown from '../icons/ArrowDropdown';
|
||||||
import ArrowDropup from '../icons/ArrowDropup';
|
import ArrowDropup from '../icons/ArrowDropup';
|
||||||
import Button from '../components/Button';
|
import Button from '../components/Button';
|
||||||
|
import Dialog from '../components/Dialog';
|
||||||
import Heading from '../components/Heading';
|
import Heading from '../components/Heading';
|
||||||
import Select from '../components/Select';
|
import Select from '../components/Select';
|
||||||
import Switch from '../components/Switch';
|
import Switch from '../components/Switch';
|
||||||
@ -10,6 +11,7 @@ import { useCallback, useState } from 'preact/hooks';
|
|||||||
|
|
||||||
export default function StyleGuide() {
|
export default function StyleGuide() {
|
||||||
const [switches, setSwitches] = useState({ 0: false, 1: true, 2: false, 3: false });
|
const [switches, setSwitches] = useState({ 0: false, 1: true, 2: false, 3: false });
|
||||||
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
|
||||||
const handleSwitch = useCallback(
|
const handleSwitch = useCallback(
|
||||||
(id, checked) => {
|
(id, checked) => {
|
||||||
@ -18,6 +20,10 @@ export default function StyleGuide() {
|
|||||||
[switches]
|
[switches]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDismissDialog = () => {
|
||||||
|
setShowDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Heading size="md">Button</Heading>
|
<Heading size="md">Button</Heading>
|
||||||
@ -59,6 +65,26 @@ export default function StyleGuide() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Heading size="md">Dialog</Heading>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowDialog(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Show Dialog
|
||||||
|
</Button>
|
||||||
|
{showDialog ? (
|
||||||
|
<Dialog
|
||||||
|
onDismiss={handleDismissDialog}
|
||||||
|
title="This is a dialog"
|
||||||
|
text="Would you like to see more?"
|
||||||
|
actions={[
|
||||||
|
{ text: 'Yes', color: 'red', onClick: handleDismissDialog },
|
||||||
|
{ text: 'No', onClick: handleDismissDialog },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Heading size="md">Switch</Heading>
|
<Heading size="md">Switch</Heading>
|
||||||
<div className="flex-col space-y-4 max-w-4xl">
|
<div className="flex-col space-y-4 max-w-4xl">
|
||||||
<Switch label="Disabled, off" labelPosition="after" />
|
<Switch label="Disabled, off" labelPosition="after" />
|
||||||
|
|||||||
@ -12,8 +12,8 @@ describe('Cameras Route', () => {
|
|||||||
useConfigMock = jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
|
useConfigMock = jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
|
||||||
data: {
|
data: {
|
||||||
cameras: {
|
cameras: {
|
||||||
front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] } },
|
front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] }, record: { enabled: true } },
|
||||||
side: { name: 'side', objects: { track: ['taco', 'cat', 'dog'] } },
|
side: { name: 'side', objects: { track: ['taco', 'cat', 'dog'] }, record: { enabled: false } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
status: 'loaded',
|
status: 'loaded',
|
||||||
@ -41,6 +41,14 @@ describe('Cameras Route', () => {
|
|||||||
expect(screen.queryByText('side').closest('a')).toHaveAttribute('href', '/cameras/side');
|
expect(screen.queryByText('side').closest('a')).toHaveAttribute('href', '/cameras/side');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shows recordings link', async () => {
|
||||||
|
render(<Cameras />);
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.queryAllByText('Recordings')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('buttons toggle detect, clips, and snapshots', async () => {
|
test('buttons toggle detect, clips, and snapshots', async () => {
|
||||||
const sendDetect = jest.fn();
|
const sendDetect = jest.fn();
|
||||||
const sendClips = jest.fn();
|
const sendClips = jest.fn();
|
||||||
|
|||||||
@ -18,6 +18,11 @@ export async function getEvents(url, cb, props) {
|
|||||||
return module.default;
|
return module.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getRecording(url, cb, props) {
|
||||||
|
const module = await import('./Recording.jsx');
|
||||||
|
return module.default;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getDebug(url, cb, props) {
|
export async function getDebug(url, cb, props) {
|
||||||
const module = await import('./Debug.jsx');
|
const module = await import('./Debug.jsx');
|
||||||
return module.default;
|
return module.default;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user