继承于2.44.3版本

Lamp-dev
陈增辉 2025-09-16 09:19:40 +08:00
commit aced3ee4f0
2568 changed files with 404954 additions and 0 deletions

7
.bandit Normal file
View File

@ -0,0 +1,7 @@
[bandit]
# B101 : assert_used
# B102 : exec_used
# B404 : import_subprocess
# B406 : import_xml_sax
skips: B101,B102,B404,B406
exclude: **/tests/**,tests

38
.coveragerc Normal file
View File

@ -0,0 +1,38 @@
[run]
branch = true
source =
cvat/apps/
cvat-sdk/
cvat-cli/
utils/dataset_manifest
omit =
cvat/settings/*
*/tests/*
*/test_*
*/_test_*
*/migrations/*
[report]
# Regexes for lines to exclude from consideration
exclude_lines =
# Have to re-enable the standard pragma
pragma: no cover
# Don't complain about missing debug-only code:
def __repr__
if\s+[\w\.()]+\.isEnabledFor\(log\.DEBUG\):
# Don't complain if tests don't hit defensive assertion code:
raise AssertionError
raise NotImplementedError
# Don't complain if non-runnable code isn't run:
if 0:
if __name__ == .__main__.:
# don't fail on the code that can be found
ignore_errors = true
skip_empty = true

18
.dockerignore Normal file
View File

@ -0,0 +1,18 @@
/.git
/.env
/.vscode
/.github
/.husky
/share
/data
/media
/keys
**/node_modules
/static
/serverless
/site
/helm-chart
/dev
/changelog.d
/cvat-cli
/profiles

21
.editorconfig Normal file
View File

@ -0,0 +1,21 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
# Change these settings to your own preference
indent_style = space
indent_size = 4
# We recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{yml,yaml}]
indent_size = 2

11
.eslintignore Normal file
View File

@ -0,0 +1,11 @@
.*/
3rdparty/
node_modules/
dist/
data/
datumaro/
keys/
logs/
static/
templates/
*/webpack.config.js

78
.eslintrc.cjs Normal file
View File

@ -0,0 +1,78 @@
// Copyright (C) 2018-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
module.exports = {
root: true,
env: {
node: true,
browser: true,
es2020: true,
},
parserOptions: {
sourceType: 'module',
parser: '@typescript-eslint/parser',
},
ignorePatterns: [
'.eslintrc.cjs',
'lint-staged.config.js',
'site/**',
'webpack.config.cjs',
],
plugins: ['@typescript-eslint', 'security', 'no-unsanitized', 'import'],
extends: [
'eslint:recommended', 'plugin:security/recommended', 'plugin:no-unsanitized/DOM',
'airbnb-base', 'plugin:import/errors', 'plugin:import/warnings',
'plugin:import/typescript', 'plugin:@typescript-eslint/recommended', 'airbnb-typescript/base',
],
rules: {
// 'header/header': [2, 'line', [{
// pattern: ' {1}Copyright \\(C\\) (?:20\\d{2}-)?2022 Intel Corporation',
// template: ' Copyright (C) 2022 Intel Corporation'
// }, '', ' SPDX-License-Identifier: MIT']],
'no-plusplus': 0,
'no-continue': 0,
'no-console': 0,
'no-param-reassign': ['error', { 'props': false }],
'no-restricted-syntax': [0, { selector: 'ForOfStatement' }],
'no-await-in-loop': 0,
'indent': ['error', 4, { 'SwitchCase': 1 }],
'max-len': ['error', { code: 120, ignoreStrings: true }],
'func-names': 0,
'valid-typeof': 0,
'quotes': ['error', 'single', { "avoidEscape": true }],
'lines-between-class-members': 0,
'class-methods-use-this': 0,
'no-underscore-dangle': ['error', { allowAfterThis: true }],
'max-classes-per-file': 0,
'operator-linebreak': ['error', 'after'],
'newline-per-chained-call': 0,
'global-require': 0,
'arrow-parens': ['error', 'always'],
'security/detect-object-injection': 0, // the rule is relevant for user input data on the node.js environment
'import/order': ['error', {'groups': ['builtin', 'external', 'internal']}],
'import/prefer-default-export': 0, // works incorrect with interfaces
'react/jsx-indent-props': 0, // new rule, breaks current styling
'react/jsx-indent': 0, // new rule, conflicts with eslint@typescript-eslint/indent eslint@indent, breaks current styling
'function-paren-newline': 0, // new rule, breaks current styling
'@typescript-eslint/default-param-last': 0, // does not really work with redux reducers
'@typescript-eslint/ban-ts-comment': 0,
'@typescript-eslint/no-explicit-any': 0,
'@typescript-eslint/indent': ['error', 4],
'@typescript-eslint/lines-between-class-members': 0,
'@typescript-eslint/explicit-function-return-type': ['warn', { allowExpressions: true }],
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/ban-types': [
'error',
{
types: {
'{}': false, // TODO: try to fix with Record<string, unknown>
object: false, // TODO: try to fix with Record<string, unknown>
Function: false, // TODO: try to fix somehow
},
},
],
},
};

19
.nycrc Normal file
View File

@ -0,0 +1,19 @@
{
"all": true,
"compact": false,
"extension": [
".js",
".jsx",
".ts",
".tsx"
],
"exclude": [
"**/3rdparty/*",
"**/tests/*",
"cvat-ui/src/actions/boundaries-actions.ts",
"cvat-ui/src/utils/platform-checker.ts"
],
"parser-plugins": [
"typescript"
]
}

10
.prettierignore Normal file
View File

@ -0,0 +1,10 @@
.*/
3rdparty/
node_modules/
dist/
data/
datumaro/
keys/
logs/
static/
templates/

27
.prettierrc Normal file
View File

@ -0,0 +1,27 @@
{
"arrowParens": "always",
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxBracketSameLine": false,
"jsxSingleQuote": true,
"printWidth": 120,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": true,
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "all",
"useTabs": false,
"vueIndentScriptAndStyle": false,
"overrides": [
{
"files": ["*.json", "*.yml", "*.yaml", "*.md"],
"options": {
"tabWidth": 2
}
}
]
}

977
.pylintrc Normal file
View File

@ -0,0 +1,977 @@
[MAIN]
# 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
# Load and enable all available extensions. Use --list-extensions to see a list
# all available extensions.
#enable-all-extensions=
# In error mode, messages with a category besides ERROR or FATAL are
# suppressed, and no reports are done by default. Error mode is compatible with
# disabling specific errors.
#errors-only=
# Always return a 0 (non-error) status code, even if lint errors are found.
# This is primarily useful in continuous integration scripts.
#exit-zero=
# 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-allow-list=
# 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. (This is an alternative name to extension-pkg-allow-list
# for backward compatibility.)
extension-pkg-whitelist=
# Return non-zero exit code if any of these messages/categories are detected,
# even if score is above --fail-under value. Syntax same as enable. Messages
# specified are enabled, while categories only check already-enabled messages.
fail-on=
# Specify a score threshold to be exceeded before program exits with error.
fail-under=10
# Interpret the stdin as a python script, whose filename needs to be passed as
# the module_or_package argument.
#from-stdin=
# Files or directories to be skipped. They should be base names, not paths.
ignore=CVS
# Add files or directories matching the regex patterns to the ignore-list. The
# regex matches against paths and can be in Posix or Windows format.
ignore-paths=
cvat-sdk/cvat_sdk/api_client|cvat-sdk/build|node_modules
# Files or directories matching the regex patterns are skipped. The regex
# matches against base names, not paths. The default value ignores Emacs file
# locks
ignore-patterns=
# 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=
# 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, and will cap the count on Windows to
# avoid hangs.
jobs=0
# 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=pylint_django
# Pickle collected data for later comparisons.
persistent=yes
# Minimum Python version to use for version dependent checks. Will default to
# the version used to run pylint.
py-version=3.9
# Discover python modules and packages in the file system subtree.
recursive=no
# 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
# In verbose mode, extra non-checker-related info will be displayed.
#verbose=
[REPORTS]
# Python expression which should return a score less than or equal to 10. You
# have access to the variables 'fatal', 'error', 'warning', 'refactor',
# 'convention', and 'info' 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=
# Tells whether to display a full report or only the messages.
reports=no
# Activate the evaluation score.
score=yes
[MESSAGES CONTROL]
# Only show warnings with the listed confidence levels. Leave empty to show
# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
# UNDEFINED.
confidence=HIGH,
CONTROL_FLOW,
INFERENCE,
INFERENCE_FAILURE,
UNDEFINED
# 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 re-enable 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=abstract-method,
arguments-out-of-order,
arguments-renamed,
assert-on-string-literal,
assigning-non-slot,
assignment-from-none,
astroid-error,
attribute-defined-outside-init,
await-outside-async,
bad-classmethod-argument,
bad-configuration-section,
bad-file-encoding,
bad-inline-option,
bad-mcs-classmethod-argument,
bad-mcs-method-argument,
bad-open-mode,
bad-plugin-value,
bad-reversed-sequence,
bad-staticmethod-argument,
bad-str-strip-call,
bad-string-format-type,
bad-thread-instantiation,
bidirectional-unicode,
boolean-datetime,
broad-exception-caught,
broad-exception-raised,
c-extension-no-member,
cell-var-from-loop,
chained-comparison,
class-variable-slots-conflict,
comparison-of-constants,
comparison-with-callable,
comparison-with-itself,
condition-evals-to-constant,
config-parse-error,
consider-iterating-dictionary,
consider-merging-isinstance,
consider-swap-variables,
consider-using-dict-comprehension,
consider-using-dict-items,
consider-using-f-string,
consider-using-from-import,
consider-using-generator,
consider-using-get,
consider-using-in,
consider-using-join,
consider-using-max-builtin,
consider-using-min-builtin,
consider-using-set-comprehension,
consider-using-sys-exit,
consider-using-ternary,
consider-using-with,
cyclic-import,
deprecated-argument,
deprecated-class,
deprecated-decorator,
deprecated-method,
deprecated-module,
deprecated-pragma,
dict-iter-missing-items,
disallowed-name,
django-not-available-placeholder,
django-not-available,
django-not-configured,
django-settings-module-not-found,
duplicate-code,
duplicate-string-formatting-argument,
duplicate-value,
empty-docstring,
eval-used,
f-string-without-interpolation,
fatal,
file-ignored,
fixme,
forgotten-debug-statement,
format-string-without-interpolation,
global-statement,
hard-coded-auth-user,
http-response-with-content-type-json,
http-response-with-json-dumps,
implicit-str-concat,
import-error,
import-outside-toplevel,
import-self,
imported-auth-user,
inconsistent-quotes,
inconsistent-return-statements,
invalid-all-format,
invalid-bool-returned,
invalid-bytes-returned,
invalid-character-backspace,
invalid-character-carriage-return,
invalid-character-esc,
invalid-character-nul,
invalid-character-sub,
invalid-character-zero-width-space,
invalid-characters-in-docstring,
invalid-class-object,
invalid-enum-extension,
invalid-envvar-default,
invalid-envvar-value,
invalid-format-returned,
invalid-getnewargs-ex-returned,
invalid-getnewargs-returned,
invalid-hash-returned,
invalid-index-returned,
invalid-length-hint-returned,
invalid-length-returned,
invalid-metaclass,
invalid-name,
invalid-overridden-method,
invalid-repr-returned,
invalid-str-returned,
invalid-unary-operand-type,
invalid-unicode-codec,
isinstance-second-argument-not-valid-type,
keyword-arg-before-vararg,
line-too-long,
literal-comparison,
locally-disabled,
logging-format-interpolation,
logging-fstring-interpolation,
logging-not-lazy,
method-cache-max-size-none,
method-check-failed,
misplaced-format-function,
missing-class-docstring,
missing-function-docstring,
missing-module-docstring,
missing-parentheses-for-call-in-test,
mixed-line-endings,
model-has-unicode,
model-missing-unicode,
model-no-explicit-unicode,
model-unicode-not-callable,
modelform-uses-exclude,
modified-iterating-dict,
modified-iterating-list,
modified-iterating-set,
multiple-imports,
multiple-statements,
nan-comparison,
no-else-break,
no-else-continue,
no-else-raise,
no-else-return,
no-member,
no-name-in-module,
no-self-argument,
non-ascii-file-name,
non-ascii-module-import,
non-ascii-name,
non-str-assignment-to-dunder-name,
not-a-mapping,
not-an-iterable,
not-async-context-manager,
not-context-manager,
overridden-final-method,
parse-error,
possibly-unused-variable,
possibly-used-before-assignment,
potential-index-error,
preferred-module,
property-with-parameters,
protected-access,
raise-missing-from,
raising-format-tuple,
raw-checker-failed,
redeclared-assigned-name,
redefined-argument-from-local,
redefined-outer-name,
redefined-slots-in-subclass,
redundant-content-type-for-json-response,
redundant-u-string-prefix,
redundant-unittest-assert,
relative-beyond-top-level,
return-arg-in-generator,
self-assigning-variable,
self-cls-assignment,
shallow-copy-environ,
simplifiable-condition,
simplifiable-if-expression,
simplify-boolean-expression,
single-string-used-for-slots,
stop-iteration-return,
subclassed-final-class,
subprocess-popen-preexec-fn,
subprocess-run-check,
super-init-not-called,
super-with-arguments,
super-without-brackets,
suppressed-message,
too-few-public-methods,
too-many-ancestors,
too-many-arguments,
too-many-boolean-expressions,
too-many-branches,
too-many-instance-attributes,
too-many-lines,
too-many-locals,
too-many-nested-blocks,
too-many-positional-arguments,
too-many-public-methods,
too-many-return-statements,
too-many-statements,
trailing-comma-tuple,
trailing-newlines,
try-except-raise,
typevar-double-variance,
typevar-name-incorrect-variance,
typevar-name-mismatch,
unbalanced-tuple-unpacking,
undefined-loop-variable,
unexpected-line-ending-format,
ungrouped-imports,
unhashable-member,
unknown-option-value,
unnecessary-comprehension,
unnecessary-dict-index-lookup,
unnecessary-direct-lambda-call,
unnecessary-dunder-call,
unnecessary-ellipsis,
unnecessary-lambda-assignment,
unnecessary-list-index-lookup,
unnecessary-negation,
unpacking-non-sequence,
unrecognized-inline-option,
unrecognized-option,
unspecified-encoding,
unsubscriptable-object,
unsupported-assignment-operation,
unsupported-binary-operation,
unsupported-delete-operation,
unsupported-membership-test,
unused-argument,
unused-format-string-argument,
unused-private-member,
unused-wildcard-import,
use-a-generator,
use-dict-literal,
use-implicit-booleaness-not-comparison,
use-implicit-booleaness-not-len,
use-list-literal,
use-maxsplit-arg,
use-sequence-for-iteration,
use-symbolic-message-instead,
used-prior-global-declaration,
useless-import-alias,
useless-option-value,
useless-parent-delegation,
useless-return,
useless-suppression,
useless-with-lock,
using-constant-test,
using-f-string-in-unsupported-version,
using-final-decorator-in-unsupported-version,
wrong-exception-operation,
wrong-import-order,
wrong-import-position,
wrong-spelling-in-comment,
wrong-spelling-in-docstring,
yield-inside-async-function,
# 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=abstract-class-instantiated,
access-member-before-definition,
anomalous-backslash-in-string,
anomalous-unicode-escape-in-string,
arguments-differ,
assert-on-tuple,
assignment-from-no-return,
bad-except-order,
bad-exception-cause,
bad-format-character,
bad-format-string-key,
bad-format-string,
bad-indentation,
bad-super-call,
bare-except,
binary-op-exception,
catching-non-exception,
confusing-with-statement,
consider-using-enumerate,
continue-in-finally,
dangerous-default-value,
duplicate-argument-name,
duplicate-bases,
duplicate-except,
duplicate-key,
exec-used,
expression-not-assigned,
format-combined-specification,
format-needs-mapping,
function-redefined,
global-at-module-level,
global-variable-not-assigned,
global-variable-undefined,
inconsistent-mro,
inherit-non-class,
init-is-generator,
invalid-all-object,
invalid-format-index,
invalid-sequence-index,
invalid-slice-index,
invalid-slots-object,
invalid-slots,
invalid-star-assignment-target,
logging-format-truncated,
logging-too-few-args,
logging-too-many-args,
logging-unsupported-format,
lost-exception,
method-hidden,
misplaced-bare-raise,
misplaced-future,
missing-final-newline,
missing-format-argument-key,
missing-format-attribute,
missing-format-string-key,
missing-kwoa,
mixed-format-string,
no-classmethod-decorator,
no-method-argument,
no-staticmethod-decorator,
no-value-for-parameter,
non-iterator-returned,
non-parent-init-called,
nonexistent-operator,
nonlocal-and-global,
nonlocal-without-binding,
not-callable,
not-in-loop,
notimplemented-raised,
pointless-statement,
pointless-string-statement,
raising-bad-type,
raising-non-exception,
redefined-builtin,
redundant-keyword-arg,
reimported,
repeated-keyword,
return-in-init,
return-outside-function,
signature-differs,
simplifiable-if-statement,
singleton-comparison,
star-needs-assignment-target,
superfluous-parens,
syntax-error,
too-few-format-args,
too-many-format-args,
too-many-function-args,
too-many-star-expressions,
trailing-whitespace,
truncated-format-string,
undefined-all-variable,
undefined-variable,
unexpected-keyword-arg,
unexpected-special-method-signature,
unidiomatic-typecheck,
unnecessary-lambda,
unnecessary-pass,
unnecessary-semicolon,
unreachable,
unused-format-string-key,
unused-import,
unused-variable,
used-before-assignment,
useless-else-on-loop,
useless-object-inheritance,
wildcard-import,
yield-outside-function,
[BASIC]
# Naming style matching correct argument names.
argument-naming-style=snake_case
# Regular expression matching correct argument names. Overrides argument-
# naming-style. If left empty, argument names will be checked with the set
# naming style.
argument-rgx=[a-z_][a-z0-9_]{2,30}$
# Naming style matching correct attribute names.
attr-naming-style=snake_case
# Regular expression matching correct attribute names. Overrides attr-naming-
# style. If left empty, attribute names will be checked with the set naming
# style.
attr-rgx=[a-z_][a-z0-9_]{2,30}$
# 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. If left empty, class attribute names will be checked
# with the set naming style.
class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
# Naming style matching correct class constant names.
class-const-naming-style=UPPER_CASE
# Regular expression matching correct class constant names. Overrides class-
# const-naming-style. If left empty, class constant names will be checked with
# the set naming style.
#class-const-rgx=
# Naming style matching correct class names.
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-
# style. If left empty, class names will be checked with the set naming style.
class-rgx=[A-Z_][a-zA-Z0-9]+$
# Naming style matching correct constant names.
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style. If left empty, constant names will be checked with the set naming
# style.
const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$
# 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. If left empty, function names will be checked with the set
# naming style.
function-rgx=[a-z_][a-z0-9_]{2,30}$
# 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. If left empty, inline iteration names will be checked
# with the set naming style.
inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$
# Naming style matching correct method names.
method-naming-style=snake_case
# Regular expression matching correct method names. Overrides method-naming-
# style. If left empty, method names will be checked with the set naming style.
method-rgx=[a-z_][a-z0-9_]{2,30}$
# Naming style matching correct module names.
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style. If left empty, module names will be checked with the set naming style.
module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
# 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
# Regular expression matching correct type variable names. If left empty, type
# variable names will be checked with the set naming style.
#typevar-rgx=
# Naming style matching correct variable names.
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style. If left empty, variable names will be checked with the set
# naming style.
variable-rgx=[a-z_][a-z0-9_]{2,30}$
[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 names allowed to shadow builtins
allowed-redefined-builtins=
# 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]*?$)|dummy
# Argument names that match this expression will be ignored. Default to name
# with leading underscore.
ignored-argument-names=_.*
# 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,future.builtins
[DESIGN]
# List of regular expressions of class ancestor names to ignore when counting
# public methods (see R0903)
exclude-too-few-public-methods=
# List of qualified class names to ignore when counting class parents (see
# R0901)
ignored-parents=
# 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
[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
[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
[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=
[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,argparse.parse_error
[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 be considered directives if they
# appear at the beginning of a comment and should not be checked.
spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
# 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
[SIMILARITIES]
# Comments are removed from the similarity computation
ignore-comments=yes
# Docstrings are removed from the similarity computation
ignore-docstrings=yes
# Imports are removed from the similarity computation
ignore-imports=no
# Signatures are removed from the similarity computation
ignore-signatures=yes
# Minimum lines number of a similarity.
min-similarity-lines=4
[EXCEPTIONS]
# Exceptions that will emit a warning when caught.
overgeneral-exceptions=builtins.Exception
[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
# Deprecated modules which should not be used, separated by a comma.
deprecated-modules=optparse
# Output a graph (.gv or any supported image format) of external dependencies
# to the given file (report RP0402 must not be disabled).
ext-import-graph=
# Output a graph (.gv or any supported image format) of all (i.e. internal and
# external) dependencies to the given file (report RP0402 must not be
# disabled).
import-graph=
# Output a graph (.gv or any supported image format) of internal dependencies
# to 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=
[CLASSES]
# Warn about protected attribute access inside special methods
check-protected-access-in-special-methods=no
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,
__new__,
setUp
# 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=mcs
[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 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 symbolic message names to ignore for Mixin members.
ignored-checks-for-mixins=no-member,
not-async-context-manager,
not-context-manager,
attribute-defined-outside-init
# 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
# 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
# Regex pattern to define which classes are considered mixins.
mixin-class-rgx=.*[Mm]ixin
# List of decorators that change the signature of a decorated function.
signature-mutators=
[LOGGING]
# The type of string formatting that logging methods do. `old` means using %
# formatting, `new` is for `{}` formatting.
logging-format-style=old
# Logging modules to check that the string format arguments are in logging
# function parameter format.
logging-modules=logging
[DJANGO FOREIGN KEYS REFERENCED BY STRINGS]
# A module containing Django settings to be used while linting.
#django-settings-module=

37
.regal/config.yaml Normal file
View File

@ -0,0 +1,37 @@
ignore:
files:
- "*_test.gen.rego"
rules:
custom:
naming-convention:
conventions:
- pattern: '^[a-z_]+$|^[A-Z_]+$'
targets:
- rule
- function
- variable
level: error
idiomatic:
no-defined-entrypoint:
# This would likely be the allow rule in each package
# Not critical though, so ignoring for the time being
level: ignore
style:
avoid-get-and-list-prefix:
# Mainly a style preference
# https://docs.styra.com/regal/rules/style/avoid-get-and-list-prefix
level: ignore
opa-fmt:
# https://docs.styra.com/regal/rules/style/opa-fmt
level: ignore
prefer-snake-case:
# Disabled in favor of custom naming-convention rule above
level: ignore
rule-length:
# Many rules longer than the default limit of 30 lines
level: error
max-rule-length: 60
imports:
unresolved-import:
# one of "error", "warning", "ignore"
level: error

4
.remarkignore Normal file
View File

@ -0,0 +1,4 @@
cvat-sdk/docs/
cvat-sdk/README.md
.env/
site/themes/

18
.remarkrc.js Normal file
View File

@ -0,0 +1,18 @@
exports.settings = { bullet: '*', paddedTable: false };
exports.plugins = [
'remark-frontmatter',
'remark-gfm',
'remark-preset-lint-recommended',
'remark-preset-lint-consistent',
['remark-lint-list-item-indent', 'one'],
['remark-lint-no-dead-urls', false], // Does not work because of github protection system
['remark-lint-maximum-line-length', 120],
['remark-lint-maximum-heading-length', 120],
['remark-lint-strong-marker', '*'],
['remark-lint-emphasis-marker', '_'],
['remark-lint-unordered-list-marker-style', '-'],
['remark-lint-ordered-list-marker-style', '.'],
['remark-lint-no-file-name-irregular-characters', false],
['remark-lint-list-item-spacing', false],
];

11
.stylelintrc.json Normal file
View File

@ -0,0 +1,11 @@
{
"extends": "stylelint-config-standard-scss",
"rules": {
"scss/comment-no-empty": null,
"value-keyword-case": null,
"color-function-notation": ["legacy"],
"scss/at-extend-no-missing-placeholder": null,
"no-descending-specificity": null
},
"ignoreFiles": ["**/*.js", "**/*.ts", "**/*.py"]
}

572
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,572 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "REST API tests: Attach to server",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "127.0.0.1",
"port": 9090
},
"pathMappings": [
{
"localRoot": "${workspaceFolder}",
"remoteRoot": "/home/django/"
},
{
"localRoot": "${workspaceFolder}/.env",
"remoteRoot": "/opt/venv",
}
],
"justMyCode": false,
},
{
"name": "REST API tests: Attach to RQ annotation worker",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "127.0.0.1",
"port": 9091
},
"pathMappings": [
{
"localRoot": "${workspaceFolder}",
"remoteRoot": "/home/django/"
},
{
"localRoot": "${workspaceFolder}/.env",
"remoteRoot": "/opt/venv",
}
],
"justMyCode": false,
},
{
"name": "REST API tests: Attach to RQ export worker",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "127.0.0.1",
"port": 9092
},
"pathMappings": [
{
"localRoot": "${workspaceFolder}",
"remoteRoot": "/home/django/"
},
{
"localRoot": "${workspaceFolder}/.env",
"remoteRoot": "/opt/venv",
}
],
"justMyCode": false,
},
{
"name": "REST API tests: Attach to RQ import worker",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "127.0.0.1",
"port": 9093
},
"pathMappings": [
{
"localRoot": "${workspaceFolder}",
"remoteRoot": "/home/django/"
},
{
"localRoot": "${workspaceFolder}/.env",
"remoteRoot": "/opt/venv",
}
],
"justMyCode": false,
},
{
"name": "REST API tests: Attach to RQ quality reports worker",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "127.0.0.1",
"port": 9094
},
"pathMappings": [
{
"localRoot": "${workspaceFolder}",
"remoteRoot": "/home/django/"
},
{
"localRoot": "${workspaceFolder}/.env",
"remoteRoot": "/opt/venv",
}
],
"justMyCode": false,
},
{
"name": "REST API tests: Attach to RQ consensus worker",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "127.0.0.1",
"port": 9096
},
"pathMappings": [
{
"localRoot": "${workspaceFolder}",
"remoteRoot": "/home/django/"
},
{
"localRoot": "${workspaceFolder}/.env",
"remoteRoot": "/opt/venv",
}
],
"justMyCode": false,
},
{
"type": "pwa-chrome",
"request": "launch",
"preLaunchTask": "npm: start - cvat-ui",
"name": "ui.js: debug",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/cvat-ui",
"sourceMaps": true,
"sourceMapPathOverrides": {
"webpack://cvat/./*": "${workspaceFolder}/cvat-core/*",
"webpack:///./*": "${webRoot}/*",
"webpack:///src/*": "${webRoot}/*",
"webpack:///*": "*",
"webpack:///./~/*": "${webRoot}/node_modules/*"
},
"smartStep": true,
},
{
"type": "node",
"request": "launch",
"name": "ui.js: test",
"cwd": "${workspaceFolder}/tests",
"runtimeExecutable": "${workspaceFolder}/tests/node_modules/.bin/cypress",
"args": [
"run",
"--headless",
"--browser",
"chrome"
],
"outputCapture": "std",
"console": "internalConsole"
},
{
"name": "server: django",
"type": "debugpy",
"request": "launch",
"stopOnEntry": false,
"justMyCode": false,
"python": "${command:python.interpreterPath}",
"program": "${workspaceFolder}/manage.py",
"env": {
"CVAT_SERVERLESS": "1",
"ALLOWED_HOSTS": "*",
"DJANGO_LOG_SERVER_HOST": "localhost",
"DJANGO_LOG_SERVER_PORT": "8282",
},
"args": [
"runserver",
"--noreload",
"--insecure",
"127.0.0.1:7000"
],
"django": true,
"cwd": "${workspaceFolder}",
"console": "internalConsole",
},
{
"name": "server: chrome",
"type": "pwa-chrome",
"request": "launch",
"url": "http://localhost:7000/",
"disableNetworkCache": true,
"trace": true,
"showAsyncStacks": true,
"pathMapping": {
"/static/engine/": "${workspaceFolder}/cvat/apps/engine/static/engine/",
"/static/dashboard/": "${workspaceFolder}/cvat/apps/dashboard/static/dashboard/",
}
},
{
"name": "server: RQ - import",
"type": "debugpy",
"request": "launch",
"stopOnEntry": false,
"justMyCode": false,
"python": "${command:python.interpreterPath}",
"program": "${workspaceFolder}/manage.py",
"args": [
"rqworker",
"import",
"--worker-class",
"cvat.rqworker.SimpleWorker"
],
"django": true,
"cwd": "${workspaceFolder}",
"env": {
"DJANGO_LOG_SERVER_HOST": "localhost",
"DJANGO_LOG_SERVER_PORT": "8282"
},
"console": "internalConsole"
},
{
"name": "server: RQ - export",
"type": "debugpy",
"request": "launch",
"stopOnEntry": false,
"justMyCode": false,
"python": "${command:python.interpreterPath}",
"program": "${workspaceFolder}/manage.py",
"args": [
"rqworker",
"export",
"--worker-class",
"cvat.rqworker.SimpleWorker",
],
"django": true,
"cwd": "${workspaceFolder}",
"env": {
"DJANGO_LOG_SERVER_HOST": "localhost",
"DJANGO_LOG_SERVER_PORT": "8282"
},
"console": "internalConsole"
},
{
"name": "server: RQ - quality reports",
"type": "debugpy",
"request": "launch",
"stopOnEntry": false,
"justMyCode": false,
"python": "${command:python.interpreterPath}",
"program": "${workspaceFolder}/manage.py",
"args": [
"rqworker",
"quality_reports",
"--worker-class",
"cvat.rqworker.SimpleWorker",
],
"django": true,
"cwd": "${workspaceFolder}",
"env": {
"DJANGO_LOG_SERVER_HOST": "localhost",
"DJANGO_LOG_SERVER_PORT": "8282"
},
"console": "internalConsole"
},
{
"name": "server: RQ - scheduler",
"type": "debugpy",
"request": "launch",
"stopOnEntry": false,
"justMyCode": false,
"python": "${command:python.interpreterPath}",
"program": "${workspaceFolder}/rqscheduler.py",
"django": true,
"cwd": "${workspaceFolder}",
"args": [
"-i", "1"
],
"env": {
"DJANGO_LOG_SERVER_HOST": "localhost",
"DJANGO_LOG_SERVER_PORT": "8282"
},
"console": "internalConsole"
},
{
"name": "server: RQ - annotation",
"type": "debugpy",
"request": "launch",
"justMyCode": false,
"stopOnEntry": false,
"python": "${command:python.interpreterPath}",
"program": "${workspaceFolder}/manage.py",
"args": [
"rqworker",
"annotation",
"--worker-class",
"cvat.rqworker.SimpleWorker",
],
"django": true,
"cwd": "${workspaceFolder}",
"env": {},
"console": "internalConsole"
},
{
"name": "server: RQ - webhooks",
"type": "debugpy",
"request": "launch",
"justMyCode": false,
"stopOnEntry": false,
"python": "${command:python.interpreterPath}",
"program": "${workspaceFolder}/manage.py",
"args": [
"rqworker",
"webhooks",
"--worker-class",
"cvat.rqworker.SimpleWorker",
],
"django": true,
"cwd": "${workspaceFolder}",
"env": {},
"console": "internalConsole"
},
{
"name": "server: RQ - cleaning",
"type": "debugpy",
"request": "launch",
"stopOnEntry": false,
"justMyCode": false,
"python": "${command:python.interpreterPath}",
"program": "${workspaceFolder}/manage.py",
"args": [
"rqworker",
"cleaning",
"--worker-class",
"cvat.rqworker.SimpleWorker"
],
"django": true,
"cwd": "${workspaceFolder}",
"env": {
"DJANGO_LOG_SERVER_HOST": "localhost",
"DJANGO_LOG_SERVER_PORT": "8282"
},
"console": "internalConsole"
},
{
"name": "server: RQ - chunks",
"type": "debugpy",
"request": "launch",
"stopOnEntry": false,
"justMyCode": false,
"python": "${command:python.interpreterPath}",
"program": "${workspaceFolder}/manage.py",
"args": [
"rqworker",
"chunks",
"--worker-class",
"cvat.rqworker.SimpleWorker"
],
"django": true,
"cwd": "${workspaceFolder}",
"env": {
"DJANGO_LOG_SERVER_HOST": "localhost",
"DJANGO_LOG_SERVER_PORT": "8282"
},
"console": "internalConsole"
},
{
"name": "server: RQ - consensus",
"type": "debugpy",
"request": "launch",
"stopOnEntry": false,
"justMyCode": false,
"python": "${command:python.interpreterPath}",
"program": "${workspaceRoot}/manage.py",
"args": [
"rqworker",
"consensus",
"--worker-class",
"cvat.rqworker.SimpleWorker"
],
"django": true,
"cwd": "${workspaceFolder}",
"env": {
"DJANGO_LOG_SERVER_HOST": "localhost",
"DJANGO_LOG_SERVER_PORT": "8282"
},
"console": "internalConsole"
},
{
"name": "server: migrate",
"type": "debugpy",
"request": "launch",
"justMyCode": false,
"stopOnEntry": false,
"python": "${command:python.interpreterPath}",
"program": "${workspaceFolder}/manage.py",
"args": [
"migrate"
],
"django": true,
"cwd": "${workspaceFolder}",
"env": {},
"console": "internalConsole"
},
{
"name": "server: sync periodic jobs",
"type": "debugpy",
"request": "launch",
"justMyCode": false,
"stopOnEntry": false,
"python": "${command:python.interpreterPath}",
"program": "${workspaceFolder}/manage.py",
"args": [
"syncperiodicjobs"
],
"django": true,
"cwd": "${workspaceFolder}",
"env": {},
"console": "internalConsole"
},
{
"name": "server: tests",
"type": "debugpy",
"request": "launch",
"justMyCode": false,
"stopOnEntry": false,
"python": "${command:python.interpreterPath}",
"program": "${workspaceFolder}/manage.py",
"args": [
"test",
"--settings",
"cvat.settings.testing",
"cvat/apps",
"cvat-cli/"
],
"django": true,
"cwd": "${workspaceFolder}",
"env": {},
"console": "internalConsole"
},
{
"name": "server: REST API tests",
"type": "debugpy",
"request": "launch",
"justMyCode": false,
"stopOnEntry": false,
"python": "${command:python.interpreterPath}",
"module": "pytest",
"args": [
"--verbose",
"--no-cov", // vscode debugger might not work otherwise
"tests/python/rest_api/"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
},
{
"name": "sdk: tests",
"type": "debugpy",
"request": "launch",
"justMyCode": false,
"stopOnEntry": false,
"python": "${command:python.interpreterPath}",
"module": "pytest",
"args": [
"tests/python/sdk/"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
},
{
"name": "cli: tests",
"type": "debugpy",
"request": "launch",
"justMyCode": false,
"stopOnEntry": false,
"python": "${command:python.interpreterPath}",
"module": "pytest",
"args": [
"tests/python/cli/"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
},
{
"name": "api client: Postprocess generator output",
"type": "debugpy",
"request": "launch",
"justMyCode": false,
"stopOnEntry": false,
"python": "${command:python.interpreterPath}",
"program": "${workspaceFolder}/cvat-sdk/gen/postprocess.py",
"args": [
"--schema", "${workspaceFolder}/cvat/schema.yml",
"--input-path", "${workspaceFolder}/cvat-sdk/cvat_sdk/"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
},
{
"name": "docs: Postprocess SDK docs",
"type": "debugpy",
"request": "launch",
"justMyCode": false,
"stopOnEntry": false,
"python": "${command:python.interpreterPath}",
"program": "${workspaceFolder}/site/process_sdk_docs.py",
"args": [
"--input-dir", "${workspaceFolder}/cvat-sdk/docs/",
"--site-root", "${workspaceFolder}/site/",
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
},
{
"name": "docs: Build docs",
"type": "debugpy",
"request": "launch",
"justMyCode": false,
"stopOnEntry": false,
"python": "${command:python.interpreterPath}",
"program": "${workspaceFolder}/site/build_docs.py",
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
},
{
"name": "server: Generate REST API Schema",
"type": "debugpy",
"request": "launch",
"justMyCode": false,
"stopOnEntry": false,
"python": "${command:python.interpreterPath}",
"program": "${workspaceFolder}/manage.py",
"args": [
"spectacular",
"--file",
"${workspaceFolder}/cvat/schema.yml"
],
"django": true,
"cwd": "${workspaceFolder}",
"env": {},
"console": "internalConsole"
},
{
"name": "core.js: debug",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}/cvat-core",
"runtimeExecutable": "node",
"runtimeArgs": [
"--nolazy",
"--inspect-brk=9230",
"src/api.js"
],
"port": 9230
}
],
"compounds": [
{
"name": "server: debug",
"configurations": [
"server: django",
"server: RQ - import",
"server: RQ - export",
"server: RQ - annotation",
"server: RQ - webhooks",
"server: RQ - scheduler",
"server: RQ - quality reports",
"server: RQ - cleaning",
"server: RQ - chunks",
"server: RQ - consensus",
]
}
]
}

50
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,50 @@
{
"eslint.probe": [
"javascript",
"typescript",
"typescriptreact"
],
"eslint.onIgnoredFiles": "warn",
"eslint.workingDirectories": [
{
"directory": "${cwd}",
},
{
"pattern": "cvat-*"
},
{
"directory": "tests",
"!cwd": true
}
],
"npm.exclude": "**/.env/**",
"files.trimTrailingWhitespace": true,
"sqltools.connections": [
{
"previewLimit": 50,
"driver": "SQLite",
"name": "cvat",
"database": "${workspaceFolder:cvat}/db.sqlite3"
}
],
"python.analysis.exclude": [
// VS Code defaults
"**/node_modules",
"**/__pycache__",
".git",
"cvat-cli/build",
"cvat-sdk/build",
],
"python.defaultInterpreterPath": "${workspaceFolder}/.env/",
"python.testing.pytestArgs": [
"--rootdir","${workspaceFolder}/tests/"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"python.testing.pytestPath": "${workspaceFolder}/.env/bin/pytest",
"python.testing.cwd": "${workspaceFolder}/tests",
"cSpell.words": [
"crowdsourcing"
]
}

38
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,38 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"path": "cvat-ui/",
"label": "npm: start - cvat-ui",
"detail": "webpack-dev-server --env.API_URL=http://localhost:7000 --config ./webpack.config.js --mode=development",
"promptOnClose": true,
"isBackground": true,
"problemMatcher": {
"owner": "webpack",
"severity": "error",
"fileLocation": "absolute",
"pattern": [
{
"regexp": "ERROR in (.*)",
"file": 1
},
{
"regexp": "\\((\\d+),(\\d+)\\):(.*)",
"line": 1,
"column": 2,
"message": 3
}
],
"background": {
"activeOnStart": true,
"beginsPattern": "webpack-dev-server",
"endsPattern": "compiled"
}
}
}
]
}

1
.yarnrc.yml Normal file
View File

@ -0,0 +1 @@
nodeLinker: node-modules

4534
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

38
CITATION.cff Normal file
View File

@ -0,0 +1,38 @@
# This CITATION.cff file was generated with cffinit.
# Visit https://bit.ly/cffinit to generate yours today!
cff-version: 1.2.0
title: Computer Vision Annotation Tool (CVAT)
message: >-
If you use this software, please cite it using the
metadata from this file.
type: software
authors:
- email: support+github@cvat.ai
name: CVAT.ai Corporation
identifiers:
- type: doi
value: 10.5281/zenodo.4009388
repository-code: 'https://github.com/cvat-ai/cvat'
url: 'https://cvat.ai/'
abstract: >-
Annotate better with CVAT, the industry-leading
data engine for machine learning. Used and trusted
by teams at any scale, for data of any scale.
keywords:
- image-labeling-tool
- computer-vision-annotation
- labeling-tool
- image-labeling
- semantic-segmentation
- annotation-tool
- object-detection
- image-classification
- video-annotation
- computer-vision
- deep-learning
- annotation
license: MIT
version: 2.25.0
date-released: '2023-11-06'

214
Dockerfile Normal file
View File

@ -0,0 +1,214 @@
ARG PIP_VERSION=24.0
ARG BASE_IMAGE=ubuntu:22.04
FROM ${BASE_IMAGE} AS build-image-base
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get --no-install-recommends install -yq \
curl \
g++ \
gcc \
git \
libgeos-dev \
libldap2-dev \
libsasl2-dev \
make \
nasm \
pkg-config \
python3-dev \
python3-pip \
libxml2-dev \
libxmlsec1-dev \
libxmlsec1-openssl \
libhdf5-dev \
cargo \
&& rm -rf /var/lib/apt/lists/*
ARG PIP_VERSION
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
RUN --mount=type=cache,target=/root/.cache/pip/http \
python3 -m pip install -U pip==${PIP_VERSION}
# We build OpenH264, FFmpeg and PyAV in a separate build stage,
# because this way Docker can do it in parallel to all the other packages.
FROM build-image-base AS build-image-av
# Compile Openh264 and FFmpeg
ARG PREFIX=/opt/ffmpeg
ARG PKG_CONFIG_PATH=${PREFIX}/lib/pkgconfig
ENV FFMPEG_VERSION=4.3.1 \
OPENH264_VERSION=2.1.1
WORKDIR /tmp/openh264
RUN curl -sL https://github.com/cisco/openh264/archive/v${OPENH264_VERSION}.tar.gz --output - | \
tar -zx --strip-components=1 && \
make -j5 && make install-shared PREFIX=${PREFIX} && make clean
WORKDIR /tmp/ffmpeg
RUN curl -sL https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.gz --output - | \
tar -zx --strip-components=1 && \
./configure --disable-nonfree --disable-gpl --enable-libopenh264 \
--enable-shared --disable-static --disable-doc --disable-programs --prefix="${PREFIX}" && \
make -j5 && make install && make clean
COPY utils/dataset_manifest/requirements.txt /tmp/utils/dataset_manifest/requirements.txt
# Since we're using pip-compile-multi, each dependency can only be listed in
# one requirements file. In the case of PyAV, that should be
# `dataset_manifest/requirements.txt`. Make sure it's actually there,
# and then remove everything else.
RUN grep -q '^av==' /tmp/utils/dataset_manifest/requirements.txt
RUN sed -i '/^av==/!d' /tmp/utils/dataset_manifest/requirements.txt
# Work around https://github.com/PyAV-Org/PyAV/issues/1140
RUN pip install setuptools wheel 'cython<3'
RUN --mount=type=cache,target=/root/.cache/pip/http-v2 \
python3 -m pip wheel --no-binary=av --no-build-isolation \
-r /tmp/utils/dataset_manifest/requirements.txt \
-w /tmp/wheelhouse
# This stage builds wheels for all dependencies (except PyAV)
FROM build-image-base AS build-image
COPY cvat/requirements/ /tmp/cvat/requirements/
COPY utils/dataset_manifest/requirements.txt /tmp/utils/dataset_manifest/requirements.txt
# Exclude av from the requirements file
RUN sed -i '/^av==/d' /tmp/utils/dataset_manifest/requirements.txt
ARG CVAT_CONFIGURATION="production"
RUN --mount=type=cache,target=/root/.cache/pip/http-v2 \
DATUMARO_HEADLESS=1 python3 -m pip wheel --no-deps --no-binary lxml,xmlsec \
-r /tmp/cvat/requirements/${CVAT_CONFIGURATION}.txt \
-w /tmp/wheelhouse
FROM golang:1.24.4 AS build-smokescreen
RUN git clone --filter=blob:none --no-checkout https://github.com/stripe/smokescreen.git
RUN cd smokescreen && git checkout eb1ac09 && go build -o /tmp/smokescreen
FROM ${BASE_IMAGE}
ARG http_proxy
ARG https_proxy
ARG no_proxy
ARG socks_proxy
ARG TZ="Etc/UTC"
ENV TERM=xterm \
http_proxy=${http_proxy} \
https_proxy=${https_proxy} \
no_proxy=${no_proxy} \
socks_proxy=${socks_proxy} \
LANG='C.UTF-8' \
LC_ALL='C.UTF-8' \
TZ=${TZ}
ARG USER="django"
ARG CVAT_CONFIGURATION="production"
ENV DJANGO_SETTINGS_MODULE="cvat.settings.${CVAT_CONFIGURATION}"
# Install necessary apt packages
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get --no-install-recommends install -yq \
bzip2 \
ca-certificates \
curl \
git \
libgeos-c1v5 \
libgl1 \
libgomp1 \
libldap-2.5-0 \
libpython3.10 \
libsasl2-2 \
libxml2 \
libxmlsec1 \
libxmlsec1-openssl \
nginx \
p7zip-full \
poppler-utils \
python3 \
python3-venv \
supervisor \
tzdata \
unrar \
wait-for-it \
&& ln -fs /usr/share/zoneinfo/${TZ} /etc/localtime && \
dpkg-reconfigure -f noninteractive tzdata && \
rm -rf /var/lib/apt/lists/* && \
echo 'application/wasm wasm' >> /etc/mime.types
# Install smokescreen
COPY --from=build-smokescreen /tmp/smokescreen /usr/local/bin/smokescreen
# Add a non-root user
ENV USER=${USER}
ENV HOME /home/${USER}
RUN adduser --uid=1000 --shell /bin/bash --disabled-password --gecos "" ${USER}
ARG CLAM_AV="no"
RUN if [ "$CLAM_AV" = "yes" ]; then \
apt-get update && \
apt-get --no-install-recommends install -yq \
clamav \
libclamunrar9 && \
sed -i 's/ReceiveTimeout 30/ReceiveTimeout 300/g' /etc/clamav/freshclam.conf && \
freshclam && \
chown -R ${USER}:${USER} /var/lib/clamav && \
rm -rf /var/lib/apt/lists/*; \
fi
# Install wheels from the build image
RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:${PATH}"
# setuptools should be uninstalled after updating google-cloud-storage
# https://github.com/googleapis/python-storage/issues/740
RUN python -m pip install --upgrade setuptools
ARG PIP_VERSION
ARG PIP_DISABLE_PIP_VERSION_CHECK=1
RUN python -m pip install -U pip==${PIP_VERSION}
RUN --mount=type=bind,from=build-image,source=/tmp/wheelhouse,target=/mnt/wheelhouse \
--mount=type=bind,from=build-image-av,source=/tmp/wheelhouse,target=/mnt/wheelhouse-av \
python -m pip install --no-index /mnt/wheelhouse/*.whl /mnt/wheelhouse-av/*.whl
ENV NUMPROCS=1
COPY --from=build-image-av /opt/ffmpeg/lib /usr/lib
# These variables are required for supervisord substitutions in files
# This library allows remote python debugging with VS Code
ARG CVAT_DEBUG_ENABLED
RUN if [ "${CVAT_DEBUG_ENABLED}" = 'yes' ]; then \
python3 -m pip install --no-cache-dir debugpy; \
fi
# Removing pip due to security reasons. See: https://scout.docker.com/vulnerabilities/id/CVE-2018-20225
# The vulnerability is dubious and we don't use pip at runtime, but some vulnerability scanners mark it as a high vulnerability,
# and it was decided to remove pip from the final image
RUN python -m pip uninstall -y pip
# Install and initialize CVAT, copy all necessary files
COPY cvat/nginx.conf /etc/nginx/nginx.conf
COPY --chown=${USER} supervisord/ ${HOME}/supervisord
COPY --chown=${USER} backend_entrypoint.d/ ${HOME}/backend_entrypoint.d
COPY --chown=${USER} manage.py rqscheduler.py backend_entrypoint.sh wait_for_deps.sh ${HOME}/
COPY --chown=${USER} utils/ ${HOME}/utils
COPY --chown=${USER} cvat/ ${HOME}/cvat
ARG COVERAGE_PROCESS_START
RUN if [ "${COVERAGE_PROCESS_START}" ]; then \
echo "import coverage; coverage.process_startup()" > /opt/venv/lib/python3.10/site-packages/coverage_subprocess.pth; \
fi
# RUN all commands below as 'django' user.
# Use numeric UID/GID so that the image is compatible with the Kubernetes runAsNonRoot setting.
USER 1000:1000
WORKDIR ${HOME}
RUN mkdir -p data share keys logs /tmp/supervisord static
EXPOSE 8080
ENTRYPOINT ["./backend_entrypoint.sh"]

21
Dockerfile.ci Normal file
View File

@ -0,0 +1,21 @@
FROM cvat/server:local
ENV DJANGO_SETTINGS_MODULE=cvat.settings.testing
USER root
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get --no-install-recommends install -yq \
build-essential \
python3-dev \
&& \
rm -rf /var/lib/apt/lists/*;
COPY cvat/requirements/ /tmp/cvat/requirements/
COPY utils/dataset_manifest/requirements.txt /tmp/utils/dataset_manifest/requirements.txt
RUN python3 -m ensurepip
RUN DATUMARO_HEADLESS=1 python3 -m pip install --no-cache-dir -r /tmp/cvat/requirements/testing.txt
COPY .coveragerc .
ENTRYPOINT []

44
Dockerfile.ui Normal file
View File

@ -0,0 +1,44 @@
FROM node:lts-slim AS cvat-ui
ENV TERM=xterm \
LANG='C.UTF-8' \
LC_ALL='C.UTF-8'
# Install dependencies
RUN npm install -g corepack
COPY .yarnrc.yml /tmp/
COPY package.json /tmp/
COPY yarn.lock /tmp/
COPY cvat-core/package.json /tmp/cvat-core/
COPY cvat-canvas/package.json /tmp/cvat-canvas/
COPY cvat-canvas3d/package.json /tmp/cvat-canvas3d/
COPY cvat-ui/package.json /tmp/cvat-ui/
COPY cvat-data/package.json /tmp/cvat-data/
# Install common dependencies
WORKDIR /tmp/
RUN DISABLE_HUSKY=1 yarn install --immutable
# Build source code
COPY cvat-data/ /tmp/cvat-data/
COPY cvat-core/ /tmp/cvat-core/
COPY cvat-canvas3d/ /tmp/cvat-canvas3d/
COPY cvat-canvas/ /tmp/cvat-canvas/
COPY cvat-ui/ /tmp/cvat-ui/
ARG UI_APP_CONFIG
ARG CLIENT_PLUGINS
ARG DISABLE_SOURCE_MAPS
ARG SOURCE_MAPS_TOKEN
RUN CLIENT_PLUGINS="${CLIENT_PLUGINS}" \
DISABLE_SOURCE_MAPS="${DISABLE_SOURCE_MAPS}" \
UI_APP_CONFIG="${UI_APP_CONFIG}" \
SOURCE_MAPS_TOKEN="${SOURCE_MAPS_TOKEN}" yarn run build:cvat-ui
FROM nginxinc/nginx-unprivileged:1.28.0-alpine3.21-slim
# Replace default.conf configuration to remove unnecessary rules
COPY cvat-ui/react_nginx.conf /etc/nginx/conf.d/default.conf
COPY cvat-ui/robots.txt /usr/share/nginx/html/
COPY --from=cvat-ui /tmp/cvat-ui/dist /usr/share/nginx/html/

22
LICENSE Normal file
View File

@ -0,0 +1,22 @@
MIT License
Copyright (C) 2018-2022 Intel Corporation
Copyright (C) 2022-2025 CVAT.ai Corporation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

277
README.md Normal file
View File

@ -0,0 +1,277 @@
<p align="center">
<img src="/site/content/en/images/cvat-readme-gif.gif" alt="CVAT Platform" width="100%" max-width="800px">
</p>
<p align="center">
<a href="https://app.cvat.ai/">
<img src="/site/content/en/images/cvat-readme-button-tr-bg.png" alt="Start Annotating Now">
</a>
</p>
# Computer Vision Annotation Tool (CVAT)
[![CI][ci-img]][ci-url]
[![Gitter chat][gitter-img]][gitter-url]
[![Discord][discord-img]][discord-url]
[![Coverage Status][coverage-img]][coverage-url]
[![server pulls][docker-server-pulls-img]][docker-server-image-url]
[![ui pulls][docker-ui-pulls-img]][docker-ui-image-url]
[![DOI][doi-img]][doi-url]
[![Status][status-img]][status-url]
CVAT is an interactive video and image annotation
tool for computer vision. It is used by tens of thousands of users and
companies around the world. Our mission is to help developers, companies, and
organizations around the world to solve real problems using the Data-centric
AI approach.
Start using CVAT online: [cvat.ai](https://cvat.ai). You can use it for free,
or [subscribe](https://www.cvat.ai/pricing/cloud) to get unlimited data,
organizations, autoannotations, and [Roboflow and HuggingFace integration](https://www.cvat.ai/post/integrating-hugging-face-and-roboflow-models).
Or set CVAT up as a self-hosted solution:
[Self-hosted Installation Guide](https://docs.cvat.ai/docs/administration/basics/installation/).
We provide [Enterprise support](https://www.cvat.ai/pricing/on-prem) for
self-hosted installations with premium features: SSO, LDAP, Roboflow and
HuggingFace integrations, and advanced analytics (coming soon). We also
do trainings and a dedicated support with 24 hour SLA.
## Quick start ⚡
- [Installation guide](https://docs.cvat.ai/docs/administration/basics/installation/)
- [Manual](https://docs.cvat.ai/docs/manual/)
- [Contributing](https://docs.cvat.ai/docs/contributing/)
- [Datumaro dataset framework](https://github.com/cvat-ai/datumaro/blob/develop/README.md)
- [Server API](#api)
- [Python SDK](#sdk)
- [Command line tool](#cli)
- [XML annotation format](https://docs.cvat.ai/docs/manual/advanced/xml_format/)
- [AWS Deployment Guide](https://docs.cvat.ai/docs/administration/basics/aws-deployment-guide/)
- [Frequently asked questions](https://docs.cvat.ai/docs/faq/)
- [Where to ask questions](#where-to-ask-questions)
## Partners ❤️
CVAT is used by teams all over the world. In the list, you can find key companies which
help us support the product or an essential part of our ecosystem. If you use us,
please drop us a line at [contact@cvat.ai](mailto:contact+github@cvat.ai).
- [Human Protocol](https://hmt.ai) uses CVAT as a way of adding annotation service to the Human Protocol.
- [FiftyOne](https://fiftyone.ai) is an open-source dataset curation and model analysis
tool for visualizing, exploring, and improving computer vision datasets and models that are
[tightly integrated](https://voxel51.com/docs/fiftyone/integrations/cvat.html) with CVAT
for annotation and label refinement.
## Public datasets
[ATLANTIS](https://github.com/smhassanerfani/atlantis), an open-source dataset for semantic segmentation
of waterbody images, developed by [iWERS](http://ce.sc.edu/iwers/) group in the
Department of Civil and Environmental Engineering at the University of South Carolina is using CVAT.
For developing a semantic segmentation dataset using CVAT, see:
- [ATLANTIS published article](https://www.sciencedirect.com/science/article/pii/S1364815222000391)
- [ATLANTIS Development Kit](https://github.com/smhassanerfani/atlantis/tree/master/adk)
- [ATLANTIS annotation tutorial videos](https://www.youtube.com/playlist?list=PLIfLGY-zZChS5trt7Lc3MfNhab7OWl2BR).
## CVAT online: [cvat.ai](https://cvat.ai)
This is an online version of CVAT. It's free, efficient, and easy to use.
[cvat.ai](https://cvat.ai) runs the latest version of the tool. You can create up
to 10 tasks there and upload up to 500Mb of data to annotate. It will only be
visible to you or the people you assign to it.
For now, it does not have [analytics features](https://docs.cvat.ai/docs/administration/advanced/analytics/)
like management and monitoring the data annotation team. It also does not allow exporting images, just the annotations.
We plan to enhance [cvat.ai](https://cvat.ai) with new powerful features. Stay tuned!
## Prebuilt Docker images 🐳
Prebuilt docker images are the easiest way to start using CVAT locally. They are available on Docker Hub:
- [cvat/server](https://hub.docker.com/r/cvat/server)
- [cvat/ui](https://hub.docker.com/r/cvat/ui)
The images have been downloaded more than 1M times so far.
## Screencasts 🎦
Here are some screencasts showing how to use CVAT.
<!--lint disable maximum-line-length-->
[Computer Vision Annotation Course](https://www.youtube.com/playlist?list=PL0to7Ng4PuuYQT4eXlHb_oIlq_RPeuasN):
we introduce our course series designed to help you annotate data faster and better
using CVAT. This course is about CVAT deployment and integrations, it includes
presentations and covers the following topics:
- **Speeding up your data annotation process: introduction to CVAT and Datumaro**.
What problems do CVAT and Datumaro solve, and how they can speed up your model
training process. Some resources you can use to learn more about how to use them.
- **Deployment and use CVAT**. Use the app online at [app.cvat.ai](https://app.cvat.ai).
A local deployment. A containerized local deployment with Docker Compose (for regular use),
and a local cluster deployment with Kubernetes (for enterprise users). A 2-minute
tour of the interface, a breakdown of CVATs internals, and a demonstration of how
to deploy CVAT using Docker Compose.
[Product tour](https://www.youtube.com/playlist?list=PL0to7Ng4Puua37NJVMIShl_pzqJTigFzg): in this course, we show how to use CVAT, and help to get familiar with CVAT functionality and interfaces. This course does not cover integrations and is dedicated solely to CVAT. It covers the following topics:
- **Pipeline**. In this video, we show how to use [app.cvat.ai](https://app.cvat.ai): how to sign up, upload your data, annotate it, and download it.
<!--lint enable maximum-line-length-->
For feedback, please see [Contact us](#contact-us)
## API
- [Documentation](https://docs.cvat.ai/docs/api_sdk/api/)
## SDK
- Install with `pip install cvat-sdk`
- [PyPI package homepage](https://pypi.org/project/cvat-sdk/)
- [Documentation](https://docs.cvat.ai/docs/api_sdk/sdk/)
## CLI
- Install with `pip install cvat-cli`
- [PyPI package homepage](https://pypi.org/project/cvat-cli/)
- [Documentation](https://docs.cvat.ai/docs/api_sdk/cli/)
## Supported annotation formats
CVAT supports multiple annotation formats. You can select the format
after clicking the **Upload annotation** and **Dump annotation** buttons.
[Datumaro](https://github.com/cvat-ai/datumaro) dataset framework allows
additional dataset transformations with its command line tool and Python library.
For more information about the supported formats, see:
[Annotation Formats](https://docs.cvat.ai/docs/manual/advanced/formats/).
<!--lint disable maximum-line-length-->
| Annotation format | Import | Export |
| ------------------------------------------------------------------------------------------------ | ------ | ------ |
| [CVAT for images](https://docs.cvat.ai/docs/manual/advanced/xml_format/#annotation) | ✔️ | ✔️ |
| [CVAT for a video](https://docs.cvat.ai/docs/manual/advanced/xml_format/#interpolation) | ✔️ | ✔️ |
| [Datumaro](https://github.com/cvat-ai/datumaro) | ✔️ | ✔️ |
| [PASCAL VOC](http://host.robots.ox.ac.uk/pascal/VOC/) | ✔️ | ✔️ |
| Segmentation masks from [PASCAL VOC](http://host.robots.ox.ac.uk/pascal/VOC/) | ✔️ | ✔️ |
| [YOLO](https://pjreddie.com/darknet/yolo/) | ✔️ | ✔️ |
| [MS COCO Object Detection](http://cocodataset.org/#format-data) | ✔️ | ✔️ |
| [MS COCO Keypoints Detection](http://cocodataset.org/#format-data) | ✔️ | ✔️ |
| [MOT](https://motchallenge.net/) | ✔️ | ✔️ |
| [MOTS PNG](https://www.vision.rwth-aachen.de/page/mots) | ✔️ | ✔️ |
| [LabelMe 3.0](http://labelme.csail.mit.edu/Release3.0) | ✔️ | ✔️ |
| [ImageNet](http://www.image-net.org) | ✔️ | ✔️ |
| [CamVid](http://mi.eng.cam.ac.uk/research/projects/VideoRec/CamVid/) | ✔️ | ✔️ |
| [WIDER Face](http://shuoyang1213.me/WIDERFACE/) | ✔️ | ✔️ |
| [VGGFace2](https://github.com/ox-vgg/vgg_face2) | ✔️ | ✔️ |
| [Market-1501](https://www.aitribune.com/dataset/2018051063) | ✔️ | ✔️ |
| [ICDAR13/15](https://rrc.cvc.uab.es/?ch=2) | ✔️ | ✔️ |
| [Open Images V6](https://storage.googleapis.com/openimages/web/index.html) | ✔️ | ✔️ |
| [Cityscapes](https://www.cityscapes-dataset.com/login/) | ✔️ | ✔️ |
| [KITTI](http://www.cvlibs.net/datasets/kitti/) | ✔️ | ✔️ |
| [Kitti Raw Format](https://www.cvlibs.net/datasets/kitti/raw_data.php) | ✔️ | ✔️ |
| [LFW](http://vis-www.cs.umass.edu/lfw/) | ✔️ | ✔️ |
| [Supervisely Point Cloud Format](https://docs.supervise.ly/data-organization/00_ann_format_navi) | ✔️ | ✔️ |
| [Ultralytics YOLO Detection](https://docs.ultralytics.com/datasets/detect/) | ✔️ | ✔️ |
| [Ultralytics YOLO Oriented Bounding Boxes](https://docs.ultralytics.com/datasets/obb/) | ✔️ | ✔️ |
| [Ultralytics YOLO Segmentation](https://docs.ultralytics.com/datasets/segment/) | ✔️ | ✔️ |
| [Ultralytics YOLO Pose](https://docs.ultralytics.com/datasets/pose/) | ✔️ | ✔️ |
| [Ultralytics YOLO Classification](https://docs.ultralytics.com/datasets/classify/) | ✔️ | ✔️ |
<!--lint enable maximum-line-length-->
## Deep learning serverless functions for automatic labeling
CVAT supports automatic labeling. It can speed up the annotation process
up to 10x. Here is a list of the algorithms we support, and the platforms they can be run on:
<!--lint disable maximum-line-length-->
| Name | Type | Framework | CPU | GPU |
| ------------------------------------------------------------------------------------------------------- | ---------- | ---------- | --- | --- |
| [Segment Anything](/serverless/pytorch/facebookresearch/sam/nuclio/) | interactor | PyTorch | ✔️ | ✔️ |
| [Deep Extreme Cut](/serverless/openvino/dextr/nuclio) | interactor | OpenVINO | ✔️ | |
| [Faster RCNN](/serverless/openvino/omz/public/faster_rcnn_inception_resnet_v2_atrous_coco/nuclio) | detector | OpenVINO | ✔️ | |
| [Mask RCNN](/serverless/openvino/omz/public/mask_rcnn_inception_resnet_v2_atrous_coco/nuclio) | detector | OpenVINO | ✔️ | |
| [YOLO v3](/serverless/openvino/omz/public/yolo-v3-tf/nuclio) | detector | OpenVINO | ✔️ | |
| [YOLO v7](/serverless/onnx/WongKinYiu/yolov7/nuclio) | detector | ONNX | ✔️ | ✔️ |
| [Object reidentification](/serverless/openvino/omz/intel/person-reidentification-retail-0277/nuclio) | reid | OpenVINO | ✔️ | |
| [Semantic segmentation for ADAS](/serverless/openvino/omz/intel/semantic-segmentation-adas-0001/nuclio) | detector | OpenVINO | ✔️ | |
| [Text detection v4](/serverless/openvino/omz/intel/text-detection-0004/nuclio) | detector | OpenVINO | ✔️ | |
| [SiamMask](/serverless/pytorch/foolwood/siammask/nuclio) | tracker | PyTorch | ✔️ | ✔️ |
| [TransT](/serverless/pytorch/dschoerk/transt/nuclio) | tracker | PyTorch | ✔️ | ✔️ |
| [Inside-Outside Guidance](/serverless/pytorch/shiyinzhang/iog/nuclio) | interactor | PyTorch | ✔️ | |
| [Faster RCNN](/serverless/tensorflow/faster_rcnn_inception_v2_coco/nuclio) | detector | TensorFlow | ✔️ | ✔️ |
| [RetinaNet](serverless/pytorch/facebookresearch/detectron2/retinanet_r101/nuclio) | detector | PyTorch | ✔️ | ✔️ |
| [Face Detection](/serverless/openvino/omz/intel/face-detection-0205/nuclio) | detector | OpenVINO | ✔️ | |
<!--lint enable maximum-line-length-->
## License
The code is released under the [MIT License](https://opensource.org/licenses/MIT).
The code contained within the `/serverless` directory is released under the **MIT License**.
However, it may download and utilize various assets, such as source code, architectures, and weights, among others.
These assets may be distributed under different licenses, including non-commercial licenses.
It is your responsibility to ensure compliance with the terms of these licenses before using the assets.
This software uses LGPL-licensed libraries from the [FFmpeg](https://www.ffmpeg.org) project.
The exact steps on how FFmpeg was configured and compiled can be found in the [Dockerfile](Dockerfile).
FFmpeg is an open-source framework licensed under LGPL and GPL.
See [https://www.ffmpeg.org/legal.html](https://www.ffmpeg.org/legal.html). You are solely responsible
for determining if your use of FFmpeg requires any
additional licenses. CVAT.ai Corporation is not responsible for obtaining any
such licenses, nor liable for any licensing fees due in
connection with your use of FFmpeg.
## Contact us
[Gitter](https://gitter.im/opencv-cvat/public) to ask CVAT usage-related questions.
Typically questions get answered fast by the core team or community. There you can also browse other common questions.
[Discord](https://discord.gg/S6sRHhuQ7K) is the place to also ask questions or discuss any other stuff related to CVAT.
[LinkedIn](https://www.linkedin.com/company/cvat-ai/) for the company and work-related questions.
[YouTube](https://www.youtube.com/@cvat-ai) to see screencast and tutorials about the CVAT.
[GitHub issues](https://github.com/cvat-ai/cvat/issues) for feature requests or bug reports.
If it's a bug, please add the steps to reproduce it.
[#cvat](https://stackoverflow.com/search?q=%23cvat) tag on StackOverflow is one more way to ask
questions and get our support.
[Use our website](https://www.cvat.ai/contact-us/enterprise) to reach out to us if you need commercial support.
## Links
- [Intel AI blog: New Computer Vision Tool Accelerates Annotation of Digital Images and Video](https://www.intel.ai/introducing-cvat)
- [Intel Software: Computer Vision Annotation Tool: A Universal Approach to Data Annotation](https://software.intel.com/en-us/articles/computer-vision-annotation-tool-a-universal-approach-to-data-annotation)
- [VentureBeat: Intel open-sources CVAT, a toolkit for data labeling](https://venturebeat.com/2019/03/05/intel-open-sources-cvat-a-toolkit-for-data-labeling/)
- [How to Use CVAT (Roboflow guide)](https://blog.roboflow.com/cvat/)
- [How to auto-label data in CVAT with one of 50,000+ models on Roboflow Universe](https://blog.roboflow.com/how-to-use-roboflow-models-in-cvat/)
<!-- Badges -->
[docker-server-pulls-img]: https://img.shields.io/docker/pulls/cvat/server.svg?style=flat-square&label=server%20pulls
[docker-server-image-url]: https://hub.docker.com/r/cvat/server
[docker-ui-pulls-img]: https://img.shields.io/docker/pulls/cvat/ui.svg?style=flat-square&label=UI%20pulls
[docker-ui-image-url]: https://hub.docker.com/r/cvat/ui
[ci-img]: https://github.com/cvat-ai/cvat/actions/workflows/main.yml/badge.svg?branch=develop
[ci-url]: https://github.com/cvat-ai/cvat/actions
[gitter-img]: https://img.shields.io/gitter/room/opencv-cvat/public?style=flat
[gitter-url]: https://gitter.im/opencv-cvat/public
[coverage-img]: https://codecov.io/github/cvat-ai/cvat/branch/develop/graph/badge.svg
[coverage-url]: https://codecov.io/github/cvat-ai/cvat
[doi-img]: https://zenodo.org/badge/139156354.svg
[doi-url]: https://zenodo.org/badge/latestdoi/139156354
[discord-img]: https://img.shields.io/discord/1000789942802337834?label=discord
[discord-url]: https://discord.gg/fNR3eXfk6C
[status-img]: https://uptime.betterstack.com/status-badges/v2/monitor/1yl3h.svg
[status-url]: https://status.cvat.ai

19
SECURITY.md Normal file
View File

@ -0,0 +1,19 @@
# Security Policy
## Supported Versions
At the moment only the latest release is supported. When you report a security issue,
be sure it can be reproduced in the supported version.
## Reporting a Vulnerability
If you have information about a security issue or vulnerability in the product, please
send an e-mail to [secure@cvat.ai](mailto:secure+github@cvat.ai).
Please provide as much information as possible, including:
- The products and versions affected
- Detailed description of the vulnerability
- Information on known exploits
- A member of the CVAT.ai Product Security Team will review your e-mail and contact you to
collaborate on resolving the issue.

58
_typos.toml Normal file
View File

@ -0,0 +1,58 @@
files.extend-exclude = [
"cvat-ui/src/assets/opencv_4.8.0.js",
"cvat/apps/engine/migrations/0033_projects_adjastment.py", # legacy migration name, set for life
"cvat-data/src/ts/3rdparty/*.js",
"site/themes/docsy/",
"*.log",
"*.min.js",
"*.patch",
]
default.extend-ignore-re = [
# test passwords
"\"md5.*?\"",
# Line ignore with trailing "# spellchecker:disable-line"
# or trailing "// spellchecker:disable-line"
"(?Rm)^.*(#|//)\\s*spellchecker:disable-line$",
# Line block with "# spellchecker:<on|off>"
# or "// spellchecker:<on|off>"
"(?s)(#|//)\\s*spellchecker:off.*?\\n\\s*(#|//)\\s*spellchecker:on"
# Taken from https://github.com/crate-ci/typos/blob/master/docs/reference.md#example-configurations
]
[default.extend-words]
AVOD = "AVOD"
NAM = "NAM"
adjastment = "adjustment"
analitics = "analytics"
blok = "block"
cheeck = "check"
clicable = "clickable"
concatening = "concatenating"
configurate = "configure"
convertation = "conversion"
convertor = "converter"
correciton = "correction"
cubiod = "cuboid"
cuboiud = "cuboid"
cuccessfully = "successfully"
debounding = "debouncing"
firts = "first"
inconvinient = "inconvenient"
keupoints = "keypoints"
leafs = "leafs"
nd = "nd"
occurreed = "occurred"
onject = ""
perameters = "parameters"
roration = "rotation"
segway = "segway"
splitted = "splitted"
succefull = "successful"
tesk = "task"
tpic = "tpic"
trak = "trak"
typ = "type"
tupe = ""

11
ai-models/README.md Normal file
View File

@ -0,0 +1,11 @@
This directory contains various auto-annotation function implementations
for use with the CVAT SDK and CLI.
These functions use popular deep learning models to perform various computer vision tasks.
Consult the [Auto-annotation API reference][apiref] for general information about AA functions.
[apiref]: https://docs.cvat.ai/docs/api_sdk/sdk/auto-annotation/
For information on each AA function, see the `README.md` file in its directory.
Each function also has a `requirements.txt` file describing Python packages
that you must install in order to use it.

View File

@ -0,0 +1,50 @@
# Ultralytics YOLO
This is an implementation of a CVAT auto-annotation function that uses models from the YOLO
family, as implemented in the Ultralytics library.
> WARNING: While the function code is provided under the MIT license, the underlying Ultralytics
> library has a different licensing model. Make sure to familiarize yourself with the terms at
> <https://www.ultralytics.com/license> before using this function.
This AA function supports all numbered YOLO models implemented by the Ultralytics library,
starting with YOLOv3. At the time of this writing, the most recent such model was YOLO12;
however, future models should also work, provided that the API remains the same.
Zero-shot models, such as YOLO-World and YOLOE, are not supported.
The AA function supports models solving the following tasks:
- classification
- instance segmentation
- object detection
- oriented object detection
- pose estimation
To use this with CVAT CLI, use the following options:
```
--function-file func.py -p model=str:<model>
```
where `<model>` is the path to a pretrained model file, such as `yolo12n.pt`. If the file does
not exist, but its name matches one of the pretrained models available in the library,
that model will be automatically downloaded and used.
See the documentation at <https://docs.ultralytics.com/models/> for information on available
pretrained models.
This function also supports the following options:
- `-p device=str:<device>` - the PyTorch device, such as `cuda`, on which to run the model.
By default, `cpu` is used.
- `-p keypoint_names_path=str:<path>` - path to a file with names of keypoints.
Only valid for pose estimation models.
By default, the 17 keypoint names from the COCO dataset (`nose`, `left_eye`, `right_eye`, etc.)
will be used.
Ultralytics model files don't contain keypoint names, so you will likely need to set
this option if your pose estimation model was trained on a custom dataset.
The `<path>` must point to a text file, with one keypoint name per line. Leading and trailing
whitespace will be ignored, and so will empty lines.

View File

@ -0,0 +1,194 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import abc
import math
from collections.abc import Iterable
from typing import ClassVar, Optional
import cvat_sdk.auto_annotation as cvataa
import cvat_sdk.models as models
import PIL.Image
from ultralytics import YOLO
from ultralytics.engine.results import Results
class YoloFunction(abc.ABC):
def __init__(self, model: YOLO, device: str) -> None:
self._model = model
self._device = device
self.spec = cvataa.DetectionFunctionSpec(
labels=[self._label_spec(name, id) for id, name in self._model.names.items()],
)
@abc.abstractmethod
def _label_spec(self, name: str, id_: int) -> models.PatchedLabelRequest: ...
class YoloFunctionWithSimpleLabel(YoloFunction):
LABEL_TYPE: ClassVar[str]
def _label_spec(self, name: str, id_: int) -> models.PatchedLabelRequest:
return cvataa.label_spec(name, id_, type=self.LABEL_TYPE)
class YoloClassificationFunction(YoloFunctionWithSimpleLabel):
LABEL_TYPE = "tag"
def detect(
self, context: cvataa.DetectionFunctionContext, image: PIL.Image.Image
) -> list[cvataa.DetectionAnnotation]:
# Unlike the other models, the `predict` method of the classification models does not
# take a confidence threshold. Therefore, we apply one manually.
# We also use 0 as the default threshold on the assumption that by default the user
# wants to get exactly one tag per image.
conf_threshold = context.conf_threshold or 0.0
return [
cvataa.tag(results.probs.top1)
for results in self._model.predict(source=image, device=self._device, verbose=False)
if results.probs.top1conf >= conf_threshold
]
class YoloFunctionWithShapes(YoloFunction):
def detect(
self, context: cvataa.DetectionFunctionContext, image: PIL.Image.Image
) -> list[cvataa.DetectionAnnotation]:
kwargs = {}
if context.conf_threshold is not None:
kwargs["conf"] = context.conf_threshold
return [
annotation
for results in self._model.predict(
source=image, device=self._device, verbose=False, **kwargs
)
if len(results) > 0
for annotation in self._annotations_from_results(results)
]
@abc.abstractmethod
def _annotations_from_results(
self, results: Results
) -> Iterable[cvataa.DetectionAnnotation]: ...
class YoloDetectionFunction(YoloFunctionWithSimpleLabel, YoloFunctionWithShapes):
LABEL_TYPE = "rectangle"
def _annotations_from_results(self, results: Results) -> Iterable[cvataa.DetectionAnnotation]:
return (
cvataa.rectangle(int(label.item()), points.tolist())
for label, points in zip(results.boxes.cls, results.boxes.xyxy)
)
class YoloOrientedDetectionFunction(YoloFunctionWithSimpleLabel, YoloFunctionWithShapes):
LABEL_TYPE = "rectangle"
def _annotations_from_results(self, results: Results) -> Iterable[cvataa.DetectionAnnotation]:
return (
cvataa.rectangle(
int(label.item()),
[x - 0.5 * w, y - 0.5 * h, x + 0.5 * w, y + 0.5 * h],
rotation=math.degrees(r),
)
for label, xywhr in zip(results.obb.cls, results.obb.xywhr)
for x, y, w, h, r in [xywhr.tolist()]
)
DEFAULT_KEYPOINT_NAMES = [
# The keypoint names are not recorded in the model file, so we have to ask the user to
# supply them separately (see the keypoint_names_path option).
# But to make using the default models easier, we hardcode the usual COCO keypoint names.
"nose",
"left_eye",
"right_eye",
"left_ear",
"right_ear",
"left_shoulder",
"right_shoulder",
"left_elbow",
"right_elbow",
"left_wrist",
"right_wrist",
"left_hip",
"right_hip",
"left_knee",
"right_knee",
"left_ankle",
"right_ankle",
]
class YoloPoseEstimationFunction(YoloFunctionWithShapes):
def __init__(
self, model: YOLO, device: str, *, keypoint_names_path: Optional[str] = None
) -> None:
if keypoint_names_path is None:
self._keypoint_names = DEFAULT_KEYPOINT_NAMES
else:
self._keypoint_names = self._load_names(keypoint_names_path)
super().__init__(model, device)
def _load_names(self, path: str) -> list[str]:
with open(path, "r") as f:
return [
stripped_line
for line in f.readlines()
for stripped_line in [line.strip()]
if stripped_line
]
def _label_spec(self, name: str, id_: int) -> models.PatchedLabelRequest:
return cvataa.skeleton_label_spec(
name,
id_,
[
cvataa.keypoint_spec(kp_name, kp_id)
for kp_id, kp_name in enumerate(self._keypoint_names)
],
)
def _annotations_from_results(self, results: Results) -> Iterable[cvataa.DetectionAnnotation]:
return (
cvataa.skeleton(
int(label.item()),
[
cvataa.keypoint(kp_index, kp.tolist(), outside=kp_conf.item() < 0.5)
for kp_index, (kp, kp_conf) in enumerate(zip(kps, kp_confs))
],
)
for label, kps, kp_confs in zip(
results.boxes.cls, results.keypoints.xy, results.keypoints.conf
)
)
class YoloSegmentationFunction(YoloFunctionWithSimpleLabel, YoloFunctionWithShapes):
LABEL_TYPE = "polygon"
def _annotations_from_results(self, results: Results) -> Iterable[cvataa.DetectionAnnotation]:
return (
cvataa.polygon(int(label.item()), [c for p in poly_points.tolist() for c in p])
for label, poly_points in zip(results.boxes.cls, results.masks.xy)
)
FUNCTION_CLASS_BY_TASK = {
"classify": YoloClassificationFunction,
"detect": YoloDetectionFunction,
"pose": YoloPoseEstimationFunction,
"obb": YoloOrientedDetectionFunction,
"segment": YoloSegmentationFunction,
}
def create(model: str, **kwargs) -> cvataa.DetectionFunction:
model = YOLO(model=model, verbose=False)
return FUNCTION_CLASS_BY_TASK[model.task](model, **kwargs)

View File

@ -0,0 +1,6 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
cvat_sdk>=2.44
ultralytics

View File

@ -0,0 +1,23 @@
# SAM2 tracker
This directory contains an implementation of a CVAT auto-annotation function
that tracks masks and polygons using the [Segment Anything Model 2][sam2] (SAM2)
from Meta Research.
[sam2]: https://github.com/facebookresearch/sam2
To use this with CVAT CLI, use the following options:
```
--function-file func.py -p model_id=str:<model_id>
```
where `<model_id>` is one of the [SAM2 model IDs][sam2-hf] from Meta's Hugging Face account,
such as `facebook/sam2.1-hiera-tiny` or `facebook/sam2.1-hiera-large`.
[sam2-hf]: https://huggingface.co/models?search=facebook%2Fsam2
In addition, you can add `-p device=str:<device>` to run the model on a specific PyTorch device,
such as `cuda`. By default, the model will be run on the CPU.
All other parameters set with the `-p` option will be passed directly to the model constructor.

View File

@ -0,0 +1,225 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import collections
import dataclasses
from typing import Optional, TypedDict
import cv2
import cvat_sdk.auto_annotation as cvataa
import numpy as np
import PIL.Image
import torch
import torchvision.transforms
from cvat_sdk.masks import decode_mask, encode_mask
from sam2.sam2_video_predictor import SAM2VideoPredictor
from sam2.utils.misc import fill_holes_in_mask_scores
@dataclasses.dataclass(frozen=True, kw_only=True)
class _PreprocessedImage:
original_width: int
original_height: int
vision_feats: list[torch.Tensor]
vision_pos_embeds: list[torch.Tensor]
feat_sizes: list[tuple[int, int]]
class _PredictorOutputs(TypedDict):
# We always keep 1 cond_frame_outputs and up to num_maskmem non_cond_frame_outputs.
cond_frame_outputs: dict[int, dict]
# We make this an OrderedDict to make popping old elements easier.
non_cond_frame_outputs: collections.OrderedDict[int, dict]
@dataclasses.dataclass(kw_only=True)
class _TrackingState:
frame_idx: int
predictor_outputs: _PredictorOutputs
class _Sam2Tracker:
def __init__(self, model_id: str, device: str = "cpu", **kwargs) -> None:
self._device = torch.device(device)
if self._device.type == "cuda":
torch.set_autocast_enabled(True)
torch.set_autocast_gpu_dtype(torch.bfloat16)
if torch.cuda.get_device_properties(self._device).major >= 8:
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
self._predictor = SAM2VideoPredictor.from_pretrained(
model_id, device=self._device, **kwargs
)
self._transform = torchvision.transforms.Compose(
[
torchvision.transforms.Resize(
(self._predictor.image_size, self._predictor.image_size)
),
torchvision.transforms.ToTensor(),
torchvision.transforms.Normalize(
# see load_video_frames in the SAM2 source
mean=(0.485, 0.456, 0.406),
std=(0.229, 0.224, 0.225),
),
]
)
spec = cvataa.TrackingFunctionSpec(supported_shape_types=["mask", "polygon"])
@torch.inference_mode()
def preprocess_image(
self, context: cvataa.TrackingFunctionContext, image: PIL.Image.Image
) -> _PreprocessedImage:
image = image.convert("RGB")
image_tensor = self._transform(image).unsqueeze(0).to(device=self._device)
backbone_out = self._predictor.forward_image(image_tensor)
vision_feats = backbone_out["backbone_fpn"][-self._predictor.num_feature_levels :]
vision_pos_embeds = backbone_out["vision_pos_enc"][-self._predictor.num_feature_levels :]
return _PreprocessedImage(
original_width=image.width,
original_height=image.height,
vision_feats=[x.flatten(2).permute(2, 0, 1) for x in vision_feats],
vision_pos_embeds=[x.flatten(2).permute(2, 0, 1) for x in vision_pos_embeds],
feat_sizes=[(x.shape[-2], x.shape[-1]) for x in vision_pos_embeds],
)
def _call_predictor(self, *, pp_image: _PreprocessedImage, frame_idx: int, **kwargs) -> dict:
out = self._predictor.track_step(
current_vision_feats=pp_image.vision_feats,
current_vision_pos_embeds=pp_image.vision_pos_embeds,
feat_sizes=pp_image.feat_sizes,
point_inputs=None,
frame_idx=frame_idx,
num_frames=frame_idx + 1,
**kwargs,
)
return {
"maskmem_features": out["maskmem_features"],
"maskmem_pos_enc": out["maskmem_pos_enc"][-1:],
"pred_masks": fill_holes_in_mask_scores(
out["pred_masks"], self._predictor.fill_hole_area
),
"obj_ptr": out["obj_ptr"],
}
def _shape_to_mask(
self, pp_image: _PreprocessedImage, shape: cvataa.TrackableShape
) -> np.ndarray:
if shape.type == "mask":
return decode_mask(
shape.points,
image_width=pp_image.original_width,
image_height=pp_image.original_height,
)
if shape.type == "polygon":
mask = np.zeros((pp_image.original_height, pp_image.original_width), dtype=np.uint8)
points_array = np.array(shape.points, dtype=np.int32).reshape((-1, 2))
cv2.fillPoly(mask, [points_array], 1)
return mask
assert False, f"unexpected shape type {shape.type!r}"
@torch.inference_mode()
def init_tracking_state(
self,
context: cvataa.TrackingFunctionShapeContext,
pp_image: _PreprocessedImage,
shape: cvataa.TrackableShape,
) -> _TrackingState:
mask = torch.from_numpy(self._shape_to_mask(pp_image, shape))
resized_mask = torch.nn.functional.interpolate(
mask.float()[None, None], # add batch and channel dimensions
(self._predictor.image_size, self._predictor.image_size),
mode="bilinear",
align_corners=False,
)
resized_mask = (resized_mask >= 0.5).float().to(device=self._device)
current_out = self._call_predictor(
pp_image=pp_image,
frame_idx=0,
is_init_cond_frame=True,
mask_inputs=resized_mask,
output_dict={},
)
return _TrackingState(
frame_idx=0,
predictor_outputs={
"cond_frame_outputs": {0: current_out},
"non_cond_frame_outputs": collections.OrderedDict(),
},
)
def _mask_to_shape(
self, context: cvataa.TrackingFunctionShapeContext, mask: torch.Tensor
) -> Optional[cvataa.TrackableShape]:
if context.original_shape_type == "mask":
return cvataa.TrackableShape(type="mask", points=encode_mask(mask))
if context.original_shape_type == "polygon":
mask_np = np.asarray(mask, dtype=np.uint8)
contours, _ = cv2.findContours(mask_np, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
largest_contour = max(contours, key=cv2.contourArea)
approx_contour = cv2.approxPolyDP(largest_contour, epsilon=1.0, closed=True)
if approx_contour.shape[0] < 3:
return None
return cvataa.TrackableShape(type="polygon", points=approx_contour.flatten().tolist())
assert False, f"unexpected shape type {context.original_shape_type!r}"
@torch.inference_mode()
def track(
self,
context: cvataa.TrackingFunctionShapeContext,
pp_image: _PreprocessedImage,
state: _TrackingState,
) -> Optional[cvataa.TrackableShape]:
state.frame_idx += 1
current_out = self._call_predictor(
pp_image=pp_image,
frame_idx=state.frame_idx,
is_init_cond_frame=False,
mask_inputs=None,
output_dict=state.predictor_outputs,
)
non_cond_frame_outputs = state.predictor_outputs["non_cond_frame_outputs"]
non_cond_frame_outputs[state.frame_idx] = current_out
# discard old outputs as the predictor uses up to num_maskmem elements
while len(non_cond_frame_outputs) > self._predictor.num_maskmem:
non_cond_frame_outputs.popitem(last=False)
output_mask = (
torch.nn.functional.interpolate(
current_out["pred_masks"],
size=(pp_image.original_height, pp_image.original_width),
align_corners=False,
mode="bilinear",
antialias=True,
)[0, 0]
> 0
)
if output_mask.any():
return self._mask_to_shape(context, output_mask.cpu())
else:
return None
create = _Sam2Tracker

View File

@ -0,0 +1,8 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
cvat_sdk>=2.42
huggingface_hub
opencv-python-headless
sam-2 @ git+https://github.com/facebookresearch/sam2.git@2b90b9f5ceec907a1c18123530e92e794ad901a4

View File

@ -0,0 +1,12 @@
(
["annotation"]="smokescreen"
["chunks"]="smokescreen"
["consensus"]=""
["export"]="smokescreen"
["import"]="smokescreen clamav"
["quality_reports"]=""
["cleaning"]="rqscheduler"
["webhooks"]="smokescreen"
["notifications"]=""
["server"]="smokescreen clamav"
)

138
backend_entrypoint.sh Executable file
View File

@ -0,0 +1,138 @@
#!/usr/bin/env bash
set -eu
fail() {
printf >&2 "%s: %s\n" "$0" "$1"
exit 1
}
wait_for_db() {
wait-for-it "${CVAT_POSTGRES_HOST}:${CVAT_POSTGRES_PORT:-5432}" -t 0
}
wait_for_redis_inmem() {
wait-for-it "${CVAT_REDIS_INMEM_HOST}:${CVAT_REDIS_INMEM_PORT:-6379}" -t 0
}
cmd_bash() {
exec bash "$@"
}
cmd_init() {
wait_for_db
~/manage.py migrate
wait_for_redis_inmem
~/manage.py migrateredis
~/manage.py syncperiodicjobs
}
_get_includes() {
declare -A merged_config
for config in ~/backend_entrypoint.d/*.conf; do
declare -A config=$(cat $config)
for key in "${!config[@]}"; do
if [ -v merged_config[$key] ]; then
fail "Duplicated component definition: $key"
fi
merged_config[$key]=${config[$key]}
done
done
extra_configs=()
for component in "$@"; do
if ! [ -v merged_config[$component] ]; then
fail "Unexpected worker: $component"
fi
for include in ${merged_config["$component"]}; do
if ! [[ ${extra_configs[@]} =~ $include ]] && \
( ! [[ "$include" == "clamav" ]] || [[ "${CLAM_AV:-}" == "yes" ]] ); then
extra_configs+=("$include")
fi
done
done
if [ ${#extra_configs[@]} -gt 0 ]; then
printf 'reusable/%s.conf ' "${extra_configs[@]}"
fi
}
cmd_run() {
if [ "$#" -eq 0 ]; then
fail "run: at least 1 argument is expected"
fi
component="$1"
if [ "$component" = "server" ]; then
~/manage.py collectstatic --no-input
fi
wait_for_db
echo "waiting for migrations to complete..."
while ! ~/manage.py migrate --check; do
sleep 10
done
wait_for_redis_inmem
echo "waiting for Redis migrations to complete..."
while ! ~/manage.py migrateredis --check; do
sleep 10
done
supervisord_includes=""
postgres_app_name="cvat:$component"
if [ "$component" = "server" ]; then
supervisord_includes=$(_get_includes "$component")
elif [ "$component" = "worker" ]; then
if [ "$#" -eq 1 ]; then
fail "run worker: expected at least 1 queue name"
fi
queue_list="${@:2}"
echo "Workers to run: $queue_list"
export CVAT_QUEUES=$queue_list
postgres_app_name+=":${queue_list// /+}"
supervisord_includes=$(_get_includes $queue_list)
fi
echo "Additional supervisor configs that will be included: $supervisord_includes"
export CVAT_POSTGRES_APPLICATION_NAME=$postgres_app_name
export CVAT_SUPERVISORD_INCLUDES=$supervisord_includes
exec supervisord -c "supervisord/$component.conf"
}
if [ $# -eq 0 ]; then
echo >&2 "$0: at least one subcommand required"
echo >&2 ""
echo >&2 "available subcommands:"
echo >&2 " bash <bash args...>"
echo >&2 " init"
echo >&2 " run server"
echo >&2 " run worker <list of queues>"
exit 1
fi
for init_script in /etc/cvat/init.d/*; do
if [ -r "$init_script" ]; then
. "$init_script"
fi
done
while [ $# -ne 0 ]; do
if [ "$(type -t "cmd_$1")" != "function" ]; then
fail "unknown subcommand: $1"
fi
cmd_name="$1"
shift
"cmd_$cmd_name" "$@"
done

4
changelog.d/fragment.j2 Normal file
View File

@ -0,0 +1,4 @@
### {{ config.categories | join('|') }} <!-- pick one -->
- Describe your change here...
(<https://github.com/cvat-ai/cvat/pull/XXXX>)

6
changelog.d/scriv.ini Normal file
View File

@ -0,0 +1,6 @@
[scriv]
categories = Added, Changed, Deprecated, Removed, Fixed, Security
entry_title_template = \[{{ version }}\] - {{ date.strftime('%%Y-%%m-%%d') }}
format = md
md_header_level = 2
new_fragment_template = file: fragment.j2

View File

@ -0,0 +1,32 @@
#!/bin/bash
CLICKHOUSE_DB="${CLICKHOUSE_DB:-cvat}";
clickhouse-client --query "CREATE DATABASE IF NOT EXISTS ${CLICKHOUSE_DB};"
echo "
CREATE TABLE IF NOT EXISTS ${CLICKHOUSE_DB}.events
(
\`scope\` String NOT NULL,
\`obj_name\` String NULL,
\`obj_id\` UInt64 NULL,
\`obj_val\` String NULL,
\`source\` String NOT NULL,
\`timestamp\` DateTime64(3, 'Etc/UTC') NOT NULL,
\`count\` UInt16 NULL,
\`duration\` UInt32 DEFAULT toUInt32(0),
\`project_id\` UInt64 NULL,
\`task_id\` UInt64 NULL,
\`job_id\` UInt64 NULL,
\`user_id\` UInt64 NULL,
\`user_name\` String NULL,
\`user_email\` String NULL,
\`org_id\` UInt64 NULL,
\`org_slug\` String NULL,
\`payload\` String NULL
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(timestamp)
ORDER BY (timestamp)
SETTINGS index_granularity = 8192
;" | clickhouse-client

View File

@ -0,0 +1,760 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"target": {
"limit": 100,
"matchAny": false,
"tags": [],
"type": "dashboard"
},
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "continuous-GrYlRd"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "bars",
"fillOpacity": 90,
"gradientMode": "scheme",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 24,
"x": 0,
"y": 0
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"builderOptions": {
"fields": [],
"filters": [],
"metrics": [
{
"aggregation": "count",
"field": "*"
}
],
"mode": "trend",
"orderBy": [],
"table": "events",
"timeField": "timestamp",
"timeFieldType": "DateTime64(3, 'Etc/UTC')"
},
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"format": 0,
"meta": {
"builderOptions": {
"fields": [],
"filters": [],
"metrics": [
{
"aggregation": "count",
"field": "*"
}
],
"mode": "trend",
"orderBy": [],
"table": "events",
"timeField": "timestamp",
"timeFieldType": "DateTime64(3, 'Etc/UTC')"
}
},
"queryType": "sql",
"rawSql": "SELECT $__timeInterval(timestamp) as time, count(*)\r\nFROM events\r\nWHERE $__timeFilter(timestamp)\r\nAND scope IN (${scopes})\r\nAND source IN (${sources})\r\nAND (-1 IN (${users}) OR user_id IN (${users}))\r\nAND (-1 IN (${organizations}) OR org_id IN (${organizations}))\r\nAND (-1 IN (${projects}) OR project_id IN (${projects}))\r\nAND (-1 IN (${tasks}) OR task_id IN (${tasks}))\r\nAND (-1 IN (${jobs}) OR job_id IN (${jobs}))\r\nGROUP BY time ORDER BY time ASC",
"refId": "A"
}
],
"title": "Overall Activity",
"type": "timeseries"
},
{
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"displayMode": "auto",
"inspect": true
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "timestamp"
},
"properties": [
{
"id": "custom.width",
"value": 158
}
]
}
]
},
"gridPos": {
"h": 23,
"w": 24,
"x": 0,
"y": 7
},
"id": 4,
"options": {
"footer": {
"enablePagination": true,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"sortBy": []
},
"pluginVersion": "9.3.6",
"targets": [
{
"builderOptions": {
"fields": [
"*"
],
"filters": [
{
"condition": "AND",
"filterType": "custom",
"key": "timestamp",
"operator": "WITH IN DASHBOARD TIME RANGE",
"type": "DateTime64(3, 'Etc/UTC')",
"value": "TODAY"
},
{
"condition": "AND",
"filterType": "custom",
"key": "scope",
"operator": "IN",
"type": "String",
"value": [
""
]
}
],
"mode": "list",
"orderBy": [
{
"dir": "ASC",
"name": "timestamp"
}
],
"table": "events"
},
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"format": 1,
"meta": {
"builderOptions": {
"fields": [
"*"
],
"filters": [
{
"condition": "AND",
"filterType": "custom",
"key": "timestamp",
"operator": "WITH IN DASHBOARD TIME RANGE",
"type": "DateTime64(3, 'Etc/UTC')",
"value": "TODAY"
},
{
"condition": "AND",
"filterType": "custom",
"key": "scope",
"operator": "IN",
"type": "String",
"value": [
""
]
}
],
"mode": "list",
"orderBy": [
{
"dir": "ASC",
"name": "timestamp"
}
],
"table": "events"
}
},
"queryType": "sql",
"rawSql": "SELECT * \r\nFROM events \r\nWHERE $__timeFilter(timestamp)\r\n AND scope IN (${scopes})\r\n AND source IN (${sources})\r\n AND (-1 IN (${users}) OR user_id IN (${users}))\r\n AND (' ' IN (${usernames}) OR user_name IN (${usernames}))\r\n AND (-1 IN (${organizations}) OR org_id IN (${organizations}))\r\n AND (-1 IN (${projects}) OR project_id IN (${projects}))\r\n AND (-1 IN (${tasks}) OR task_id IN (${tasks}))\r\n AND (-1 IN (${jobs}) OR job_id IN (${jobs}))\r\nORDER BY timestamp DESC\r\nLIMIT 1000",
"refId": "A"
}
],
"title": "All events",
"type": "table"
},
{
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisGridShow": false,
"axisLabel": "",
"axisPlacement": "auto",
"fillOpacity": 80,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineWidth": 0,
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "percentage",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 12,
"x": 0,
"y": 30
},
"id": 6,
"options": {
"barRadius": 0,
"barWidth": 0.51,
"groupWidth": 0.7,
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": false
},
"orientation": "horizontal",
"showValue": "always",
"stacking": "none",
"tooltip": {
"mode": "single",
"sort": "none"
},
"xField": "browser",
"xTickLabelRotation": 0,
"xTickLabelSpacing": 0
},
"pluginVersion": "9.3.6",
"targets": [
{
"builderOptions": {
"fields": [],
"filters": [],
"limit": 100,
"mode": "list",
"orderBy": [],
"table": "events"
},
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"format": 1,
"meta": {
"builderOptions": {
"fields": [],
"filters": [],
"limit": 100,
"mode": "list",
"orderBy": [],
"table": "events"
}
},
"queryType": "sql",
"rawSql": "SELECT\r\n browser,\r\n count()\r\nFROM\r\n(\r\n SELECT\r\n concat(JSON_VALUE(payload, '$.platform.name'), ' ', JSON_VALUE(payload, '$.platform.version')) AS browser,\r\n user_id\r\n FROM cvat.events\r\n WHERE $__timeFilter(timestamp) AND (scope = 'load:cvat') AND (browser != ' ')\r\n GROUP BY\r\n user_id,\r\n browser\r\n)\r\nGROUP BY browser\r\nORDER BY count() DESC",
"refId": "A"
}
],
"title": "Browser",
"type": "barchart"
},
{
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisGridShow": false,
"axisLabel": "",
"axisPlacement": "auto",
"fillOpacity": 80,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineWidth": 0,
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "percentage",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 12,
"x": 12,
"y": 30
},
"id": 8,
"options": {
"barRadius": 0,
"barWidth": 0.51,
"groupWidth": 0.7,
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": false
},
"orientation": "horizontal",
"showValue": "always",
"stacking": "none",
"tooltip": {
"mode": "single",
"sort": "none"
},
"xField": "os",
"xTickLabelRotation": 0,
"xTickLabelSpacing": 0
},
"pluginVersion": "9.3.6",
"targets": [
{
"builderOptions": {
"fields": [],
"filters": [],
"limit": 100,
"mode": "list",
"orderBy": [],
"table": "events"
},
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"format": 1,
"meta": {
"builderOptions": {
"fields": [],
"filters": [],
"limit": 100,
"mode": "list",
"orderBy": [],
"table": "events"
}
},
"queryType": "sql",
"rawSql": "SELECT\r\n os,\r\n count()\r\nFROM\r\n(\r\n SELECT\r\n JSON_VALUE(payload, '$.platform.os') AS os,\r\n user_id\r\n FROM cvat.events\r\n WHERE $__timeFilter(timestamp) AND (scope = 'load:cvat') AND (os != '')\r\n GROUP BY\r\n user_id,\r\n os\r\n)\r\nGROUP BY os\r\nORDER BY count() DESC",
"refId": "A"
}
],
"title": "OS",
"type": "barchart"
}
],
"refresh": false,
"schemaVersion": 38,
"style": "dark",
"tags": [],
"templating": {
"list": [
{
"allValue": "",
"current": {
"selected": true,
"text": [
"All"
],
"value": [
"$__all"
]
},
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"definition": "SELECT DISTINCT scope\nFROM events\nWHERE $__timeFilter(timestamp)",
"hide": 0,
"includeAll": true,
"label": "Scope",
"multi": true,
"name": "scopes",
"options": [],
"query": "SELECT DISTINCT scope\nFROM events\nWHERE $__timeFilter(timestamp)",
"refresh": 2,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"type": "query"
},
{
"allValue": "",
"current": {
"selected": true,
"text": [
"All"
],
"value": [
"$__all"
]
},
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"definition": "SELECT DISTINCT source\nFROM events\nWHERE $__timeFilter(timestamp)",
"hide": 0,
"includeAll": true,
"label": "Source",
"multi": true,
"name": "sources",
"options": [],
"query": "SELECT DISTINCT source\nFROM events\nWHERE $__timeFilter(timestamp)",
"refresh": 2,
"regex": "",
"skipUrlSync": false,
"sort": 0,
"type": "query"
},
{
"allValue": "-1",
"current": {
"selected": true,
"text": [
"All"
],
"value": [
"$__all"
]
},
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"definition": "SELECT DISTINCT user_id\nFROM events\nWHERE $__timeFilter(timestamp)\n AND user_id IS NOT NULL",
"hide": 0,
"includeAll": true,
"label": "User",
"multi": true,
"name": "users",
"options": [],
"query": "SELECT DISTINCT user_id\nFROM events\nWHERE $__timeFilter(timestamp)\n AND user_id IS NOT NULL",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 0,
"type": "query"
},
{
"allValue": "' '",
"current": {
"selected": true,
"text": [
"All"
],
"value": [
"$__all"
]
},
"definition": "SELECT DISTINCT user_name\nFROM events\nWHERE $__timeFilter(timestamp)",
"hide": 0,
"includeAll": true,
"label": "Username",
"multi": true,
"name": "usernames",
"options": [],
"query": "SELECT DISTINCT user_name\nFROM events\nWHERE $__timeFilter(timestamp)",
"refresh": 2,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"type": "query"
},
{
"allValue": "-1",
"current": {
"selected": true,
"text": [
"All"
],
"value": [
"$__all"
]
},
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"definition": "SELECT DISTINCT project_id\nFROM events\nWHERE $__timeFilter(timestamp)\n AND project_id IS NOT NULL",
"hide": 0,
"includeAll": true,
"label": "Project",
"multi": true,
"name": "projects",
"options": [],
"query": "SELECT DISTINCT project_id\nFROM events\nWHERE $__timeFilter(timestamp)\n AND project_id IS NOT NULL",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 0,
"type": "query"
},
{
"allValue": "-1",
"current": {
"selected": true,
"text": [
"All"
],
"value": [
"$__all"
]
},
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"definition": "SELECT DISTINCT task_id\nFROM events\nWHERE $__timeFilter(timestamp) \n AND task_id IS NOT NULL",
"hide": 0,
"includeAll": true,
"label": "Task",
"multi": true,
"name": "tasks",
"options": [],
"query": "SELECT DISTINCT task_id\nFROM events\nWHERE $__timeFilter(timestamp) \n AND task_id IS NOT NULL",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 0,
"type": "query"
},
{
"allValue": "-1",
"current": {
"selected": true,
"text": [
"All"
],
"value": [
"$__all"
]
},
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"definition": "SELECT DISTINCT job_id\nFROM events\nWHERE $__timeFilter(timestamp)\n AND job_id IS NOT NULL",
"hide": 0,
"includeAll": true,
"label": "Job",
"multi": true,
"name": "jobs",
"options": [],
"query": "SELECT DISTINCT job_id\nFROM events\nWHERE $__timeFilter(timestamp)\n AND job_id IS NOT NULL",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 0,
"type": "query"
},
{
"allValue": "-1",
"current": {
"selected": true,
"text": [
"All"
],
"value": [
"$__all"
]
},
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"definition": "SELECT DISTINCT org_id\nFROM events\nWHERE $__timeFilter(timestamp)\nAND org_id IS NOT NULL",
"hide": 0,
"includeAll": true,
"label": "Organization",
"multi": true,
"name": "organizations",
"options": [],
"query": "SELECT DISTINCT org_id\nFROM events\nWHERE $__timeFilter(timestamp)\nAND org_id IS NOT NULL",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 0,
"type": "query"
}
]
},
"time": {
"from": "now-7d",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "All events",
"uid": "EIGSTDAVz",
"version": 1,
"weekStart": ""
}

View File

@ -0,0 +1,532 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"target": {
"limit": 100,
"matchAny": false,
"tags": [],
"type": "dashboard"
},
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 25,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "smooth",
"lineStyle": {
"fill": "solid"
},
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"__systemRef": "hideSeriesFrom",
"matcher": {
"id": "byNames",
"options": {
"mode": "exclude",
"names": [
"User 1"
],
"prefix": "All except:",
"readOnly": true
}
},
"properties": [
{
"id": "custom.hideFrom",
"value": {
"legend": false,
"tooltip": false,
"viz": true
}
}
]
}
]
},
"gridPos": {
"h": 9,
"w": 24,
"x": 0,
"y": 0
},
"id": 6,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "9.3.6",
"targets": [
{
"builderOptions": {
"fields": [],
"filters": [],
"groupBy": [
"user_id"
],
"metrics": [
{
"aggregation": "count",
"alias": "value",
"field": "*"
}
],
"mode": "trend",
"orderBy": [],
"table": "events",
"timeField": "timestamp",
"timeFieldType": "DateTime64(3, 'Etc/UTC')"
},
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"format": 0,
"hide": false,
"meta": {
"builderOptions": {
"fields": [],
"filters": [],
"groupBy": [
"user_id"
],
"metrics": [
{
"aggregation": "count",
"alias": "value",
"field": "*"
}
],
"mode": "trend",
"orderBy": [],
"table": "events",
"timeField": "timestamp",
"timeFieldType": "DateTime64(3, 'Etc/UTC')"
}
},
"queryType": "sql",
"rawSql": "SELECT $__timeInterval(timestamp) as time, toString(user_id), count() as User\r\nFROM events\r\nWHERE $__timeFilter(timestamp)\r\nGROUP BY time, user_id\r\nORDER BY time ASC, user_id ASC",
"refId": "A"
}
],
"title": "User Activity",
"transformations": [],
"type": "timeseries"
},
{
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "continuous-GrYlRd"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "bars",
"fillOpacity": 90,
"gradientMode": "scheme",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 24,
"x": 0,
"y": 9
},
"id": 4,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"builderOptions": {
"fields": [],
"filters": [],
"metrics": [
{
"aggregation": "count",
"alias": "Count",
"field": "*"
}
],
"mode": "trend",
"orderBy": [],
"table": "events",
"timeField": "timestamp",
"timeFieldType": "DateTime64(3, 'Etc/UTC')"
},
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"format": 0,
"queryType": "builder",
"rawSql": "SELECT $__timeInterval(timestamp) as time, count(*) Count FROM events WHERE $__timeFilter(timestamp) GROUP BY time ORDER BY time ASC",
"refId": "A"
}
],
"title": "Overall Activity",
"type": "timeseries"
},
{
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"displayMode": "auto",
"filterable": false,
"inspect": true
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 13,
"w": 24,
"x": 0,
"y": 16
},
"id": 2,
"options": {
"footer": {
"enablePagination": true,
"fields": [
"Working time(h)",
"Activity"
],
"reducer": [
"sum"
],
"show": true
},
"showHeader": true,
"sortBy": []
},
"pluginVersion": "9.3.6",
"targets": [
{
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"format": 1,
"meta": {
"builderOptions": {
"fields": [],
"limit": 100,
"mode": "list"
}
},
"queryType": "sql",
"rawSql": "SELECT\r\n user_id as User,\r\n user_name as Username,\r\n project_id as Project,\r\n task_id as Task,\r\n job_id as Job, sum(JSONExtractUInt(payload, 'working_time')) / 1000 / 3600 as \"Working time(h)\",\r\n count() as Activity\r\nFROM events\r\nWHERE JSONHas(payload, 'working_time')\r\n AND $__timeFilter(timestamp)\r\n AND(-1 IN (${users}) OR user_id IN (${users}))\r\n AND (' ' IN (${usernames}) OR user_name IN (${usernames}))\r\n AND (-1 IN (${projects}) OR project_id IN (${projects}))\r\n AND task_id IN (${tasks})\r\n AND job_id IN (${jobs})\r\nGROUP BY user_id, user_name, project_id, task_id, job_id",
"refId": "A"
}
],
"title": "Working time",
"type": "table"
}
],
"refresh": false,
"schemaVersion": 38,
"style": "dark",
"tags": [],
"templating": {
"list": [
{
"allValue": "-1",
"current": {
"selected": true,
"text": [
"All"
],
"value": [
"$__all"
]
},
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"definition": "SELECT DISTINCT user_id\nFROM events\nWHERE $__timeFilter(timestamp)\n AND source = 'client'",
"hide": 0,
"includeAll": true,
"label": "User",
"multi": true,
"name": "users",
"options": [],
"query": "SELECT DISTINCT user_id\nFROM events\nWHERE $__timeFilter(timestamp)\n AND source = 'client'",
"refresh": 2,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"type": "query"
},
{
"allValue": "' '",
"current": {
"selected": true,
"text": [
"All"
],
"value": [
"$__all"
]
},
"definition": "SELECT DISTINCT user_name\nFROM events\nWHERE $__timeFilter(timestamp)",
"hide": 0,
"includeAll": true,
"label": "Username",
"multi": true,
"name": "usernames",
"options": [],
"query": "SELECT DISTINCT user_name\nFROM events\nWHERE $__timeFilter(timestamp)",
"refresh": 2,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"type": "query"
},
{
"allValue": "-1",
"current": {
"selected": true,
"text": [
"All"
],
"value": [
"$__all"
]
},
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"definition": "SELECT DISTINCT project_id\nFROM events\nWHERE $__timeFilter(timestamp)\n AND project_id IS NOT NULL\n AND source = 'client'",
"hide": 0,
"includeAll": true,
"label": "Project",
"multi": true,
"name": "projects",
"options": [],
"query": "SELECT DISTINCT project_id\nFROM events\nWHERE $__timeFilter(timestamp)\n AND project_id IS NOT NULL\n AND source = 'client'",
"refresh": 2,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"type": "query"
},
{
"current": {
"selected": false,
"text": "All",
"value": "$__all"
},
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"definition": "SELECT DISTINCT task_id\nFROM events\nWHERE $__timeFilter(timestamp) \n AND task_id IS NOT NULL\n AND source = 'client'\n AND (-1 IN (${projects}) OR project_id IN (${projects}))",
"description": "",
"hide": 0,
"includeAll": true,
"label": "Task",
"multi": true,
"name": "tasks",
"options": [],
"query": "SELECT DISTINCT task_id\nFROM events\nWHERE $__timeFilter(timestamp) \n AND task_id IS NOT NULL\n AND source = 'client'\n AND (-1 IN (${projects}) OR project_id IN (${projects}))",
"refresh": 2,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"type": "query"
},
{
"current": {
"selected": true,
"text": [
"All"
],
"value": [
"$__all"
]
},
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "PDEE91DDB90597936"
},
"definition": "SELECT DISTINCT job_id\nFROM events\nWHERE $__timeFilter(timestamp)\n AND job_id IS NOT NULL\n AND source = 'client'\n AND task_id in (${tasks})",
"hide": 0,
"includeAll": true,
"label": "Job",
"multi": true,
"name": "jobs",
"options": [],
"query": "SELECT DISTINCT job_id\nFROM events\nWHERE $__timeFilter(timestamp)\n AND job_id IS NOT NULL\n AND source = 'client'\n AND task_id in (${tasks})",
"refresh": 2,
"regex": "",
"skipUrlSync": false,
"sort": 0,
"type": "query"
}
]
},
"time": {
"from": "now-7d",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Management",
"uid": "w0if6WAVz",
"version": 2,
"weekStart": ""
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
http:
routers:
grafana:
entryPoints:
- web
middlewares:
- analytics-auth
- strip-prefix
service: grafana
rule: Host(`{{ env "CVAT_HOST" }}`) && PathPrefix(`/analytics`)
grafana_https:
entryPoints:
- websecure
middlewares:
- analytics-auth
- strip-prefix
service: grafana
tls: {}
rule: Host(`{{ env "CVAT_HOST" }}`) && PathPrefix(`/analytics`)
middlewares:
analytics-auth:
forwardauth:
address: http://cvat_server:8080/analytics
authRequestHeaders:
- "Cookie"
- "Authorization"
strip-prefix:
stripprefix:
prefixes:
- /analytics
services:
grafana:
loadBalancer:
servers:
- url: http://{{ env "DJANGO_LOG_VIEWER_HOST" }}:{{ env "DJANGO_LOG_VIEWER_PORT" }}
passHostHeader: false

View File

@ -0,0 +1,45 @@
data_dir = "/vector-data-dir"
[sources.http-events]
type = "http_server"
address = "0.0.0.0:8282"
encoding = "json"
# Uncomment for debug
# [sinks.console]
# type = "console"
# inputs = [ "http-events" ]
# target = "stdout"
# [sinks.console.encoding]
# codec = "json"
[sinks.clickhouse]
inputs = [ "http-events" ]
type = "clickhouse"
database = "${CLICKHOUSE_DB}"
table = "events"
auth.strategy = "basic"
auth.user = "${CLICKHOUSE_USER}"
auth.password = "${CLICKHOUSE_PASSWORD}"
endpoint = "http://${CLICKHOUSE_HOST}:${CLICKHOUSE_PORT}"
request.concurrency = "adaptive"
encoding.only_fields = [
"scope",
"obj_name",
"obj_id",
"obj_val",
"source",
"timestamp",
"count",
"duration",
"project_id",
"task_id",
"job_id",
"user_id",
"user_name",
"user_email",
"org_id",
"org_slug",
"payload",
]

View File

@ -0,0 +1,8 @@
## Serverless for Computer Vision Annotation Tool (CVAT)
### Run docker container
```bash
# From project root directory
docker compose -f docker-compose.yml -f components/serverless/docker-compose.serverless.yml up -d
```

View File

@ -0,0 +1,32 @@
services:
nuclio:
container_name: nuclio
image: quay.io/nuclio/dashboard:1.13.0-amd64
restart: always
networks:
- cvat
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
http_proxy:
https_proxy:
no_proxy: ${no_proxy:-}
NUCLIO_CHECK_FUNCTION_CONTAINERS_HEALTHINESS: 'true'
NUCLIO_DASHBOARD_DEFAULT_FUNCTION_MOUNT_MODE: 'volume'
ports:
- '8070:8070'
logging:
driver: "json-file"
options:
max-size: 100m
max-file: "3"
cvat_server:
environment:
CVAT_SERVERLESS: 1
extra_hosts:
- "host.docker.internal:host-gateway"
cvat_worker_annotation:
extra_hosts:
- "host.docker.internal:host-gateway"

27
cvat-canvas/.eslintrc.cjs Normal file
View File

@ -0,0 +1,27 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
const { join } = require('path');
module.exports = {
ignorePatterns: [
'.eslintrc.cjs',
'webpack.config.js',
'node_modules/**',
'dist/**',
],
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
},
rules: {
'import/no-extraneous-dependencies': [
'error',
{
packageDir: [__dirname, join(__dirname, '../')]
},
],
}
};

134
cvat-canvas/README.md Normal file
View File

@ -0,0 +1,134 @@
# Module CVAT-CANVAS
## Description
The CVAT module written in TypeScript language.
It presents a canvas to viewing, drawing and editing of annotations.
## Commands
- Building of the module from sources in the `dist` directory:
```bash
yarn run build
yarn run build --mode=development # without a minification
```
### API Methods
For API methods, their arguments and return types, please look at ``canvas.ts``.
### API CSS
- All drawn objects (shapes, tracks) have an id `cvat_canvas_shape_{objectState.clientID}`
- Drawn shapes and tracks have classes `cvat_canvas_shape`,
`cvat_canvas_shape_activated`,
`cvat_canvas_shape_selection`,
`cvat_canvas_shape_merging`,
`cvat_canvas_shape_drawing`,
`cvat_canvas_shape_occluded`
- Drawn review ROIs have an id `cvat_canvas_issue_region_{issue.id}`
- Drawn review roi has the class `cvat_canvas_issue_region`
- Drawn texts have the class `cvat_canvas_text`
- Tags have the class `cvat_canvas_tag`
- Canvas image has ID `cvat_canvas_image`
- Grid on the canvas has ID `cvat_canvas_grid` and `cvat_canvas_grid_pattern`
- Crosshair during a draw has class `cvat_canvas_crosshair`
- To stick something to a specific position you can use an element with id `cvat_canvas_attachment_board`
### Events
Standard JS events are used.
```js
- canvas.setup
- canvas.activated => {state: ObjectState}
- canvas.clicked => {state: ObjectState}
- canvas.moved => {states: ObjectState[], x: number, y: number}
- canvas.find => {states: ObjectState[], x: number, y: number}
- canvas.drawn => {state: DrawnData}
- canvas.interacted => {shapes: InteractionResult[]}
- canvas.editstart
- canvas.edited => {state: ObjectState, points: number[], rotation?: number}
- canvas.splitted => {state: ObjectState, frame: number, duration: number}
- canvas.grouped => {states: ObjectState[], duration: number}
- canvas.joined => {states: ObjectState[], points: number[], duration: number}
- canvas.sliced => {state: ObjectState, results: number[][], duration: number}
- canvas.merged => {states: ObjectState[], duration: number}
- canvas.canceled
- canvas.dragstart
- canvas.dragstop
- canvas.zoomstart
- canvas.zoomstop
- canvas.zoom
- canvas.reshape
- canvas.fit
- canvas.regionselected => {points: number[]}
- canvas.dragshape => {duration: number, state: ObjectState}
- canvas.roiselected => {points: number[]}
- canvas.resizeshape => {duration: number, state: ObjectState}
- canvas.contextmenu => { mouseEvent: MouseEvent, objectState: ObjectState, pointID: number }
- canvas.message => { messages: { type: 'text' | 'list'; content: string | string[]; className?: string; icon?: 'info' | 'loading' }[] | null, topic: string }
- canvas.error => { exception: Error, domain?: string }
- canvas.destroy
```
### WEB
```js
// Create an instance of a canvas
const canvas = new window.canvas.Canvas();
console.log('Version ', window.canvas.CanvasVersion);
console.log('Current mode is ', window.canvas.mode());
// Put canvas to a html container
htmlContainer.appendChild(canvas.html());
canvas.fitCanvas();
// Next you can use its API methods. For example:
canvas.rotate(270);
canvas.draw({
enabled: true,
shapeType: 'rectangle',
crosshair: true,
rectDrawingMethod: window.Canvas.RectDrawingMethod.CLASSIC,
});
```
<!--lint disable maximum-line-length-->
## API Reaction
| | IDLE | GROUP | SPLIT | DRAW | MERGE | EDIT | DRAG | RESIZE | ZOOM_CANVAS | DRAG_CANVAS | INTERACT | JOIN | SLICE | SELECT_REGION |
| -------------- | ---- | ----- | ----- | ---- | ----- | ---- | ---- | ------ | ----------- | ----------- | -------- | ---- | ----- | ------------- |
| setup() | + | + | + | +/- | + | +/- | +/- | +/- | + | + | + | + | + | + |
| activate() | + | - | - | - | - | - | - | - | - | - | - | - | - | - |
| rotate() | + | + | + | + | + | + | + | + | + | + | + | + | + | + |
| focus() | + | + | + | + | + | + | + | + | + | + | + | + | + | + |
| fit() | + | + | + | + | + | + | + | + | + | + | + | + | + | + |
| grid() | + | + | + | + | + | + | + | + | + | + | + | + | + | + |
| draw() | + | - | - | + | - | - | - | - | - | - | - | - | - | - |
| interact() | + | - | - | - | - | - | - | - | - | - | + | - | - | - |
| split() | + | - | + | - | - | - | - | - | - | - | - | - | - | - |
| group() | + | + | - | - | - | - | - | - | - | - | - | - | - | - |
| merge() | + | - | - | - | + | - | - | - | - | - | - | - | - | - |
| edit() | + | - | - | - | - | + | - | - | - | - | - | - | - | - |
| join() | + | - | - | - | - | - | - | - | - | - | - | + | - | - |
| slice() | + | - | - | - | - | - | - | - | - | - | - | - | + | - |
| selectRegion() | + | - | - | - | - | - | - | - | - | - | - | - | - | + |
| fitCanvas() | + | + | + | + | + | + | + | + | + | + | + | + | + | + |
| dragCanvas() | + | - | - | - | - | - | + | - | - | + | - | - | - | - |
| zoomCanvas() | + | - | - | - | - | - | - | + | + | - | - | - | - | - |
| cancel() | - | + | + | + | + | + | + | + | + | + | + | + | + | + |
| configure() | + | + | + | + | + | + | + | + | + | + | + | + | + | + |
| bitmap() | + | + | + | + | + | + | + | + | + | + | + | + | + | + |
| setZLayer() | + | + | + | + | + | + | + | + | + | + | + | + | + | + |
| destroy() | + | + | + | + | + | + | + | + | + | + | + | + | + | + |
<!--lint enable maximum-line-length-->
You can call setup() during editing, dragging, and resizing only to update objects, not to change a frame.
You can change frame during draw only when you do not redraw an existing object
Other methods do not change state and can be used at any time.

27
cvat-canvas/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "cvat-canvas",
"version": "2.20.10",
"type": "module",
"description": "Part of Computer Vision Annotation Tool which presents its canvas library",
"main": "src/canvas.ts",
"scripts": {
"build": "tsc && webpack --config ./webpack.config.cjs"
},
"author": "CVAT.ai",
"license": "MIT",
"browserslist": [
"Chrome >= 99",
"Firefox >= 110",
"not IE 11",
"> 2%"
],
"dependencies": {
"@types/polylabel": "^1.0.5",
"polylabel": "^1.1.0",
"svg.draggable.js": "2.2.2",
"svg.draw.js": "^2.0.4",
"svg.js": "2.7.1",
"svg.resize.js": "1.4.3",
"svg.select.js": "3.0.1"
}
}

View File

@ -0,0 +1,463 @@
// Copyright (C) 2020-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
/* stylelint-disable selector-class-pattern, selector-id-pattern */
.cvat_canvas_hidden {
display: none;
}
.cvat_canvas_shape {
stroke-opacity: 1;
}
g.cvat_canvas_shape {
> circle {
fill-opacity: 1;
}
}
polyline.cvat_canvas_shape {
fill-opacity: 0;
}
.cvat_shape_action_opacity {
fill-opacity: 0.5;
stroke-opacity: 1;
}
polyline.cvat_shape_action_opacity {
fill-opacity: 0;
}
.cvat_shape_drawing_opacity {
stroke-opacity: 1;
}
polyline.cvat_shape_drawing_opacity {
fill-opacity: 0;
}
.cvat_shape_action_dasharray {
stroke-dasharray: 4 1 2 3;
}
.cvat_canvas_text {
font-weight: bold;
fill: white;
cursor: default;
filter: drop-shadow(1px 1px 1px black) drop-shadow(-1px -1px 1px black);
user-select: none;
pointer-events: none;
}
.cvat_canvas_text_dimensions {
fill: lightskyblue;
}
.cvat_canvas_text_description {
fill: yellow;
font-style: oblique 40deg;
}
.cvat_canvas_crosshair {
stroke: red;
}
.cvat_canvas_shape_selection {
@extend .cvat_shape_action_dasharray;
@extend .cvat_shape_action_opacity;
fill: #fcfbfc;
}
image.cvat_canvas_shape_selection {
visibility: hidden;
}
.cvat_canvas_selection_box {
fill: white;
fill-opacity: 0.1;
stroke: white;
}
.cvat_canvas_shape_region_selection {
@extend .cvat_shape_action_dasharray;
@extend .cvat_shape_action_opacity;
fill: white;
stroke: white;
}
.cvat_canvas_issue_region {
pointer-events: none;
stroke-width: 0;
}
circle.cvat_canvas_issue_region {
opacity: 1 !important;
}
polyline.cvat_canvas_shape_selection {
@extend .cvat_shape_action_dasharray;
@extend .cvat_shape_action_opacity;
stroke: darkmagenta;
}
.cvat_canvas_shape_merging {
@extend .cvat_shape_action_dasharray;
@extend .cvat_shape_action_opacity;
fill: blue;
> circle[data-node-id] {
fill: blue;
}
}
polyline.cvat_canvas_shape_merging {
@extend .cvat_shape_action_dasharray;
@extend .cvat_shape_action_opacity;
stroke: blue;
}
polyline.cvat_canvas_shape_splitting {
@extend .cvat_shape_action_dasharray;
@extend .cvat_shape_action_opacity;
stroke: dodgerblue;
}
.cvat_canvas_shape_splitting {
@extend .cvat_shape_action_dasharray;
@extend .cvat_shape_action_opacity;
fill: dodgerblue;
}
.cvat_canvas_shape_drawing {
@extend .cvat_shape_drawing_opacity;
fill: white;
}
.cvat_canvas_zoom_selection {
@extend .cvat_shape_action_dasharray;
stroke: #096dd9;
fill-opacity: 0;
}
.cvat_canvas_shape_occluded {
stroke-dasharray: 5;
}
.cvat_canvas_ground_truth {
stroke-dasharray: 1;
}
.cvat_canvas_conflicted {
stroke: #ff4800;
fill: #ff4800;
rect,
ellipse,
polygon,
polyline,
line {
fill: #ff4800;
stroke: #ff4800;
}
circle {
fill: #ff4800;
}
}
.cvat_canvas_warned {
stroke: #ff7301;
fill: #ff7301;
rect,
ellipse,
polygon,
polyline,
line {
fill: #ff7301;
stroke: #ff7301;
}
circle {
fill: #ff7301;
}
}
.cvat_canvas_shape_occluded_point {
stroke-dasharray: 1 !important;
stroke: white;
}
circle.cvat_canvas_shape_occluded {
@extend .cvat_canvas_shape_occluded_point;
}
g.cvat_canvas_shape_occluded {
> rect {
stroke-dasharray: 5;
}
> circle {
@extend .cvat_canvas_shape_occluded_point;
}
}
.svg_select_points_rot {
fill: white;
}
.cvat_canvas_shape .svg_select_points,
.cvat_canvas_shape .cvat_canvas_cuboid_projections {
stroke-dasharray: none;
}
.cvat_canvas_autoborder_point {
opacity: 0.55;
}
.cvat_canvas_autoborder_point:hover {
opacity: 1;
fill: red;
}
.cvat_canvas_autoborder_point:active {
opacity: 0.55;
fill: red;
}
.cvat_canvas_autoborder_point_direction {
fill: blueviolet;
}
.cvat_canvas_interact_intermediate_shape {
@extend .cvat_canvas_shape;
}
.cvat_canvas_removable_interaction_point {
cursor:
url(
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAxMCAxMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEgMUw5IDlNMSA5TDkgMSIgc3Ryb2tlPSJibGFjayIvPgo8L3N2Zz4K'
) 10 10,
auto;
}
.svg_select_boundingRect {
opacity: 0;
pointer-events: none;
}
.svg_select_points_lb:hover,
.svg_select_points_rt:hover {
cursor: nesw-resize;
}
.svg_select_points_lt:hover,
.svg_select_points_rb:hover {
cursor: nwse-resize;
}
.svg_select_points_l:hover,
.svg_select_points_r:hover,
.svg_select_points_ew:hover {
cursor: ew-resize;
}
.svg_select_points_t:hover,
.svg_select_points_b:hover {
cursor: ns-resize;
}
.cvat_canvas_shape_draggable:hover {
cursor: move;
}
.cvat_canvas_first_poly_point {
fill: lightgray;
}
.cvat_canvas_poly_direction {
fill: lightgray;
stroke: black;
&:hover {
fill: black;
stroke: lightgray;
}
&:active {
fill: lightgray;
stroke: black;
}
}
.cvat_canvas_skeleton_wrapping_rect {
// wrapping rect must not apply transform attribute from selectize.js
// otherwise it rotated twice, because we apply the same rotation value to parent element (skeleton itself)
transform: none !important;
}
.cvat_canvas_shape > .cvat_canvas_skeleton_wrapping_rect {
visibility: hidden;
}
.cvat_canvas_shape.cvat_canvas_shape_activated > .cvat_canvas_skeleton_wrapping_rect {
visibility: initial;
}
.cvat_canvas_pixelized {
image-rendering: optimizeSpeed;
image-rendering: optimize-contrast;
image-rendering: crisp-edges;
image-rendering: pixelated;
}
.cvat_canvas_removed_image {
filter: saturate(0) brightness(1.2) contrast(0.75) !important;
}
#cvat_canvas_wrapper {
width: calc(100% - 10px);
height: calc(100% - 10px);
margin: 5px;
border-radius: 5px;
background-color: inherit;
overflow: hidden;
position: relative;
}
.cvat-canvas-highlight-enabled {
svg {
>rect:not(.cvat_canvas_issue_region,.cvat_canvas_conflicted,.cvat_canvas_warned),
>ellipse:not(.cvat_canvas_issue_region,.cvat_canvas_conflicted,.cvat_canvas_warned),
>polygon:not(.cvat_canvas_issue_region,.cvat_canvas_conflicted,.cvat_canvas_warned),
>polyline:not(.cvat_canvas_issue_region,.cvat_canvas_conflicted,.cvat_canvas_warned),
>line:not(.cvat_canvas_issue_region,.cvat_canvas_conflicted,.cvat_canvas_warned) {
fill: gray;
stroke: gray;
}
>circle:not(.cvat_canvas_issue_region,.cvat_canvas_conflicted,.cvat_canvas_warned) {
fill: gray;
}
>g:not(.cvat_canvas_issue_region,.cvat_canvas_conflicted,.cvat_canvas_warned) {
rect,
ellipse,
polygon,
polyline,
line {
fill: gray;
stroke: gray;
}
circle {
fill: gray;
}
}
}
}
#cvat_canvas_text_content {
text-rendering: optimizeSpeed;
position: absolute;
z-index: 3;
pointer-events: none;
width: 100%;
height: 100%;
}
#cvat_canvas_background {
position: absolute;
z-index: 0;
background-repeat: no-repeat;
width: 100%;
height: 100%;
box-shadow: 2px 2px 5px 0 rgba(0, 0, 0, 75%);
}
#cvat_canvas_bitmap {
@extend .cvat_canvas_pixelized;
pointer-events: none;
position: absolute;
z-index: 4;
background: black;
width: 100%;
height: 100%;
box-shadow: 2px 2px 5px 0 rgba(0, 0, 0, 75%);
}
#cvat_canvas_grid {
position: absolute;
z-index: 2;
pointer-events: none;
width: 100%;
height: 100%;
}
#cvat_canvas_grid_pattern {
opacity: 1;
stroke: white;
}
#cvat_canvas_content {
@extend .cvat_canvas_pixelized;
filter: contrast(120%) saturate(150%);
position: absolute;
z-index: 2;
outline: 10px solid black;
width: 100%;
height: 100%;
}
.cvat_masks_canvas_wrapper {
@extend .cvat_canvas_pixelized;
z-index: 3;
display: none;
}
#cvat_canvas_attachment_board {
position: absolute;
z-index: 4;
pointer-events: none;
width: 100%;
height: 100%;
user-select: none;
}
.cvat_canvas_shape_darken {
fill: #838383;
stroke: #838383;
}
.cvat_canvas_sliced_contour {
fill-opacity: 0.01;
}
.cvat_canvas_slicing_line {
pointer-events: none;
fill-opacity: 0;
}
.cvat-canvas-notification-list-warning {
color: orange;
}
.cvat-canvas-notification-list-shortcuts {
color: yellow;
}

View File

@ -0,0 +1,312 @@
// Copyright (C) 2020-2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
import * as SVG from 'svg.js';
import consts from './consts';
import { Configuration, Geometry } from './canvasModel';
interface TransformedShape {
points: string;
color: string;
}
export interface AutoborderHandler {
autoborder(enabled: boolean, currentShape?: SVG.Shape, currentID?: number): void;
configure(configuration: Configuration): void;
transform(geometry: Geometry): void;
updateObjects(): void;
}
export class AutoborderHandlerImpl implements AutoborderHandler {
private currentShape: SVG.Shape | null;
private currentID?: number;
private frameContent: SVGSVGElement;
private enabled: boolean;
private scale: number;
private controlPointsSize: number;
private groups: SVGGElement[];
private auxiliaryGroupID: number | null;
private auxiliaryClicks: number[];
private listeners: Record<number, Record<number, {
click: (event: MouseEvent) => void;
dblclick: (event: MouseEvent) => void;
}>>;
public constructor(frameContent: SVGSVGElement) {
this.frameContent = frameContent;
this.currentID = undefined;
this.currentShape = null;
this.enabled = false;
this.scale = 1;
this.groups = [];
this.controlPointsSize = consts.BASE_POINT_SIZE;
this.auxiliaryGroupID = null;
this.auxiliaryClicks = [];
this.listeners = {};
}
private removeMarkers(): void {
this.groups.forEach((group: SVGGElement): void => {
const groupID = group.dataset.groupId;
Array.from(group.children).forEach((circle: SVGCircleElement, pointID: number): void => {
circle.removeEventListener('click', this.listeners[+groupID][pointID].click);
circle.removeEventListener('dblclick', this.listeners[+groupID][pointID].click);
circle.remove();
});
group.remove();
});
this.groups = [];
this.auxiliaryGroupID = null;
this.auxiliaryClicks = [];
this.listeners = {};
}
private release(): void {
this.removeMarkers();
this.enabled = false;
this.currentShape = null;
}
private addPointToCurrentShape(x: number, y: number): void {
const array: number[][] = (this.currentShape as any).array().valueOf();
array.pop();
// need to append twice (specific of the library)
array.push([x, y]);
array.push([x, y]);
const paintHandler = this.currentShape.remember('_paintHandler');
paintHandler.drawCircles();
paintHandler.set.members.forEach((el: SVG.Circle): void => {
el.attr('stroke-width', 1 / this.scale).attr('r', 2.5 / this.scale);
});
(this.currentShape as any).plot(array);
}
private resetAuxiliaryShape(): void {
if (this.auxiliaryGroupID !== null) {
while (this.auxiliaryClicks.length > 0) {
const resetID = this.auxiliaryClicks.pop();
this.groups[this.auxiliaryGroupID].children[resetID].classList.remove(
'cvat_canvas_autoborder_point_direction',
);
}
}
this.auxiliaryClicks = [];
this.auxiliaryGroupID = null;
}
// convert each shape to group of clickable points
// save all groups
private drawMarkers(transformedShapes: TransformedShape[]): void {
const svgNamespace = 'http://www.w3.org/2000/svg';
this.groups = transformedShapes.map(
(shape: TransformedShape, groupID: number): SVGGElement => {
const group = document.createElementNS(svgNamespace, 'g');
group.setAttribute('data-group-id', `${groupID}`);
this.listeners[groupID] = this.listeners[groupID] || {};
const circles = shape.points.split(/\s/).map(
(point: string, pointID: number, points: string[]): SVGCircleElement => {
const [x, y] = point.split(',');
const circle = document.createElementNS(svgNamespace, 'circle');
circle.classList.add('cvat_canvas_autoborder_point');
circle.setAttribute('fill', shape.color);
circle.setAttribute('stroke', 'black');
circle.setAttribute('stroke-width', `${consts.POINTS_STROKE_WIDTH / this.scale}`);
circle.setAttribute('cx', x);
circle.setAttribute('cy', y);
circle.setAttribute('r', `${this.controlPointsSize / this.scale}`);
const click = (event: MouseEvent): void => {
event.stopPropagation();
// another shape was clicked
if (this.auxiliaryGroupID !== null && this.auxiliaryGroupID !== groupID) {
this.resetAuxiliaryShape();
}
this.auxiliaryGroupID = groupID;
// up clicked group for convenience
this.frameContent.appendChild(group);
if (this.auxiliaryClicks[1] === pointID) {
// the second point was clicked twice
this.addPointToCurrentShape(+x, +y);
this.resetAuxiliaryShape();
return;
}
// the first point can not be clicked twice
// just ignore such a click if it is
if (this.auxiliaryClicks[0] !== pointID) {
this.auxiliaryClicks.push(pointID);
} else {
return;
}
// it is the first click
if (this.auxiliaryClicks.length === 1) {
const handler = this.currentShape.remember('_paintHandler');
// draw and remove initial point just to initialize data structures
if (!handler || !handler.startPoint) {
(this.currentShape as any).draw('point', event);
(this.currentShape as any).draw('undo');
}
this.addPointToCurrentShape(+x, +y);
// is is the second click
} else if (this.auxiliaryClicks.length === 2) {
circle.classList.add('cvat_canvas_autoborder_point_direction');
// it is the third click
} else {
// sign defines bypass direction
const landmarks = this.auxiliaryClicks;
const sign = Math.sign(landmarks[2] - landmarks[0]) *
Math.sign(landmarks[1] - landmarks[0]) *
Math.sign(landmarks[2] - landmarks[1]);
// go via a polygon and get vertices
// the first vertex has been already drawn
const way = [];
for (let i = landmarks[0] + sign; ; i += sign) {
if (i < 0) {
i = points.length - 1;
} else if (i === points.length) {
i = 0;
}
way.push(points[i]);
if (i === this.auxiliaryClicks[this.auxiliaryClicks.length - 1]) {
// put the last element twice
// specific of svg.draw.js
// way.push(points[i]);
break;
}
}
// remove the latest cursor position from drawing array
for (const wayPoint of way) {
const [pX, pY] = wayPoint
.split(',')
.map((coordinate: string): number => +coordinate);
this.addPointToCurrentShape(pX, pY);
}
this.resetAuxiliaryShape();
}
};
const dblclick = (event: MouseEvent): void => {
event.stopPropagation();
};
this.listeners[groupID][pointID] = {
click,
dblclick,
};
circle.addEventListener('mousedown', this.listeners[groupID][pointID].click);
circle.addEventListener('dblclick', this.listeners[groupID][pointID].click);
return circle;
},
);
group.append(...circles);
return group;
},
);
this.frameContent.append(...this.groups);
}
public updateObjects(): void {
if (!this.enabled) return;
this.removeMarkers();
const currentClientID = this.currentShape.node.dataset.originClientId;
const shapes = Array.from(this.frameContent.getElementsByClassName('cvat_canvas_shape')).filter(
(shape: HTMLElement): boolean => +shape.getAttribute('clientID') !== this.currentID &&
!shape.classList.contains('cvat_canvas_hidden'),
);
const transformedShapes = shapes
.map((shape: HTMLElement): TransformedShape | null => {
const color = shape.getAttribute('fill');
const clientID = shape.getAttribute('clientID');
if (color === null || clientID === null) return null;
if (+clientID === +currentClientID) {
return null;
}
let points = '';
if (shape.tagName === 'polyline' || shape.tagName === 'polygon') {
points = shape.getAttribute('points');
} else if (shape.tagName === 'ellipse') {
const cx = +shape.getAttribute('cx');
const cy = +shape.getAttribute('cy');
points = `${cx},${cy}`;
} else if (shape.tagName === 'rect') {
const x = +shape.getAttribute('x');
const y = +shape.getAttribute('y');
const width = +shape.getAttribute('width');
const height = +shape.getAttribute('height');
if (Number.isNaN(x) || Number.isNaN(y) || Number.isNaN(x) || Number.isNaN(x)) {
return null;
}
points = `${x},${y} ${x + width},${y} ${x + width},${y + height} ${x},${y + height}`;
} else if (shape.tagName === 'g') {
const polylineID = shape.dataset.polylineId;
const polyline = this.frameContent.getElementById(polylineID);
if (polyline && polyline.getAttribute('points')) {
points = polyline.getAttribute('points');
} else {
return null;
}
}
return {
color,
points: points.trim(),
};
})
.filter((state: TransformedShape | null): boolean => state !== null);
this.drawMarkers(transformedShapes);
}
public autoborder(enabled: boolean, currentShape?: SVG.Shape, currentID?: number): void {
if (enabled && !this.enabled && currentShape) {
this.enabled = true;
this.currentShape = currentShape;
this.currentID = currentID;
this.updateObjects();
} else {
this.release();
}
}
public transform(geometry: Geometry): void {
this.scale = geometry.scale;
this.groups.forEach((group: SVGGElement): void => {
Array.from(group.children).forEach((circle: SVGCircleElement): void => {
circle.setAttribute('r', `${this.controlPointsSize / this.scale}`);
circle.setAttribute('stroke-width', `${consts.BASE_STROKE_WIDTH / this.scale}`);
});
});
}
public configure(configuration: Configuration): void {
this.controlPointsSize = configuration.controlPointsSize || consts.BASE_POINT_SIZE;
}
}

View File

@ -0,0 +1,203 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import {
DrawData, MergeData, SplitData, GroupData,
JoinData, SliceData, MasksEditData,
InteractionData as _InteractionData,
InteractionResult as _InteractionResult,
CanvasModel, CanvasModelImpl, RectDrawingMethod,
CuboidDrawingMethod, Configuration, Geometry, Mode,
HighlightSeverity as _HighlightSeverity, CanvasHint as _CanvasHint,
PolyEditData,
} from './canvasModel';
import { Master } from './master';
import { CanvasController, CanvasControllerImpl } from './canvasController';
import { CanvasView, CanvasViewImpl } from './canvasView';
import '../scss/canvas.scss';
interface Canvas {
html(): HTMLDivElement;
setup(frameData: any, objectStates: any[], zLayer?: number): void;
setupIssueRegions(issueRegions: Record<number, { hidden: boolean; points: number[] }>): void;
translateFromSVG(points: number[]): number[];
setupConflictRegions(clientID: number): number[];
activate(clientID: number | null, attributeID?: number): void;
highlight(clientIDs: number[] | null, severity: HighlightSeverity | null): void;
rotate(rotationAngle: number): void;
focus(clientID: number, padding?: number): void;
fit(): void;
grid(stepX: number, stepY: number): void;
interact(interactionData: InteractionData): void;
draw(drawData: DrawData): void;
edit(editData: MasksEditData | PolyEditData): void;
group(groupData: GroupData): void;
join(joinData: JoinData): void;
slice(sliceData: SliceData): void;
split(splitData: SplitData): void;
merge(mergeData: MergeData): void;
select(objectState: any): void;
fitCanvas(): void;
bitmap(enable: boolean): void;
selectRegion(enable: boolean): void;
dragCanvas(enable: boolean): void;
zoomCanvas(enable: boolean): void;
mode(): Mode;
cancel(): void;
configure(configuration: Configuration): void;
isAbleToChangeFrame(): boolean;
destroy(): void;
readonly geometry: Geometry;
}
class CanvasImpl implements Canvas {
private model: CanvasModel & Master;
private controller: CanvasController;
private view: CanvasView;
public constructor() {
this.model = new CanvasModelImpl();
this.controller = new CanvasControllerImpl(this.model);
this.view = new CanvasViewImpl(this.model, this.controller);
}
public html(): HTMLDivElement {
return this.view.html();
}
public setup(frameData: any, objectStates: any[], zLayer = 0): void {
this.model.setup(frameData, objectStates, zLayer);
}
public setupIssueRegions(issueRegions: Record<number, { hidden: boolean; points: number[] }>): void {
this.model.setupIssueRegions(issueRegions);
}
public translateFromSVG(points: number[]): number[] {
return this.view.translateFromSVG(points);
}
public setupConflictRegions(clientID: number): number[] {
return this.view.setupConflictRegions(clientID);
}
public fitCanvas(): void {
this.model.fitCanvas(this.view.html().clientWidth, this.view.html().clientHeight);
}
public bitmap(enable: boolean): void {
this.model.bitmap(enable);
}
public selectRegion(enable: boolean): void {
this.model.selectRegion(enable);
}
public dragCanvas(enable: boolean): void {
this.model.dragCanvas(enable);
}
public zoomCanvas(enable: boolean): void {
this.model.zoomCanvas(enable);
}
public activate(clientID: number | null, attributeID: number | null = null): void {
this.model.activate(clientID, attributeID);
}
public highlight(clientIDs: number[], severity: HighlightSeverity | null = null): void {
this.model.highlight(clientIDs, severity);
}
public rotate(rotationAngle: number): void {
this.model.rotate(rotationAngle);
}
public focus(clientID: number, padding = 0): void {
this.model.focus(clientID, padding);
}
public fit(): void {
this.model.fit();
}
public grid(stepX: number, stepY: number): void {
this.model.grid(stepX, stepY);
}
public interact(interactionData: InteractionData): void {
this.model.interact(interactionData);
}
public draw(drawData: DrawData): void {
this.model.draw(drawData);
}
public edit(editData: MasksEditData | PolyEditData): void {
this.model.edit(editData);
}
public split(splitData: SplitData): void {
this.model.split(splitData);
}
public group(groupData: GroupData): void {
this.model.group(groupData);
}
public join(joinData: JoinData): void {
this.model.join(joinData);
}
public slice(sliceData: SliceData): void {
this.model.slice(sliceData);
}
public merge(mergeData: MergeData): void {
this.model.merge(mergeData);
}
public select(objectState: any): void {
this.model.select(objectState);
}
public mode(): Mode {
return this.model.mode;
}
public cancel(): void {
this.model.cancel();
}
public configure(configuration: Configuration): void {
this.model.configure(configuration);
}
public isAbleToChangeFrame(): boolean {
return this.model.isAbleToChangeFrame();
}
public get geometry(): Geometry {
return this.model.geometry;
}
public destroy(): void {
this.model.destroy();
}
}
export type InteractionData = _InteractionData;
export type CanvasHint = _CanvasHint;
export type InteractionResult = _InteractionResult;
export type HighlightSeverity = _HighlightSeverity;
export {
CanvasImpl as Canvas, RectDrawingMethod, CuboidDrawingMethod, Mode as CanvasMode,
};

View File

@ -0,0 +1,183 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import {
CanvasModel,
Geometry,
Position,
FocusData,
ActiveElement,
DrawData,
MergeData,
SplitData,
GroupData,
JoinData,
SliceData,
Mode,
InteractionData,
Configuration,
MasksEditData,
HighlightedElements,
PolyEditData,
} from './canvasModel';
export interface CanvasController {
readonly objects: any[];
readonly issueRegions: Record<number, { hidden: boolean; points: number[] }>;
readonly zLayer: number | null;
readonly focusData: FocusData;
readonly activeElement: ActiveElement;
readonly highlightedElements: HighlightedElements;
readonly drawData: DrawData;
readonly editData: MasksEditData | PolyEditData;
readonly interactionData: InteractionData;
readonly mergeData: MergeData;
readonly splitData: SplitData;
readonly groupData: GroupData;
readonly joinData: JoinData;
readonly sliceData: SliceData;
readonly selected: any;
readonly configuration: Configuration;
mode: Mode;
geometry: Geometry;
zoom(x: number, y: number, deltaY: number): void;
draw(drawData: DrawData): void;
edit(editData: MasksEditData | PolyEditData): void;
enableDrag(x: number, y: number): void;
drag(x: number, y: number): void;
disableDrag(): void;
fit(): void;
}
export class CanvasControllerImpl implements CanvasController {
private model: CanvasModel;
private lastDragPosition: Position;
private isDragging: boolean;
public constructor(model: CanvasModel) {
this.model = model;
}
public zoom(x: number, y: number, deltaY: number): void {
this.model.zoom(x, y, deltaY);
}
public fit(): void {
this.model.fit();
}
public enableDrag(x: number, y: number): void {
this.lastDragPosition = {
x,
y,
};
this.isDragging = true;
}
public drag(x: number, y: number): void {
if (this.isDragging) {
const topOffset: number = y - this.lastDragPosition.y;
const leftOffset: number = x - this.lastDragPosition.x;
this.lastDragPosition = {
x,
y,
};
this.model.move(topOffset, leftOffset);
}
}
public disableDrag(): void {
this.isDragging = false;
}
public draw(drawData: DrawData): void {
this.model.draw(drawData);
}
public edit(editData: MasksEditData | PolyEditData): void {
this.model.edit(editData);
}
public get geometry(): Geometry {
return this.model.geometry;
}
public set geometry(geometry: Geometry) {
this.model.geometry = geometry;
}
public get zLayer(): number | null {
return this.model.zLayer;
}
public get issueRegions(): Record<number, { hidden: boolean; points: number[] }> {
return this.model.issueRegions;
}
public get objects(): any[] {
return this.model.objects;
}
public get focusData(): FocusData {
return this.model.focusData;
}
public get activeElement(): ActiveElement {
return this.model.activeElement;
}
public get highlightedElements(): HighlightedElements {
return this.model.highlightedElements;
}
public get drawData(): DrawData {
return this.model.drawData;
}
public get editData(): MasksEditData | PolyEditData {
return this.model.editData;
}
public get interactionData(): InteractionData {
return this.model.interactionData;
}
public get mergeData(): MergeData {
return this.model.mergeData;
}
public get splitData(): SplitData {
return this.model.splitData;
}
public get groupData(): GroupData {
return this.model.groupData;
}
public get joinData(): JoinData {
return this.model.joinData;
}
public get sliceData(): SliceData {
return this.model.sliceData;
}
public get selected(): any {
return this.model.selected;
}
public get configuration(): Configuration {
return this.model.configuration;
}
public set mode(value: Mode) {
this.model.mode = value;
}
public get mode(): Mode {
return this.model.mode;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,59 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
const BASE_STROKE_WIDTH = 1.25;
const BASE_GRID_WIDTH = 2;
const BASE_POINT_SIZE = 4;
const TEXT_MARGIN = 10;
const SIZE_THRESHOLD = 1;
const POINTS_STROKE_WIDTH = 1;
const POINTS_SELECTED_STROKE_WIDTH = 4;
const MIN_EDGE_LENGTH = 3;
const CUBOID_ACTIVE_EDGE_STROKE_WIDTH = 2.5;
const CUBOID_UNACTIVE_EDGE_STROKE_WIDTH = 1.75;
const UNDEFINED_ATTRIBUTE_VALUE = '__undefined__';
const ARROW_PATH = 'M13.162 6.284L.682.524a.483.483 0 0 0-.574.134.477.477 0 ' +
'0 0-.012.59L4.2 6.72.096 12.192a.479.479 0 0 0 .585.724l12.48-5.76a.48.48 0 0 0 0-.872z';
const BASE_PATTERN_SIZE = 5;
const SNAP_TO_ANGLE_RESIZE_DEFAULT = 0.1;
const SNAP_TO_ANGLE_RESIZE_SHIFT = 15;
const MINIMUM_TEXT_FONT_SIZE = 8;
const SKELETON_RECT_MARGIN = 20;
const DEFAULT_SHAPE_TEXT_SIZE = 12;
const DEFAULT_SHAPE_TEXT_CONTENT = 'id,label,attributes,source,descriptions';
const DEFAULT_SHAPE_TEXT_POSITION: 'auto' | 'center' = 'auto';
const DEFAULT_UNDEFINED_ATTR_VALUE = '__undefined__';
const CONFLICT_COLOR = '#ff4800';
const WARNING_COLOR = '#ff7301';
const SHADED_COLOR = '#808080';
export default {
BASE_STROKE_WIDTH,
BASE_GRID_WIDTH,
BASE_POINT_SIZE,
TEXT_MARGIN,
SIZE_THRESHOLD,
POINTS_STROKE_WIDTH,
POINTS_SELECTED_STROKE_WIDTH,
MIN_EDGE_LENGTH,
CUBOID_ACTIVE_EDGE_STROKE_WIDTH,
CUBOID_UNACTIVE_EDGE_STROKE_WIDTH,
UNDEFINED_ATTRIBUTE_VALUE,
ARROW_PATH,
BASE_PATTERN_SIZE,
SNAP_TO_ANGLE_RESIZE_DEFAULT,
SNAP_TO_ANGLE_RESIZE_SHIFT,
DEFAULT_SHAPE_TEXT_SIZE,
DEFAULT_SHAPE_TEXT_CONTENT,
DEFAULT_SHAPE_TEXT_POSITION,
DEFAULT_UNDEFINED_ATTR_VALUE,
MINIMUM_TEXT_FONT_SIZE,
SKELETON_RECT_MARGIN,
CONFLICT_COLOR,
WARNING_COLOR,
SHADED_COLOR,
};

View File

@ -0,0 +1,76 @@
// Copyright (C) 2020-2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
import * as SVG from 'svg.js';
import consts from './consts';
export default class Crosshair {
private x: SVG.Line | null;
private y: SVG.Line | null;
private canvas: SVG.Container | null;
public constructor() {
this.x = null;
this.y = null;
this.canvas = null;
}
public show(canvas: SVG.Container, x: number, y: number, scale: number): void {
if (this.canvas && this.canvas !== canvas) {
if (this.x) this.x.remove();
if (this.y) this.y.remove();
this.x = null;
this.y = null;
}
this.canvas = canvas;
this.x = this.canvas
.line(0, y, this.canvas.node.clientWidth, y)
.attr({
'stroke-width': consts.BASE_STROKE_WIDTH / (2 * scale),
})
.addClass('cvat_canvas_crosshair');
this.y = this.canvas
.line(x, 0, x, this.canvas.node.clientHeight)
.attr({
'stroke-width': consts.BASE_STROKE_WIDTH / (2 * scale),
})
.addClass('cvat_canvas_crosshair');
}
public hide(): void {
if (this.x) {
this.x.remove();
this.x = null;
}
if (this.y) {
this.y.remove();
this.y = null;
}
this.canvas = null;
}
public move(x: number, y: number): void {
if (this.x) {
this.x.attr({ y1: y, y2: y });
}
if (this.y) {
this.y.attr({ x1: x, x2: x });
}
}
public scale(scale: number): void {
if (this.x) {
this.x.attr('stroke-width', consts.BASE_STROKE_WIDTH / (2 * scale));
}
if (this.y) {
this.y.attr('stroke-width', consts.BASE_STROKE_WIDTH / (2 * scale));
}
}
}

View File

@ -0,0 +1,484 @@
// Copyright (C) 2021-2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
import consts from './consts';
import { Point } from './shared';
export enum Orientation {
LEFT = 'left',
RIGHT = 'right',
}
export function intersection(p1: Point, p2: Point, p3: Point, p4: Point): Point | null {
// Check if none of the lines are of length 0
const { x: x1, y: y1 } = p1;
const { x: x2, y: y2 } = p2;
const { x: x3, y: y3 } = p3;
const { x: x4, y: y4 } = p4;
if ((x1 === x2 && y1 === y2) || (x3 === x4 && y3 === y4)) {
return null;
}
const denominator = ((y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1));
// Lines are parallel
if (Math.abs(denominator) < Number.EPSILON) {
return null;
}
const ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denominator;
// Return a object with the x and y coordinates of the intersection
return { x: x1 + ua * (x2 - x1), y: y1 + ua * (y2 - y1) };
}
export class Equation {
private a: number;
private b: number;
private c: number;
public constructor(p1: Point, p2: Point) {
this.a = p1.y - p2.y;
this.b = p2.x - p1.x;
this.c = this.b * p1.y + this.a * p1.x;
}
// get the line equation in actual coordinates
public getY(x: number): number {
return (this.c - this.a * x) / this.b;
}
}
export class Figure {
private indices: number[];
private allPoints: Point[];
public constructor(indices: number[], points: Point[]) {
this.indices = indices;
this.allPoints = points;
}
public get points(): Point[] {
const points = [];
for (const index of this.indices) {
points.push(this.allPoints[index]);
}
return points;
}
// sets the point for a given edge, points must be given in
// array form in the same ordering as the getter
// if you only need to update a subset of the points,
// simply put null for the points you want to keep
public set points(newPoints) {
const oldPoints = this.allPoints;
for (let i = 0; i < newPoints.length; i += 1) {
if (newPoints[i] !== null) {
oldPoints[this.indices[i]] = { x: newPoints[i].x, y: newPoints[i].y };
}
}
}
}
export class Edge extends Figure {
public getEquation(): Equation {
return new Equation(this.points[0], this.points[1]);
}
}
export class CuboidModel {
public points: Point[];
private fr: Edge;
private fl: Edge;
private dr: Edge;
private dl: Edge;
private ft: Edge;
private rt: Edge;
private lt: Edge;
private dt: Edge;
private fb: Edge;
private rb: Edge;
private lb: Edge;
private db: Edge;
public edgeList: Edge[];
private front: Figure;
private right: Figure;
private dorsal: Figure;
private left: Figure;
private top: Figure;
private bot: Figure;
public facesList: Figure[];
public vpl: Point | null;
public vpr: Point | null;
public orientation: Orientation;
public constructor(points?: Point[]) {
this.points = points;
this.initEdges();
this.initFaces();
this.updateVanishingPoints(false);
this.buildBackEdge(false);
this.updatePoints();
this.updateOrientation();
}
public getPoints(): Point[] {
return this.points;
}
public setPoints(points: (Point | null)[]): void {
points.forEach((point: Point | null, i: number): void => {
if (point !== null) {
this.points[i].x = point.x;
this.points[i].y = point.y;
}
});
}
public updateOrientation(): void {
if (this.dl.points[0].x > this.fl.points[0].x) {
this.orientation = Orientation.LEFT;
} else {
this.orientation = Orientation.RIGHT;
}
}
public updatePoints(): void {
// making sure that the edges are vertical
this.fr.points[0].x = this.fr.points[1].x;
this.fl.points[0].x = this.fl.points[1].x;
this.dr.points[0].x = this.dr.points[1].x;
this.dl.points[0].x = this.dl.points[1].x;
}
public computeSideEdgeConstraints(edge: any): any {
const midLength = this.fr.points[1].y - this.fr.points[0].y - 1;
const minY = edge.points[1].y - midLength;
const maxY = edge.points[0].y + midLength;
const y1 = edge.points[0].y;
const y2 = edge.points[1].y;
const miny1 = y2 - midLength;
const maxy1 = y2 - consts.MIN_EDGE_LENGTH;
const miny2 = y1 + consts.MIN_EDGE_LENGTH;
const maxy2 = y1 + midLength;
return {
constraint: {
minY,
maxY,
},
y1Range: {
max: maxy1,
min: miny1,
},
y2Range: {
max: maxy2,
min: miny2,
},
};
}
// boolean value parameter controls which edges should be used to recalculate vanishing points
private updateVanishingPoints(buildright: boolean): void {
let leftEdge = [];
let rightEdge = [];
let midEdge = [];
if (buildright) {
leftEdge = this.fr.points;
rightEdge = this.dl.points;
midEdge = this.fl.points;
} else {
leftEdge = this.fl.points;
rightEdge = this.dr.points;
midEdge = this.fr.points;
}
this.vpl = intersection(leftEdge[0], midEdge[0], leftEdge[1], midEdge[1]);
this.vpr = intersection(rightEdge[0], midEdge[0], rightEdge[1], midEdge[1]);
if (this.vpl === null) {
// shift the edge slightly to avoid edge case
leftEdge[0].y -= 0.001;
leftEdge[0].x += 0.001;
leftEdge[1].x += 0.001;
this.vpl = intersection(leftEdge[0], midEdge[0], leftEdge[1], midEdge[1]);
}
if (this.vpr === null) {
// shift the edge slightly to avoid edge case
rightEdge[0].y -= 0.001;
rightEdge[0].x -= 0.001;
rightEdge[1].x -= 0.001;
this.vpr = intersection(leftEdge[0], midEdge[0], leftEdge[1], midEdge[1]);
}
}
private initEdges(): void {
this.fl = new Edge([0, 1], this.points);
this.fr = new Edge([2, 3], this.points);
this.dr = new Edge([4, 5], this.points);
this.dl = new Edge([6, 7], this.points);
this.ft = new Edge([0, 2], this.points);
this.lt = new Edge([0, 6], this.points);
this.rt = new Edge([2, 4], this.points);
this.dt = new Edge([6, 4], this.points);
this.fb = new Edge([1, 3], this.points);
this.lb = new Edge([1, 7], this.points);
this.rb = new Edge([3, 5], this.points);
this.db = new Edge([7, 5], this.points);
this.edgeList = [
this.fl,
this.fr,
this.dl,
this.dr,
this.ft,
this.lt,
this.rt,
this.dt,
this.fb,
this.lb,
this.rb,
this.db,
];
}
private initFaces(): void {
this.front = new Figure([0, 1, 3, 2], this.points);
this.right = new Figure([2, 3, 5, 4], this.points);
this.dorsal = new Figure([4, 5, 7, 6], this.points);
this.left = new Figure([6, 7, 1, 0], this.points);
this.top = new Figure([0, 2, 4, 6], this.points);
this.bot = new Figure([1, 3, 5, 7], this.points);
this.facesList = [this.front, this.right, this.dorsal, this.left];
}
private buildBackEdge(buildright: boolean): void {
this.updateVanishingPoints(buildright);
let leftPoints = [];
let rightPoints = [];
let topIndex = 0;
let botIndex = 0;
if (buildright) {
leftPoints = this.dl.points;
rightPoints = this.fr.points;
topIndex = 4;
botIndex = 5;
} else {
leftPoints = this.dr.points;
rightPoints = this.fl.points;
topIndex = 6;
botIndex = 7;
}
const vpLeft = this.vpl;
const vpRight = this.vpr;
let p1 = intersection(vpLeft, leftPoints[0], vpRight, rightPoints[0]);
let p2 = intersection(vpLeft, leftPoints[1], vpRight, rightPoints[1]);
if (p1 === null) {
p1 = { x: p2.x, y: vpLeft.y };
} else if (p2 === null) {
p2 = { x: p1.x, y: vpLeft.y };
}
this.points[topIndex] = { x: p1.x, y: p1.y };
this.points[botIndex] = { x: p2.x, y: p2.y };
// Making sure that the vertical edges stay vertical
this.updatePoints();
}
}
function sortPointsClockwise(points: any[]): any[] {
points.sort((a, b): number => a.y - b.y);
// Get center y
const cy = (points[0].y + points[points.length - 1].y) / 2;
// Sort from right to left
points.sort((a, b): number => b.x - a.x);
// Get center x
const cx = (points[0].x + points[points.length - 1].x) / 2;
// Center point
const center = {
x: cx,
y: cy,
};
// Starting angle used to reference other angles
let startAng: number | undefined;
points.forEach((point): void => {
let ang = Math.atan2(point.y - center.y, point.x - center.x);
if (!startAng) {
startAng = ang;
// ensure that all points are clockwise of the start point
} else if (ang < startAng) {
ang += Math.PI * 2;
}
// eslint-disable-next-line no-param-reassign
point.angle = ang; // add the angle to the point
});
// first sort clockwise
points.sort((a, b): number => a.angle - b.angle);
return points.reverse();
}
function setupCuboidPoints(points: Point[]): any[] {
let left;
let right;
let left2;
let right2;
let p1;
let p2;
let p3;
let p4;
const height = Math.abs(points[0].x - points[1].x) < Math.abs(points[1].x - points[2].x) ?
Math.abs(points[1].y - points[0].y) : Math.abs(points[1].y - points[2].y);
// separate into left and right point
// we pick the first and third point because we know assume they will be on
// opposite corners
if (points[0].x < points[2].x) {
[left, , right] = points;
} else {
[right, , left] = points;
}
// get other 2 points using the given height
if (left.y < right.y) {
left2 = { x: left.x, y: left.y + height };
right2 = { x: right.x, y: right.y - height };
} else {
left2 = { x: left.x, y: left.y - height };
right2 = { x: right.x, y: right.y + height };
}
// get the vector for the last point relative to the previous point
const vec = {
x: points[3].x - points[2].x,
y: points[3].y - points[2].y,
};
if (left.y < left2.y) {
p1 = left;
p2 = left2;
} else {
p1 = left2;
p2 = left;
}
if (right.y < right2.y) {
p3 = right;
p4 = right2;
} else {
p3 = right2;
p4 = right;
}
const p5 = { x: p3.x + vec.x, y: p3.y + vec.y + 0.1 };
const p6 = { x: p4.x + vec.x, y: p4.y + vec.y - 0.1 };
const p7 = { x: p1.x + vec.x, y: p1.y + vec.y + 0.1 };
const p8 = { x: p2.x + vec.x, y: p2.y + vec.y - 0.1 };
p1.y += 0.1;
return [p1, p2, p3, p4, p5, p6, p7, p8];
}
export function cuboidFrom4Points(flattenedPoints: any[]): any[] {
const points: Point[] = [];
for (let i = 0; i < 4; i++) {
const [x, y] = flattenedPoints.slice(i * 2, i * 2 + 2);
points.push({ x, y });
}
const unsortedPlanePoints = points.slice(0, 3);
function rotate(array: any[], times: number): void {
let t = times;
while (t--) {
const temp = array.shift();
array.push(temp);
}
}
const plane2 = {
p1: points[0],
p2: points[0],
p3: points[0],
p4: points[0],
};
// completing the plane
const vector = {
x: points[2].x - points[1].x,
y: points[2].y - points[1].y,
};
// sorting the first plane
unsortedPlanePoints.push({
x: points[0].x + vector.x,
y: points[0].y + vector.y,
});
const sortedPlanePoints = sortPointsClockwise(unsortedPlanePoints);
let leftIndex = 0;
for (let i = 0; i < 4; i++) {
leftIndex = sortedPlanePoints[i].x < sortedPlanePoints[leftIndex].x ? i : leftIndex;
}
rotate(sortedPlanePoints, leftIndex);
const plane1 = {
p1: sortedPlanePoints[0],
p2: sortedPlanePoints[1],
p3: sortedPlanePoints[2],
p4: sortedPlanePoints[3],
};
const vec = {
x: points[3].x - points[2].x,
y: points[3].y - points[2].y,
};
// determine the orientation
const angle = Math.atan2(vec.y, vec.x);
// making the other plane
plane2.p1 = { x: plane1.p1.x + vec.x, y: plane1.p1.y + vec.y };
plane2.p2 = { x: plane1.p2.x + vec.x, y: plane1.p2.y + vec.y };
plane2.p3 = { x: plane1.p3.x + vec.x, y: plane1.p3.y + vec.y };
plane2.p4 = { x: plane1.p4.x + vec.x, y: plane1.p4.y + vec.y };
let cuboidPoints;
// right
if (Math.abs(angle) < Math.PI / 2 - 0.1) {
cuboidPoints = setupCuboidPoints(points);
// left
} else if (Math.abs(angle) > Math.PI / 2 + 0.1) {
cuboidPoints = setupCuboidPoints(points);
// down
} else if (angle > 0) {
cuboidPoints = [plane1.p1, plane2.p1, plane1.p2, plane2.p2, plane1.p3, plane2.p3, plane1.p4, plane2.p4];
cuboidPoints[0].y += 0.1;
cuboidPoints[4].y += 0.1;
// up
} else {
cuboidPoints = [plane2.p1, plane1.p1, plane2.p2, plane1.p2, plane2.p3, plane1.p3, plane2.p4, plane1.p4];
cuboidPoints[0].y += 0.1;
cuboidPoints[4].y += 0.1;
}
return cuboidPoints.reduce((arr: number[], point: any): number[] => {
arr.push(point.x);
arr.push(point.y);
return arr;
}, []);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,479 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import * as SVG from 'svg.js';
import 'svg.select.js';
import consts from './consts';
import { translateFromSVG, pointsToNumberArray } from './shared';
import { PolyEditData, Geometry, Configuration } from './canvasModel';
import { AutoborderHandler } from './autoborderHandler';
export interface EditHandler {
edit(editData: PolyEditData): void;
transform(geometry: Geometry): void;
configure(configuration: Configuration): void;
cancel(): void;
enabled: boolean;
shapeType: string;
}
export class EditHandlerImpl implements EditHandler {
private onEditDone: (state: any, points: number[]) => void;
private autoborderHandler: AutoborderHandler;
private geometry: Geometry | null;
private canvas: SVG.Container;
private editData: PolyEditData | null;
private editedShape: SVG.Shape | null;
private editLine: SVG.PolyLine | null;
private clones: SVG.Polygon[];
private controlPointsSize: number;
private autobordersEnabled: boolean;
private intelligentCutEnabled: boolean;
private outlinedBorders: string;
private isEditing: boolean;
private setupTrailingPoint(circle: SVG.Circle): void {
circle.on('mouseenter', (): void => {
circle.attr({
'stroke-width': consts.POINTS_SELECTED_STROKE_WIDTH / this.geometry.scale,
});
});
circle.on('mouseleave', (): void => {
circle.attr({
'stroke-width': consts.POINTS_STROKE_WIDTH / this.geometry.scale,
});
});
circle.on('mousedown', (e: MouseEvent): void => {
if (e.button !== 0) return;
this.edit({ enabled: false });
});
}
private startEdit(): void {
// get started coordinates
const [clientX, clientY] = translateFromSVG(
(this.canvas.node as any) as SVGSVGElement,
this.editedShape.attr('points').split(' ')[this.editData.pointID].split(','),
);
// generate mouse event
const dummyEvent = new MouseEvent('mousedown', {
bubbles: true,
cancelable: true,
clientX,
clientY,
});
// Add ability to edit shapes by sliding
// We need to remember last drawn point
// to implementation of slide drawing
const lastDrawnPoint: {
x: number;
y: number;
} = {
x: null,
y: null,
};
this.canvas.on('mousemove.edit', (e: MouseEvent): void => {
if (e.shiftKey && ['polygon', 'polyline'].includes(this.editData.state.shapeType)) {
if (lastDrawnPoint.x === null || lastDrawnPoint.y === null) {
(this.editLine as any).draw('point', e);
} else {
const deltaThreshold = 15;
const dxsqr = (e.clientX - lastDrawnPoint.x) ** 2;
const dysqr = (e.clientY - lastDrawnPoint.y) ** 2;
const delta = Math.sqrt(dxsqr + dysqr);
if (delta > deltaThreshold) {
(this.editLine as any).draw('point', e);
}
}
}
});
this.editLine = (this.canvas as any).polyline();
if (this.editData.state.shapeType === 'polyline') {
(this.editLine as any).on('drawupdate', (e: CustomEvent): void => {
const circle = (e.target as any).instance.remember('_paintHandler').set.last();
if (circle) this.setupTrailingPoint(circle);
});
}
(this.editLine as any)
.addClass('cvat_canvas_shape_drawing')
.style({
'pointer-events': 'none',
'fill-opacity': 0,
})
.attr({
'data-origin-client-id': this.editData.state.clientID,
stroke: this.editedShape.attr('stroke'),
})
.on('drawstart drawpoint', (e: CustomEvent): void => {
this.transform(this.geometry);
lastDrawnPoint.x = e.detail.event.clientX;
lastDrawnPoint.y = e.detail.event.clientY;
})
.on('drawupdate', (): void => this.transform(this.geometry))
.draw(dummyEvent, { snapToGrid: 0.1 });
if (this.editData.state.shapeType === 'points') {
this.editLine.attr('stroke-width', 0);
(this.editLine as any).draw('undo');
}
this.setupEditEvents();
if (this.autobordersEnabled) {
this.autoborderHandler.autoborder(true, this.editLine, this.editData.state.clientID);
}
}
private setupEditEvents(): void {
this.canvas.on('mousedown.edit', (e: MouseEvent): void => {
if (e.button === 0 && !e.altKey) {
(this.editLine as any).draw('point', e);
} else if (e.button === 2 && this.editLine) {
if (this.editData.state.shapeType === 'points' || this.editLine.attr('points').split(' ').length > 2) {
(this.editLine as any).draw('undo');
}
}
});
}
private selectPolygon(shape: SVG.Polygon): void {
const { offset } = this.geometry;
const points = pointsToNumberArray(shape.attr('points')).map((coord: number): number => coord - offset);
const { state } = this.editData;
this.edit({
enabled: false,
});
this.onEditDone(state, points);
}
private stopEdit(e: MouseEvent): void {
if (!this.editLine) {
return;
}
// Get stop point and all points
const stopPointID = Array.prototype.indexOf.call((e.target as HTMLElement).parentElement.children, e.target);
const oldPoints = this.editedShape.attr('points').trim().split(' ');
const linePoints = this.editLine.attr('points').trim().split(' ');
if (this.editLine.attr('points') === '0,0') {
this.cancel();
return;
}
// Compute new point array
const [start, stop] = [this.editData.pointID, stopPointID].sort((a, b): number => +a - +b);
if (this.editData.state.shapeType !== 'polygon') {
let points = null;
const { offset } = this.geometry;
if (this.editData.state.shapeType === 'polyline') {
if (start !== this.editData.pointID) {
linePoints.reverse();
}
points = oldPoints
.slice(0, start)
.concat(linePoints)
.concat(oldPoints.slice(stop + 1));
} else {
points = oldPoints.concat(linePoints.slice(0, -1));
}
points = pointsToNumberArray(points.join(' ')).map((coord: number): number => coord - offset);
const { state } = this.editData;
this.edit({
enabled: false,
});
this.onEditDone(state, points);
return;
}
const cutIndexes1 = oldPoints.reduce(
(acc: number[], _: string, i: number): number[] => (i >= stop || i <= start ? [...acc, i] : acc),
[],
);
const cutIndexes2 = oldPoints.reduce(
(acc: number[], _: string, i: number): number[] => (i <= stop && i >= start ? [...acc, i] : acc),
[],
);
const curveLength = (indexes: number[]): number => {
const points = indexes
.map((index: number): string => oldPoints[index])
.map((point: string): string[] => point.split(','))
.map((point: string[]): number[] => [+point[0], +point[1]]);
let length = 0;
for (let i = 1; i < points.length; i++) {
const dxsqr = (points[i][0] - points[i - 1][0]) ** 2;
const dysqr = (points[i][1] - points[i - 1][1]) ** 2;
length += Math.sqrt(dxsqr + dysqr);
}
return length;
};
const pointsCriteria = cutIndexes1.length > cutIndexes2.length;
const lengthCriteria = curveLength(cutIndexes1) > curveLength(cutIndexes2);
if (start !== this.editData.pointID) {
linePoints.reverse();
}
const firstPart = oldPoints
.slice(0, start)
.concat(linePoints)
.concat(oldPoints.slice(stop + 1));
const secondPart = oldPoints.slice(start, stop).concat(linePoints.slice(1).reverse());
if (firstPart.length < 3 || secondPart.length < 3) {
this.cancel();
return;
}
// We do not need these events any more
this.canvas.off('mousedown.edit');
this.canvas.off('mousemove.edit');
(this.editLine as any).draw('stop');
this.editLine.remove();
this.editLine = null;
if (pointsCriteria && lengthCriteria && this.intelligentCutEnabled) {
this.clones.push(this.canvas.polygon(firstPart.join(' ')));
this.selectPolygon(this.clones[0]);
// left indexes1 and
} else if (!pointsCriteria && !lengthCriteria && this.intelligentCutEnabled) {
this.clones.push(this.canvas.polygon(secondPart.join(' ')));
this.selectPolygon(this.clones[0]);
} else {
for (const points of [firstPart, secondPart]) {
this.clones.push(
this.canvas
.polygon(points.join(' '))
.attr('fill', this.editedShape.attr('fill'))
.attr('fill-opacity', '0.5')
.addClass('cvat_canvas_shape'),
);
}
for (const clone of this.clones) {
clone.on('click', (): void => this.selectPolygon(clone));
clone
.on('mouseenter', (): void => {
clone.addClass('cvat_canvas_shape_splitting');
})
.on('mouseleave', (): void => {
clone.removeClass('cvat_canvas_shape_splitting');
});
}
}
}
private setupPoints(enabled: boolean): void {
const stopEdit = this.stopEdit.bind(this);
const getGeometry = (): Geometry => this.geometry;
const fill = this.editedShape.attr('fill') || 'inherit';
if (enabled) {
(this.editedShape as any).selectize(true, {
deepSelect: true,
pointSize: (2 * this.controlPointsSize) / getGeometry().scale,
rotationPoint: false,
pointType(cx: number, cy: number): SVG.Circle {
const circle: SVG.Circle = this.nested
.circle(this.options.pointSize)
.stroke('black')
.fill(fill)
.center(cx, cy)
.attr({
'stroke-width': consts.POINTS_STROKE_WIDTH / getGeometry().scale,
});
circle.node.addEventListener('mouseenter', (): void => {
circle.attr({
'stroke-width': consts.POINTS_SELECTED_STROKE_WIDTH / getGeometry().scale,
});
circle.node.addEventListener('click', stopEdit);
circle.addClass('cvat_canvas_selected_point');
});
circle.node.addEventListener('mouseleave', (): void => {
circle.attr({
'stroke-width': consts.POINTS_STROKE_WIDTH / getGeometry().scale,
});
circle.node.removeEventListener('click', stopEdit);
circle.removeClass('cvat_canvas_selected_point');
});
return circle;
},
});
} else {
(this.editedShape as any).selectize(false, {
deepSelect: true,
});
}
}
private release(): void {
this.canvas.off('mousedown.edit');
this.canvas.off('mousemove.edit');
this.autoborderHandler.autoborder(false);
this.isEditing = false;
if (this.editedShape) {
this.setupPoints(false);
this.editedShape.remove();
this.editedShape = null;
}
if (this.editLine) {
(this.editLine as any).draw('stop');
this.editLine.remove();
this.editLine = null;
}
if (this.clones.length) {
for (const clone of this.clones) {
clone.remove();
}
this.clones = [];
}
}
private initEditing(): void {
this.editedShape = this.canvas
.select(`#cvat_canvas_shape_${this.editData.state.clientID}`).first()
.clone().attr('stroke', this.outlinedBorders);
this.setupPoints(true);
this.startEdit();
this.isEditing = true;
// draw points for this with selected and start editing till another point is clicked
// click one of two parts to remove (in case of polygon only)
// else we can start draw polyline
// after we have got shape and points, we are waiting for second point pressed on this shape
}
private closeEditing(): void {
if (this.isEditing && this.editData.state.shapeType === 'polyline') {
const { offset } = this.geometry;
const head = this.editedShape.attr('points').split(' ').slice(0, this.editData.pointID).join(' ');
const stringifiedPoints = `${head} ${this.editLine.node.getAttribute('points').slice(0, -2)}`;
const points = pointsToNumberArray(stringifiedPoints)
.slice(0, -2)
.map((coord: number): number => coord - offset);
if (points.length >= 2 * 2) { // minimumPoints * 2
const { state } = this.editData;
this.onEditDone(state, points);
}
}
this.release();
}
public constructor(
onEditDone: EditHandlerImpl['onEditDone'],
canvas: SVG.Container,
autoborderHandler: AutoborderHandler,
) {
this.autoborderHandler = autoborderHandler;
this.autobordersEnabled = false;
this.intelligentCutEnabled = false;
this.controlPointsSize = consts.BASE_POINT_SIZE;
this.outlinedBorders = 'black';
this.onEditDone = onEditDone;
this.canvas = canvas;
this.editData = null;
this.editedShape = null;
this.editLine = null;
this.geometry = null;
this.clones = [];
this.isEditing = false;
}
public edit(editData: any): void {
if (editData.enabled) {
if (['polygon', 'polyline', 'points'].includes(editData.state.shapeType)) {
this.editData = editData;
this.initEditing();
} else {
this.cancel();
}
} else {
this.closeEditing();
this.editData = editData;
}
}
public cancel(): void {
this.release();
this.onEditDone(null, null);
}
get enabled(): boolean {
return this.isEditing;
}
get shapeType(): string {
return this.editData.state.shapeType;
}
public configure(configuration: Configuration): void {
this.autobordersEnabled = configuration.autoborders;
this.outlinedBorders = configuration.outlinedBorders || 'black';
if (this.editedShape) {
this.editedShape.attr('stroke', this.outlinedBorders);
}
if (this.editLine) {
this.editLine.attr('stroke', this.outlinedBorders);
if (this.autobordersEnabled) {
this.autoborderHandler.autoborder(true, this.editLine, this.editData.state.clientID);
} else {
this.autoborderHandler.autoborder(false);
}
}
this.controlPointsSize = configuration.controlPointsSize || consts.BASE_POINT_SIZE;
this.intelligentCutEnabled = configuration.intelligentPolygonCrop;
}
public transform(geometry: Geometry): void {
this.geometry = geometry;
if (this.editedShape) {
this.editedShape.attr({
'stroke-width': consts.BASE_STROKE_WIDTH / geometry.scale,
});
}
if (this.editLine) {
if (this.editData.state.shapeType !== 'points') {
this.editLine.attr({
'stroke-width': consts.BASE_STROKE_WIDTH / geometry.scale,
});
}
const paintHandler = this.editLine.remember('_paintHandler');
for (const point of paintHandler.set.members) {
point.attr('stroke-width', `${consts.POINTS_STROKE_WIDTH / geometry.scale}`);
point.attr('r', `${this.controlPointsSize / geometry.scale}`);
}
}
}
}

View File

@ -0,0 +1,75 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import { GroupData } from './canvasModel';
import { ObjectSelector, SelectionFilter } from './objectSelector';
export interface GroupHandler {
group(groupData: GroupData, selectionFilter: SelectionFilter): void;
select(state: any): void;
cancel(): void;
}
export class GroupHandlerImpl implements GroupHandler {
private onSelectDone: (objects?: any[], duration?: number) => void;
private selector: ObjectSelector;
private initialized: boolean;
private statesToBeGrouped: any[];
private startTimestamp: number;
private release(): void {
this.selector.disable();
this.initialized = false;
}
private initGrouping(selectionFilter: SelectionFilter): void {
this.statesToBeGrouped = [];
this.selector.enable((selected) => {
this.statesToBeGrouped = selected;
}, selectionFilter);
this.initialized = true;
this.startTimestamp = Date.now();
}
private closeGrouping(): void {
if (this.initialized) {
const { statesToBeGrouped } = this;
this.release();
if (statesToBeGrouped.length) {
this.onSelectDone(statesToBeGrouped, Date.now() - this.startTimestamp);
} else {
this.onSelectDone();
}
}
}
public constructor(
onSelectDone: GroupHandlerImpl['onSelectDone'],
selector: ObjectSelector,
) {
this.onSelectDone = onSelectDone;
this.selector = selector;
this.statesToBeGrouped = [];
this.initialized = false;
this.startTimestamp = Date.now();
}
public group(groupData: GroupData, selectionFilter: SelectionFilter): void {
if (groupData.enabled) {
this.initGrouping(selectionFilter);
} else {
this.closeGrouping();
}
}
public select(objectState: any): void {
this.selector.push(objectState);
}
public cancel(): void {
this.release();
this.onSelectDone();
}
}

View File

@ -0,0 +1,485 @@
// Copyright (C) 2020-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import * as SVG from 'svg.js';
import consts from './consts';
import Crosshair from './crosshair';
import {
translateToSVG, PropType, stringifyPoints, translateToCanvas, expandChannels, imageDataToDataURL,
} from './shared';
import {
InteractionData, InteractionResult, Geometry, Configuration,
} from './canvasModel';
export interface InteractionHandler {
transform(geometry: Geometry): void;
interact(interactData: InteractionData): void;
configure(config: Configuration): void;
destroy(): void;
cancel(): void;
}
export class InteractionHandlerImpl implements InteractionHandler {
private onInteraction: (shapes: InteractionResult[] | null, shapesUpdated?: boolean, isDone?: boolean) => void;
private geometry: Geometry;
private canvas: SVG.Container;
private interactionData: InteractionData;
private cursorPosition: { x: number; y: number };
private shapesWereUpdated: boolean;
private interactionShapes: SVG.Shape[];
private currentInteractionShape: SVG.Shape | null;
private crosshair: Crosshair;
private intermediateShape: PropType<InteractionData, 'intermediateShape'>;
private drawnIntermediateShape: SVG.Shape;
private controlPointsSize: number;
private selectedShapeOpacity: number;
private cancelled: boolean;
private prepareResult(): InteractionResult[] {
return this.interactionShapes.map(
(shape: SVG.Shape): InteractionResult => {
if (shape.type === 'circle') {
const points = [(shape as SVG.Circle).cx(), (shape as SVG.Circle).cy()];
return {
points: points.map((coord: number): number => coord - this.geometry.offset),
shapeType: 'points',
button: shape.attr('stroke') === 'green' ? 0 : 2,
};
}
const bbox = ((shape.node as any) as SVGRectElement).getBBox();
const points = [bbox.x, bbox.y, bbox.x + bbox.width, bbox.y + bbox.height];
return {
points: points.map((coord: number): number => coord - this.geometry.offset),
shapeType: 'rectangle',
button: 0,
};
},
);
}
private shouldRaiseEvent(): boolean {
const { interactionData, interactionShapes, shapesWereUpdated } = this;
const { minPosVertices, minNegVertices, enabled } = interactionData;
const positiveShapes = interactionShapes.filter(
(shape: SVG.Shape): boolean => (shape as any).attr('stroke') === 'green',
);
const negativeShapes = interactionShapes.filter(
(shape: SVG.Shape): boolean => (shape as any).attr('stroke') !== 'green',
);
const somethingWasDrawn = interactionShapes.some((shape) => shape.type === 'rect') || positiveShapes.length;
if (interactionData.shapeType === 'rectangle') {
return enabled && !!interactionShapes.length;
}
const minPosVerticesDefined = Number.isInteger(minPosVertices);
const minNegVerticesDefined = Number.isInteger(minNegVertices) && minNegVertices >= 0;
const minPosVerticesAchieved = !minPosVerticesDefined || minPosVertices <= positiveShapes.length;
const minNegVerticesAchieved = !minNegVerticesDefined || minNegVertices <= negativeShapes.length;
const minimumVerticesAchieved = minPosVerticesAchieved && minNegVerticesAchieved;
return enabled && somethingWasDrawn && minimumVerticesAchieved && shapesWereUpdated;
}
private addCrosshair(): void {
const { x, y } = this.cursorPosition;
this.crosshair.show(this.canvas, x, y, this.geometry.scale);
}
private removeCrosshair(): void {
this.crosshair.hide();
}
private interactPoints(): void {
const eventListener = (e: MouseEvent): void => {
if ((e.button === 0 || (e.button === 2 && this.interactionData.minNegVertices >= 0)) && !e.altKey) {
e.preventDefault();
const [cx, cy] = translateToSVG((this.canvas.node as any) as SVGSVGElement, [e.clientX, e.clientY]);
if (!this.isWithinFrame(cx, cy)) return;
this.currentInteractionShape = this.canvas
.circle((this.controlPointsSize * 2) / this.geometry.scale)
.center(cx, cy)
.fill('white')
.stroke(e.button === 0 ? 'green' : 'red')
.addClass('cvat_interaction_point')
.attr({
'stroke-width': consts.POINTS_STROKE_WIDTH / this.geometry.scale,
});
this.interactionShapes.push(this.currentInteractionShape);
this.shapesWereUpdated = true;
if (this.shouldRaiseEvent()) {
this.onInteraction(this.prepareResult(), true, false);
}
const self = this.currentInteractionShape;
self.on('mouseenter', (): void => {
if (this.interactionData.allowRemoveOnlyLast) {
if (this.interactionShapes.indexOf(self) !== this.interactionShapes.length - 1) {
return;
}
}
self.addClass('cvat_canvas_removable_interaction_point');
self.attr({
'stroke-width': consts.POINTS_SELECTED_STROKE_WIDTH / this.geometry.scale,
r: (this.controlPointsSize * 1.5) / this.geometry.scale,
});
self.on('mousedown', (_e: MouseEvent): void => {
_e.preventDefault();
_e.stopPropagation();
self.remove();
this.shapesWereUpdated = true;
this.interactionShapes = this.interactionShapes.filter(
(shape: SVG.Shape): boolean => shape !== self,
);
if (this.interactionData.startWithBox && this.interactionShapes.length === 1) {
this.interactionShapes[0].style({ visibility: '' });
}
const shouldRaiseEvent = this.shouldRaiseEvent();
if (shouldRaiseEvent) {
this.onInteraction(this.prepareResult(), true, false);
}
});
});
self.on('mouseleave', (): void => {
self.removeClass('cvat_canvas_removable_interaction_point');
self.attr({
'stroke-width': consts.POINTS_STROKE_WIDTH / this.geometry.scale,
r: this.controlPointsSize / this.geometry.scale,
});
self.off('mousedown');
});
}
};
// clear this listener in release()
this.canvas.on('mousedown.interaction', eventListener);
}
private interactRectangle(shouldFinish: boolean, onContinue?: () => void): void {
let initialized = false;
const eventListener = (e: MouseEvent): void => {
if (e.button === 0 && !e.altKey) {
if (!initialized) {
(this.currentInteractionShape as any).draw(e, { snapToGrid: 0.1 });
initialized = true;
} else {
(this.currentInteractionShape as any).draw(e);
}
}
};
this.currentInteractionShape = this.canvas.rect();
this.canvas.on('mousedown.interaction', eventListener);
this.currentInteractionShape
.on('drawstop', (): void => {
if (this.cancelled) {
return;
}
this.canvas.off('mousedown.interaction', eventListener);
this.interactionShapes.push(this.currentInteractionShape);
this.shapesWereUpdated = true;
if (shouldFinish) {
this.interact({ enabled: false });
} else if (this.shouldRaiseEvent()) {
this.onInteraction(this.prepareResult(), true, false);
}
if (onContinue) {
onContinue();
}
})
.addClass('cvat_canvas_shape_drawing')
.attr({
'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale,
})
.fill({ opacity: this.selectedShapeOpacity, color: 'white' });
}
private initInteraction(): void {
if (this.interactionData.crosshair) {
this.addCrosshair();
} else if (this.crosshair) {
this.removeCrosshair();
}
}
private startInteraction(): void {
if (this.interactionData.shapeType === 'rectangle') {
this.interactRectangle(true);
} else if (this.interactionData.shapeType === 'points') {
if (this.interactionData.startWithBox) {
this.interactRectangle(false, (): void => this.interactPoints());
} else {
this.interactPoints();
}
} else {
throw new Error('Interactor implementation supports only rectangle and points');
}
}
private release(): void {
if (this.currentInteractionShape && this.currentInteractionShape.remember('_paintHandler')) {
// Cancel active drawing first
(this.currentInteractionShape as any).draw('cancel');
}
if (this.drawnIntermediateShape) {
this.drawnIntermediateShape.remove();
this.drawnIntermediateShape = null;
}
if (this.crosshair) {
this.removeCrosshair();
}
this.canvas.off('mousedown.interaction');
this.interactionShapes.forEach((shape: SVG.Shape): SVG.Shape => shape.remove());
this.interactionShapes = [];
if (this.currentInteractionShape) {
this.currentInteractionShape.remove();
this.currentInteractionShape = null;
}
}
private isWithinFrame(x: number, y: number): boolean {
const { offset, image } = this.geometry;
const { width, height } = image;
const [imageX, imageY] = [Math.round(x - offset), Math.round(y - offset)];
return imageX >= 0 && imageX < width && imageY >= 0 && imageY < height;
}
private updateIntermediateShape(): void {
const { intermediateShape, geometry } = this;
if (!intermediateShape) {
if (this.drawnIntermediateShape) {
this.drawnIntermediateShape.remove();
}
return;
}
const { shapeType, points } = intermediateShape;
if (this.drawnIntermediateShape?.type === 'polygon' && shapeType === 'polygon') {
const isInvalidShape = shapeType === 'polygon' && points.length < 3 * 2;
this.drawnIntermediateShape.attr('points', stringifyPoints(translateToCanvas(geometry.offset, points)));
this.drawnIntermediateShape.stroke(isInvalidShape ? 'red' : 'black');
return;
}
this.drawnIntermediateShape?.remove();
if (shapeType === 'polygon') {
const isInvalidShape = shapeType === 'polygon' && points.length < 3 * 2;
this.drawnIntermediateShape = this.canvas
.polygon(stringifyPoints(translateToCanvas(geometry.offset, points)))
.attr({
'color-rendering': 'optimizeQuality',
'shape-rendering': 'geometricprecision',
'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale,
stroke: isInvalidShape ? 'red' : 'black',
})
.fill({ opacity: this.selectedShapeOpacity, color: 'white' })
.addClass('cvat_canvas_interact_intermediate_shape');
this.canvas.node.prepend(this.drawnIntermediateShape.node);
} else if (shapeType === 'mask') {
const [left, top, right, bottom] = points.slice(-4);
const imageBitmap = expandChannels(255, 255, 255, points);
const image = this.canvas.image().attr({
'color-rendering': 'optimizeQuality',
'shape-rendering': 'geometricprecision',
'pointer-events': 'none',
opacity: 0.5,
}).addClass('cvat_canvas_interact_intermediate_shape');
image.move(this.geometry.offset + left, this.geometry.offset + top);
this.drawnIntermediateShape = image;
this.canvas.node.prepend(this.drawnIntermediateShape.node);
imageDataToDataURL(
imageBitmap,
right - left + 1,
bottom - top + 1,
(dataURL: string) => new Promise((resolve, reject) => {
image.loaded(() => {
resolve();
});
image.error(() => {
reject();
});
image.load(dataURL);
}),
);
} else {
throw new Error(
`Shape type "${shapeType}" was not implemented at interactionHandler::updateIntermediateShape`,
);
}
}
private visualComponentsChanged(interactionData: InteractionData): boolean {
const allowedKeys = ['enabled', 'crosshair'];
if (Object.keys(interactionData).every((key: string): boolean => allowedKeys.includes(key))) {
if (this.interactionData.crosshair !== undefined && interactionData.crosshair !== undefined &&
this.interactionData.crosshair !== interactionData.crosshair) {
return true;
}
}
return false;
}
public constructor(
onInteraction: (
shapes: InteractionResult[] | null,
shapesUpdated?: boolean,
isDone?: boolean,
) => void,
canvas: SVG.Container,
geometry: Geometry,
configuration: Configuration,
) {
this.onInteraction = (shapes: InteractionResult[] | null, shapesUpdated?: boolean, isDone?: boolean): void => {
this.shapesWereUpdated = false;
onInteraction(shapes, shapesUpdated, isDone);
};
this.canvas = canvas;
this.geometry = geometry;
this.shapesWereUpdated = false;
this.interactionShapes = [];
this.interactionData = { enabled: false };
this.currentInteractionShape = null;
this.crosshair = new Crosshair();
this.intermediateShape = null;
this.drawnIntermediateShape = null;
this.controlPointsSize = configuration.controlPointsSize;
this.selectedShapeOpacity = configuration.selectedShapeOpacity;
this.cursorPosition = {
x: 0,
y: 0,
};
this.canvas.on('mousemove.interaction', (e: MouseEvent): void => {
const [x, y] = translateToSVG((this.canvas.node as any) as SVGSVGElement, [e.clientX, e.clientY]);
this.cursorPosition = { x, y };
if (this.crosshair) {
this.crosshair.move(x, y);
}
if (this.interactionData.enableSliding && this.interactionShapes.length) {
if (this.isWithinFrame(x, y)) {
this.onInteraction(
[
...this.prepareResult(),
{
points: [x - this.geometry.offset, y - this.geometry.offset],
shapeType: 'points',
button: 0,
},
],
true,
false,
);
}
}
});
}
public transform(geometry: Geometry): void {
this.geometry = geometry;
if (this.crosshair) {
this.crosshair.scale(this.geometry.scale);
}
const shapesToBeScaled = this.currentInteractionShape ?
[...this.interactionShapes, this.currentInteractionShape] :
[...this.interactionShapes];
for (const shape of shapesToBeScaled) {
if (shape.type === 'circle') {
if (shape.hasClass('cvat_canvas_removable_interaction_point')) {
(shape as SVG.Circle).radius((this.controlPointsSize * 1.5) / this.geometry.scale);
shape.attr('stroke-width', consts.POINTS_SELECTED_STROKE_WIDTH / this.geometry.scale);
} else {
(shape as SVG.Circle).radius(this.controlPointsSize / this.geometry.scale);
shape.attr('stroke-width', consts.POINTS_STROKE_WIDTH / this.geometry.scale);
}
} else {
shape.attr('stroke-width', consts.BASE_STROKE_WIDTH / this.geometry.scale);
}
}
if (this.drawnIntermediateShape) {
this.drawnIntermediateShape.stroke({ width: consts.BASE_STROKE_WIDTH / this.geometry.scale });
}
}
public interact(interactionData: InteractionData): void {
if (interactionData.enabled) {
this.cancelled = false;
if (interactionData.intermediateShape) {
this.intermediateShape = interactionData.intermediateShape;
this.updateIntermediateShape();
if (this.interactionData.startWithBox) {
this.interactionShapes[0].style({ visibility: 'hidden' });
}
} else if (this.visualComponentsChanged(interactionData)) {
this.interactionData = { ...this.interactionData, ...interactionData };
this.initInteraction();
} else if (interactionData.enabled) {
this.interactionData = interactionData;
this.initInteraction();
this.startInteraction();
}
} else {
if (this.currentInteractionShape && this.currentInteractionShape.remember('_paintHandler')) {
// Finish active drawing first if possible
(this.currentInteractionShape as any).draw('stop');
}
this.onInteraction(this.prepareResult(), this.shouldRaiseEvent(), true);
this.release();
this.interactionData = interactionData;
}
}
public configure(configuration: Configuration): void {
this.controlPointsSize = configuration.controlPointsSize;
this.selectedShapeOpacity = configuration.selectedShapeOpacity;
if (this.drawnIntermediateShape) {
this.drawnIntermediateShape.fill({
opacity: configuration.selectedShapeOpacity,
});
}
// when interactRectangle
if (this.currentInteractionShape && this.currentInteractionShape.type === 'rect') {
this.currentInteractionShape.fill({ opacity: configuration.selectedShapeOpacity });
}
// when interactPoints with startwithbbox
if (this.interactionShapes[0] && this.interactionShapes[0].type === 'rect') {
this.interactionShapes[0].fill({ opacity: configuration.selectedShapeOpacity });
}
}
public cancel(): void {
this.cancelled = true;
this.release();
this.onInteraction(null);
}
public destroy(): void {
// nothing to release
}
}

View File

@ -0,0 +1,753 @@
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import { fabric } from 'fabric';
import debounce from 'lodash/debounce';
import {
DrawData, MasksEditData, Geometry, Configuration, BrushTool, ColorBy, Position,
} from './canvasModel';
import consts from './consts';
import { DrawHandler } from './drawHandler';
import {
PropType, computeWrappingBox, zipChannels, expandChannels, imageDataToDataURL,
} from './shared';
interface WrappingBBox {
left: number;
top: number;
right: number;
bottom: number;
}
export interface MasksHandler {
draw(drawData: DrawData): void;
edit(state: MasksEditData): void;
configure(configuration: Configuration): void;
transform(geometry: Geometry): void;
cancel(): void;
enabled: boolean;
}
export class MasksHandlerImpl implements MasksHandler {
private onDrawDone: (
data: object | null,
duration?: number,
continueDraw?: boolean,
prevDrawData?: DrawData,
) => void;
private onDrawRepeat: (data: DrawData) => void;
private onEditStart: (state: any) => void;
private onEditDone: (state: any, points: number[]) => void;
private vectorDrawHandler: DrawHandler;
private redraw: number | null;
private isDrawing: boolean;
private isEditing: boolean;
private isInsertion: boolean;
private isMouseDown: boolean;
private isBrushSizeChanging: boolean;
private resizeBrushToolLatestX: number;
private brushMarker: fabric.Rect | fabric.Circle | null;
private drawablePolygon: null | fabric.Polygon;
private isPolygonDrawing: boolean;
private drawnObjects: (fabric.Polygon | fabric.Circle | fabric.Rect | fabric.Line | fabric.Image)[];
private tool: DrawData['brushTool'] | null;
private drawData: DrawData | null;
private canvas: fabric.Canvas;
private editData: MasksEditData | null;
private colorBy: ColorBy;
private latestMousePos: Position;
private startTimestamp: number;
private geometry: Geometry;
private drawingOpacity: number;
private isHidden: boolean;
private keepDrawnPolygon(): void {
const canvasWrapper = this.canvas.getElement().parentElement;
canvasWrapper.style.pointerEvents = '';
canvasWrapper.style.zIndex = '';
this.isPolygonDrawing = false;
this.vectorDrawHandler.draw({ enabled: false }, this.geometry);
}
private removeBrushMarker(): void {
if (this.brushMarker) {
this.canvas.remove(this.brushMarker);
this.brushMarker = null;
this.canvas.renderAll();
}
}
private setupBrushMarker(): void {
if (['brush', 'eraser'].includes(this.tool.type)) {
const common = {
evented: false,
selectable: false,
opacity: 0.75,
left: this.latestMousePos.x - this.tool.size / 2,
top: this.latestMousePos.y - this.tool.size / 2,
strokeWidth: 1,
stroke: 'white',
};
this.brushMarker = this.tool.form === 'circle' ? new fabric.Circle({
...common,
radius: Math.round(this.tool.size / 2),
}) : new fabric.Rect({
...common,
width: this.tool.size,
height: this.tool.size,
});
this.canvas.defaultCursor = 'none';
this.canvas.add(this.brushMarker);
} else {
this.canvas.defaultCursor = 'inherit';
}
}
private releaseCanvasWrapperCSS(): void {
const canvasWrapper = this.canvas.getElement().parentElement;
canvasWrapper.style.pointerEvents = '';
canvasWrapper.style.zIndex = '';
canvasWrapper.style.display = '';
}
private releasePaste(): void {
this.releaseCanvasWrapperCSS();
this.canvas.clear();
this.canvas.renderAll();
this.isInsertion = false;
this.drawnObjects = this.createDrawnObjectsArray();
this.onDrawDone(null);
}
private releaseDraw(): void {
this.removeBrushMarker();
this.releaseCanvasWrapperCSS();
if (this.isPolygonDrawing) {
this.isPolygonDrawing = false;
this.vectorDrawHandler.cancel();
}
this.canvas.clear();
this.canvas.renderAll();
this.isDrawing = false;
this.isInsertion = false;
this.redraw = null;
this.drawnObjects = this.createDrawnObjectsArray();
}
private releaseEdit(): void {
this.removeBrushMarker();
this.releaseCanvasWrapperCSS();
if (this.isPolygonDrawing) {
this.isPolygonDrawing = false;
this.vectorDrawHandler.cancel();
}
this.canvas.clear();
this.canvas.renderAll();
this.isEditing = false;
this.drawnObjects = this.createDrawnObjectsArray();
this.onEditDone(null, null);
}
private getStateColor(state: any): string {
if (this.colorBy === ColorBy.INSTANCE) {
return state.color;
}
if (this.colorBy === ColorBy.LABEL) {
return state.label.color;
}
return state.group.color;
}
private getDrawnObjectsWrappingBox(): WrappingBBox {
type BoundingRect = ReturnType<PropType<fabric.Polygon, 'getBoundingRect'>>;
type TwoCornerBox = Pick<BoundingRect, 'top' | 'left'> & { right: number; bottom: number };
const { width, height } = this.geometry.image;
const wrappingBbox = this.drawnObjects
.map((obj) => {
if (obj instanceof fabric.Polygon) {
const bbox = computeWrappingBox(obj.points
.reduce(((acc, val) => {
acc.push(val.x, val.y);
return acc;
}), []));
return {
left: bbox.xtl,
top: bbox.ytl,
width: bbox.width,
height: bbox.height,
};
}
if (obj instanceof fabric.Image) {
return {
left: obj.left,
top: obj.top,
width: obj.width,
height: obj.height,
};
}
return obj.getBoundingRect();
})
.reduce((acc: TwoCornerBox, rect: BoundingRect) => {
acc.top = Math.floor(Math.max(0, Math.min(rect.top, acc.top)));
acc.left = Math.floor(Math.max(0, Math.min(rect.left, acc.left)));
acc.bottom = Math.floor(Math.min(height - 1, Math.max(rect.top + rect.height, acc.bottom)));
acc.right = Math.floor(Math.min(width - 1, Math.max(rect.left + rect.width, acc.right)));
return acc;
}, {
left: Number.MAX_SAFE_INTEGER,
top: Number.MAX_SAFE_INTEGER,
right: Number.MIN_SAFE_INTEGER,
bottom: Number.MIN_SAFE_INTEGER,
});
return wrappingBbox;
}
private imageDataFromCanvas(wrappingBBox: WrappingBBox): Uint8ClampedArray {
const imageData = this.canvas.toCanvasElement()
.getContext('2d').getImageData(
wrappingBBox.left,
wrappingBBox.top,
wrappingBBox.right - wrappingBBox.left + 1,
wrappingBBox.bottom - wrappingBBox.top + 1,
).data;
return imageData;
}
private updateHidden(value: boolean) {
this.isHidden = value;
// Need to update style of upper canvas explicitly because update of default cursor is not applied immediately
// https://github.com/fabricjs/fabric.js/issues/1456
const newOpacity = value ? '0' : '';
const newCursor = value ? 'inherit' : 'none';
this.canvas.getElement().parentElement.style.opacity = newOpacity;
const upperCanvas = this.canvas.getElement().parentElement.querySelector('.upper-canvas') as HTMLElement;
if (upperCanvas) {
upperCanvas.style.cursor = newCursor;
}
this.canvas.defaultCursor = newCursor;
}
private updateBrushTools(brushTool?: BrushTool, opts: Partial<BrushTool> = {}): void {
if (this.isPolygonDrawing) {
// tool was switched from polygon to brush for example
this.keepDrawnPolygon();
}
this.removeBrushMarker();
if (brushTool) {
if (brushTool.color && this.tool?.color !== brushTool.color) {
const color = fabric.Color.fromHex(brushTool.color);
for (const object of this.drawnObjects) {
if (object instanceof fabric.Line) {
const alpha = +object.stroke.split(',')[3].slice(0, -1);
color.setAlpha(alpha);
object.set({ stroke: color.toRgba() });
} else if (
object instanceof fabric.Rect ||
object instanceof fabric.Polygon ||
object instanceof fabric.Circle
) {
const alpha = +(object.fill as string).split(',')[3].slice(0, -1);
color.setAlpha(alpha);
(object as fabric.Object).set({ fill: color.toRgba() });
}
}
this.canvas.renderAll();
}
this.tool = { ...brushTool, ...opts };
if (this.isDrawing || this.isEditing) {
this.setupBrushMarker();
}
this.updateBlockedTools();
}
if (this.tool?.type?.startsWith('polygon-')) {
this.isPolygonDrawing = true;
this.vectorDrawHandler.draw({
enabled: true,
shapeType: 'polygon',
onDrawDone: (data: { points: number[] } | null) => {
if (!data) return;
const points = data.points.reduce((acc: fabric.Point[], _: number, idx: number) => {
if (idx % 2) {
acc.push(new fabric.Point(data.points[idx - 1], data.points[idx]));
}
return acc;
}, []);
const color = fabric.Color.fromHex(this.tool.color);
color.setAlpha(this.tool.type === 'polygon-minus' ? 1 : this.drawingOpacity);
const polygon = new fabric.Polygon(points, {
fill: color.toRgba(),
selectable: false,
objectCaching: false,
absolutePositioned: true,
globalCompositeOperation: this.tool.type === 'polygon-minus' ? 'destination-out' : 'xor',
});
this.canvas.add(polygon);
this.drawnObjects.push(polygon);
this.canvas.renderAll();
},
}, this.geometry);
const canvasWrapper = this.canvas.getElement().parentElement as HTMLDivElement;
canvasWrapper.style.pointerEvents = 'none';
canvasWrapper.style.zIndex = '0';
}
}
private updateBlockedTools(): void {
if (this.drawnObjects.length === 0) {
this.tool.onBlockUpdated({
eraser: true,
'polygon-minus': true,
});
return;
}
const wrappingBbox = this.getDrawnObjectsWrappingBox();
if (this.brushMarker) {
this.canvas.remove(this.brushMarker);
}
const imageData = this.imageDataFromCanvas(wrappingBbox);
if (this.brushMarker) {
this.canvas.add(this.brushMarker);
}
const rle = zipChannels(imageData);
const emptyMask = rle.length < 2;
this.tool.onBlockUpdated({
eraser: emptyMask,
'polygon-minus': emptyMask,
});
}
private createDrawnObjectsArray(): MasksHandlerImpl['drawnObjects'] {
const drawnObjects = [];
const updateBlockedToolsDebounced = debounce(this.updateBlockedTools.bind(this), 250);
return new Proxy(drawnObjects, {
set(target, property, value) {
target[property] = value;
updateBlockedToolsDebounced();
return true;
},
});
}
public constructor(
onDrawDone: MasksHandlerImpl['onDrawDone'],
onDrawRepeat: MasksHandlerImpl['onDrawRepeat'],
onEditStart: MasksHandlerImpl['onEditStart'],
onEditDone: MasksHandlerImpl['onEditDone'],
vectorDrawHandler: DrawHandler,
canvas: HTMLCanvasElement,
) {
this.redraw = null;
this.isDrawing = false;
this.isEditing = false;
this.isMouseDown = false;
this.isBrushSizeChanging = false;
this.isPolygonDrawing = false;
this.drawData = null;
this.editData = null;
this.drawingOpacity = 0.5;
this.brushMarker = null;
this.isHidden = false;
this.colorBy = ColorBy.LABEL;
this.onDrawDone = onDrawDone;
this.onDrawRepeat = onDrawRepeat;
this.onEditDone = onEditDone;
this.onEditStart = onEditStart;
this.vectorDrawHandler = vectorDrawHandler;
this.canvas = new fabric.Canvas(canvas, {
containerClass: 'cvat_masks_canvas_wrapper',
fireRightClick: true,
selection: false,
defaultCursor: 'inherit',
});
this.canvas.imageSmoothingEnabled = false;
this.drawnObjects = this.createDrawnObjectsArray();
this.canvas.getElement().parentElement.addEventListener('contextmenu', (e: MouseEvent) => e.preventDefault());
this.latestMousePos = { x: -1, y: -1 };
window.document.addEventListener('mouseup', () => {
this.isMouseDown = false;
this.isBrushSizeChanging = false;
});
this.canvas.on('mouse:down', (options: fabric.IEvent<MouseEvent>) => {
const { isDrawing, isEditing, isInsertion } = this;
this.isMouseDown = (isDrawing || isEditing) && options.e.button === 0 && !options.e.altKey;
this.isBrushSizeChanging = (isDrawing || isEditing) && options.e.button === 2 && options.e.altKey;
if (isInsertion) {
const continueInserting = options.e.ctrlKey;
const wrappingBbox = this.getDrawnObjectsWrappingBox();
const imageData = this.imageDataFromCanvas(wrappingBbox);
const rle = zipChannels(imageData);
rle.push(wrappingBbox.left, wrappingBbox.top, wrappingBbox.right, wrappingBbox.bottom);
this.onDrawDone({
occluded: this.drawData.initialState.occluded,
attributes: { ...this.drawData.initialState.attributes },
color: this.drawData.initialState.color,
objectType: this.drawData.initialState.objectType,
shapeType: this.drawData.shapeType,
points: rle,
label: this.drawData.initialState.label,
}, Date.now() - this.startTimestamp, continueInserting, this.drawData);
if (!continueInserting) {
this.releasePaste();
}
} else {
this.canvas.fire('mouse:move', options);
}
});
this.canvas.on('mouse:move', (e: fabric.IEvent<MouseEvent>) => {
const { image: { width: imageWidth, height: imageHeight } } = this.geometry;
const { angle } = this.geometry;
let [x, y] = [e.pointer.x, e.pointer.y];
if (angle === 180) {
[x, y] = [imageWidth - x, imageHeight - y];
} else if (angle === 270) {
[x, y] = [imageWidth - (y / imageHeight) * imageWidth, (x / imageWidth) * imageHeight];
} else if (angle === 90) {
[x, y] = [(y / imageHeight) * imageWidth, imageHeight - (x / imageWidth) * imageHeight];
}
const position = { x, y };
const {
tool, isMouseDown, isInsertion, isBrushSizeChanging,
} = this;
if (isInsertion) {
const [object] = this.drawnObjects;
if (object && object instanceof fabric.Image) {
object.left = position.x - object.width / 2;
object.top = position.y - object.height / 2;
this.canvas.renderAll();
}
}
if (isBrushSizeChanging && ['brush', 'eraser'].includes(tool?.type)) {
const xDiff = e.pointer.x - this.resizeBrushToolLatestX;
let onUpdateConfiguration = null;
if (this.isDrawing) {
onUpdateConfiguration = this.drawData.onUpdateConfiguration;
} else if (this.isEditing) {
onUpdateConfiguration = this.editData.onUpdateConfiguration;
}
if (onUpdateConfiguration) {
onUpdateConfiguration({
brushTool: {
size: Math.trunc(Math.max(1, this.tool.size + xDiff)),
},
});
}
this.resizeBrushToolLatestX = e.pointer.x;
e.e.stopPropagation();
return;
}
if (this.brushMarker) {
this.brushMarker.left = position.x - tool.size / 2;
this.brushMarker.top = position.y - tool.size / 2;
this.canvas.bringToFront(this.brushMarker);
this.canvas.renderAll();
}
if (isMouseDown && !this.isHidden && !isBrushSizeChanging && ['brush', 'eraser'].includes(tool?.type)) {
const color = fabric.Color.fromHex(tool.color);
color.setAlpha(tool.type === 'eraser' ? 1 : 0.5);
const commonProperties = {
selectable: false,
evented: false,
globalCompositeOperation: tool.type === 'eraser' ? 'destination-out' : 'xor',
};
const shapeProperties = {
...commonProperties,
fill: color.toRgba(),
left: position.x - tool.size / 2,
top: position.y - tool.size / 2,
};
let shape: fabric.Circle | fabric.Rect | null = null;
if (tool.form === 'circle') {
shape = new fabric.Circle({
...shapeProperties,
radius: Math.round(tool.size / 2),
});
} else if (tool.form === 'square') {
shape = new fabric.Rect({
...shapeProperties,
width: tool.size,
height: tool.size,
});
}
this.canvas.add(shape);
if (['brush', 'eraser'].includes(tool?.type)) {
this.drawnObjects.push(shape);
}
// add line to smooth the mask
if (this.latestMousePos.x !== -1 && this.latestMousePos.y !== -1) {
const dx = position.x - this.latestMousePos.x;
const dy = position.y - this.latestMousePos.y;
if (Math.sqrt(dx ** 2 + dy ** 2) > tool.size / 2) {
const line = new fabric.Line([
this.latestMousePos.x - tool.size / 2,
this.latestMousePos.y - tool.size / 2,
position.x - tool.size / 2,
position.y - tool.size / 2,
], {
...commonProperties,
stroke: color.toRgba(),
strokeWidth: tool.size,
strokeLineCap: tool.form === 'circle' ? 'round' : 'square',
});
this.canvas.add(line);
if (['brush', 'eraser'].includes(tool?.type)) {
this.drawnObjects.push(line);
}
}
}
this.canvas.renderAll();
} else if (tool?.type.startsWith('polygon-') && this.drawablePolygon) {
// update the polygon position
const points = this.drawablePolygon.get('points');
if (points.length) {
points[points.length - 1].setX(e.e.offsetX);
points[points.length - 1].setY(e.e.offsetY);
}
this.canvas.renderAll();
}
this.latestMousePos.x = position.x;
this.latestMousePos.y = position.y;
this.resizeBrushToolLatestX = position.x;
});
}
public configure(configuration: Configuration): void {
this.colorBy = configuration.colorBy;
if (this.isHidden !== configuration.hideEditedObject) {
this.updateHidden(configuration.hideEditedObject);
}
}
public transform(geometry: Geometry): void {
this.geometry = geometry;
const {
scale, angle, image: { width, height }, top, left,
} = geometry;
const topCanvas = this.canvas.getElement().parentElement as HTMLDivElement;
if (this.canvas.width !== width || this.canvas.height !== height) {
this.canvas.setHeight(height);
this.canvas.setWidth(width);
this.canvas.setDimensions({ width, height });
}
topCanvas.style.top = `${top}px`;
topCanvas.style.left = `${left}px`;
topCanvas.style.transform = `scale(${scale}) rotate(${angle}deg)`;
if (this.drawablePolygon) {
this.drawablePolygon.set('strokeWidth', consts.BASE_STROKE_WIDTH / scale);
this.canvas.renderAll();
}
}
public draw(drawData: DrawData): void {
if (drawData.enabled && drawData.shapeType === 'mask') {
if (!this.isInsertion && drawData.initialState?.shapeType === 'mask') {
// initialize inserting pipeline if not started
const { points } = drawData.initialState;
const color = fabric.Color.fromHex(this.getStateColor(drawData.initialState)).getSource();
const [left, top, right, bottom] = points.slice(-4);
const imageBitmap = expandChannels(color[0], color[1], color[2], points);
imageDataToDataURL(
imageBitmap,
right - left + 1,
bottom - top + 1,
(dataURL: string) => new Promise((resolve) => {
fabric.Image.fromURL(dataURL, (image: fabric.Image) => {
try {
image.selectable = false;
image.evented = false;
image.globalCompositeOperation = 'xor';
image.opacity = 0.5;
this.canvas.add(image);
/*
when we paste a mask, we do not need additional logic implemented
in MasksHandlerImpl::createDrawnObjectsArray.push using JS Proxy
because we will not work with any drawing tools here, and it will cause the issue
because this.tools may be undefined here
when it is used inside the push custom implementation
*/
this.drawnObjects = [image];
this.canvas.renderAll();
} finally {
resolve();
}
}, { left, top });
}));
this.isInsertion = true;
} else {
this.updateBrushTools(drawData.brushTool);
if (!this.isDrawing) {
// initialize drawing pipeline if not started
this.isDrawing = true;
this.redraw = drawData.redraw || null;
}
}
this.canvas.getElement().parentElement.style.display = 'block';
this.startTimestamp = Date.now();
}
if (!drawData.enabled && this.isDrawing) {
try {
if (this.drawnObjects.length) {
const wrappingBbox = this.getDrawnObjectsWrappingBox();
this.removeBrushMarker(); // remove brush marker from final mask
const imageData = this.imageDataFromCanvas(wrappingBbox);
const rle = zipChannels(imageData);
rle.push(wrappingBbox.left, wrappingBbox.top, wrappingBbox.right, wrappingBbox.bottom);
const isEmptyMask = rle.length < 6;
if (isEmptyMask) {
this.onDrawDone(null);
} else {
this.onDrawDone({
shapeType: this.drawData.shapeType,
points: rle,
...(Number.isInteger(this.redraw) ? { clientID: this.redraw } : {}),
}, Date.now() - this.startTimestamp, drawData.continue, this.drawData);
}
} else {
this.onDrawDone(null);
}
} finally {
this.releaseDraw();
}
if (drawData.continue) {
const newDrawData = {
...this.drawData,
brushTool: { ...this.tool },
...drawData,
enabled: true,
shapeType: 'mask',
};
this.onDrawRepeat({ enabled: true, shapeType: 'mask' });
this.onDrawRepeat(newDrawData);
return;
}
}
this.drawData = drawData;
}
public edit(editData: MasksEditData): void {
if (editData.enabled && editData.state.shapeType === 'mask') {
if (!this.isEditing) {
// start editing pipeline if not started yet
this.canvas.getElement().parentElement.style.display = 'block';
const { points } = editData.state;
const color = fabric.Color.fromHex(this.getStateColor(editData.state)).getSource();
const [left, top, right, bottom] = points.slice(-4);
const imageBitmap = expandChannels(color[0], color[1], color[2], points);
imageDataToDataURL(
imageBitmap,
right - left + 1,
bottom - top + 1,
(dataURL: string) => new Promise((resolve) => {
fabric.Image.fromURL(dataURL, (image: fabric.Image) => {
try {
image.selectable = false;
image.evented = false;
image.globalCompositeOperation = 'xor';
image.opacity = 0.5;
this.canvas.add(image);
this.drawnObjects.push(image);
this.canvas.renderAll();
} finally {
resolve();
}
}, { left, top });
}));
this.isEditing = true;
this.startTimestamp = Date.now();
this.onEditStart(editData.state);
}
}
this.updateBrushTools(
editData.brushTool,
editData.state ? { color: this.getStateColor(editData.state) } : {},
);
if (!editData.enabled && this.isEditing) {
try {
if (this.drawnObjects.length) {
const wrappingBbox = this.getDrawnObjectsWrappingBox();
this.removeBrushMarker(); // remove brush marker from final mask
const imageData = this.imageDataFromCanvas(wrappingBbox);
const rle = zipChannels(imageData);
rle.push(wrappingBbox.left, wrappingBbox.top, wrappingBbox.right, wrappingBbox.bottom);
const isEmptyMask = rle.length < 6;
if (isEmptyMask) {
this.onEditDone(null, null);
} else {
this.onEditDone(this.editData.state, rle);
}
}
} finally {
this.releaseEdit();
}
}
this.editData = editData;
}
get enabled(): boolean {
return this.isDrawing || this.isEditing || this.isInsertion;
}
public cancel(): void {
if (this.isDrawing || this.isInsertion) {
this.releaseDraw();
}
if (this.isEditing) {
this.releaseEdit();
}
}
}

View File

@ -0,0 +1,30 @@
// Copyright (C) 2019-2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
export interface Master {
subscribe(listener: Listener): void;
notify(reason: string): void;
}
export interface Listener {
notify(master: Master, reason: string): void;
}
export class MasterImpl implements Master {
private listeners: Listener[];
public constructor() {
this.listeners = [];
}
public subscribe(listener: Listener): void {
this.listeners.push(listener);
}
public notify(reason: string): void {
for (const listener of this.listeners) {
listener.notify(this, reason);
}
}
}

View File

@ -0,0 +1,156 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import * as SVG from 'svg.js';
import { MergeData } from './canvasModel';
export interface MergeHandler {
merge(mergeData: MergeData): void;
select(state: any): void;
cancel(): void;
repeatSelection(): void;
}
export class MergeHandlerImpl implements MergeHandler {
// callback is used to notify about merging end
private onMergeDone: (objects: any[] | null, duration?: number) => void;
private onFindObject: (event: MouseEvent) => void;
private startTimestamp: number;
private canvas: SVG.Container;
private initialized: boolean;
private statesToBeMerged: any[]; // are being merged
private highlightedShapes: Record<number, SVG.Shape>;
private constraints: {
labelID: number;
shapeType: string;
} | null;
private addConstraints(): void {
const shape = this.statesToBeMerged[0];
this.constraints = {
labelID: shape.label.id,
shapeType: shape.shapeType,
};
}
private removeConstraints(): void {
this.constraints = null;
}
private checkConstraints(state: any): boolean {
return (
!this.constraints ||
(state.label.id === this.constraints.labelID && state.shapeType === this.constraints.shapeType)
);
}
private release(): void {
this.removeConstraints();
this.canvas.node.removeEventListener('click', this.onFindObject);
for (const state of this.statesToBeMerged) {
const shape = this.highlightedShapes[state.clientID];
shape.removeClass('cvat_canvas_shape_merging');
}
this.statesToBeMerged = [];
this.highlightedShapes = {};
this.initialized = false;
}
private initMerging(): void {
this.canvas.node.addEventListener('click', this.onFindObject);
this.startTimestamp = Date.now();
this.initialized = true;
}
private closeMerging(): void {
if (this.initialized) {
const { statesToBeMerged } = this;
this.release();
if (statesToBeMerged.length > 1) {
this.onMergeDone(statesToBeMerged, Date.now() - this.startTimestamp);
} else {
this.onMergeDone(null);
// here is a cycle
// onMergeDone => controller => model => view => closeMerging
// one call of closeMerging is unuseful, but it's okey
}
}
}
public constructor(
onMergeDone: MergeHandlerImpl['onMergeDone'],
onFindObject: MergeHandlerImpl['onFindObject'],
canvas: SVG.Container,
) {
this.onMergeDone = onMergeDone;
this.onFindObject = onFindObject;
this.startTimestamp = Date.now();
this.canvas = canvas;
this.statesToBeMerged = [];
this.highlightedShapes = {};
this.constraints = null;
this.initialized = false;
}
public merge(mergeData: MergeData): void {
if (mergeData.enabled) {
this.initMerging();
} else {
this.closeMerging();
}
}
public select(objectState: any): void {
if (objectState.shapeType === 'mask') {
// masks can not be merged
return;
}
const stateIndexes = this.statesToBeMerged.map((state): number => state.clientID);
const stateFrames = this.statesToBeMerged.map((state): number => state.frame);
const includes = stateIndexes.indexOf(objectState.clientID);
if (includes !== -1) {
const shape = this.highlightedShapes[objectState.clientID];
this.statesToBeMerged.splice(includes, 1);
if (shape) {
delete this.highlightedShapes[objectState.clientID];
shape.removeClass('cvat_canvas_shape_merging');
}
if (!this.statesToBeMerged.length) {
this.removeConstraints();
}
} else {
const shape = this.canvas.select(`#cvat_canvas_shape_${objectState.clientID}`).first();
if (shape && this.checkConstraints(objectState) && !stateFrames.includes(objectState.frame)) {
this.statesToBeMerged.push(objectState);
this.highlightedShapes[objectState.clientID] = shape;
shape.addClass('cvat_canvas_shape_merging');
if (this.statesToBeMerged.length === 1) {
this.addConstraints();
}
}
}
}
public repeatSelection(): void {
for (const objectState of this.statesToBeMerged) {
const shape = this.canvas.select(`#cvat_canvas_shape_${objectState.clientID}`).first();
if (shape) {
this.highlightedShapes[objectState.clientID] = shape;
shape.addClass('cvat_canvas_shape_merging');
}
}
}
public cancel(): void {
this.release();
this.onMergeDone(null);
// here is a cycle
// onMergeDone => controller => model => view => closeMerging
// one call of closeMerging is unuseful, but it's okey
}
}

View File

@ -0,0 +1,288 @@
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import * as SVG from 'svg.js';
import { expandChannels, imageDataToDataURL, translateToSVG } from './shared';
import { Geometry } from './canvasModel';
import consts from './consts';
export interface SelectionFilter {
objectType?: string[];
shapeType?: string[];
maxCount?: number;
}
export interface ObjectSelector {
enable(
callback: (selected: ObjectState[]) => void,
filter?: SelectionFilter,
): void;
transform(geometry: Geometry): void;
push(state: ObjectState): void;
disable(): void;
resetSelected(): void;
}
export type ObjectState = any;
export class ObjectSelectorImpl implements ObjectSelector {
private selectionFilter: SelectionFilter | null;
private canvas: SVG.Container;
private selectionRect: SVG.Rect;
private geometry: Geometry;
private isEnabled: boolean;
private mouseDownPosition: { x: number; y: number; };
private selectedObjects: Record<number, ObjectState>;
private resetAppearance: Record<number, () => void>;
private findObjectOnClick: (event: MouseEvent) => void;
private getStates: () => ObjectState[];
private onSelectCallback: (selected: ObjectState[]) => void;
public constructor(
findObjectOnClick: (event: MouseEvent) => void,
getStates: () => ObjectState[],
geometry: Geometry,
canvas: SVG.Container,
) {
this.findObjectOnClick = findObjectOnClick;
this.getStates = getStates;
this.geometry = geometry;
this.canvas = canvas;
this.selectionRect = null;
this.isEnabled = false;
this.selectedObjects = {};
this.resetAppearance = {};
this.mouseDownPosition = { x: 0, y: 0 };
this.selectionFilter = null;
}
private getSelectionBox(event: MouseEvent): {
xtl: number;
ytl: number;
xbr: number;
ybr: number;
} {
const point = translateToSVG((this.canvas.node as any) as SVGSVGElement, [event.clientX, event.clientY]);
return {
xtl: Math.min(this.mouseDownPosition.x, point[0]),
ytl: Math.min(this.mouseDownPosition.y, point[1]),
xbr: Math.max(this.mouseDownPosition.x, point[0]),
ybr: Math.max(this.mouseDownPosition.y, point[1]),
};
}
private filterObjects(states: ObjectState[]): ObjectState[] {
let count = Object.keys(this.selectedObjects).length;
const maxCount = this.selectionFilter.maxCount || Number.MAX_SAFE_INTEGER;
const filtered = [];
for (const state of states) {
const { objectType, shapeType } = state;
const objectTypes = this.selectionFilter.objectType || [objectType];
const shapeTypes = this.selectionFilter.shapeType || [shapeType];
if (objectTypes.includes(objectType) && shapeTypes.includes(shapeType)) {
if (count < maxCount) {
filtered.push(state);
count++;
}
}
}
return filtered;
}
private onMouseDown = (event: MouseEvent): void => {
const point = translateToSVG((this.canvas.node as any) as SVGSVGElement, [event.clientX, event.clientY]);
this.mouseDownPosition = { x: point[0], y: point[1] };
this.selectionRect = this.canvas.rect().addClass('cvat_canvas_selection_box');
this.selectionRect.attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale });
this.selectionRect.attr({ ...this.mouseDownPosition });
};
private onMouseUp = (event: MouseEvent): void => {
if (this.selectionRect) {
this.selectionRect.remove();
this.selectionRect = null;
const states = this.getStates();
const box = this.getSelectionBox(event);
const shapes = (this.canvas.select('.cvat_canvas_shape') as any).members.filter(
(shape: SVG.Shape): boolean => !shape.hasClass('cvat_canvas_hidden'),
);
let newStates = [];
for (const shape of shapes) {
const bbox = shape.bbox();
const clientID = shape.attr('clientID');
if (
bbox.x > box.xtl &&
bbox.y > box.ytl &&
bbox.x + bbox.width < box.xbr &&
bbox.y + bbox.height < box.ybr &&
!(clientID in this.selectedObjects)
) {
const objectState = states.find((state: ObjectState): boolean => state.clientID === clientID);
if (objectState) {
newStates.push(objectState);
}
}
}
newStates = this.filterObjects(newStates);
if (newStates.length) {
newStates.forEach((_state) => {
this.selectedObjects[_state.clientID] = _state;
});
this.onSelectCallback(Object.values(this.selectedObjects));
}
}
};
private onMouseMove = (event: MouseEvent): void => {
if (this.selectionRect) {
const box = this.getSelectionBox(event);
this.selectionRect.attr({
x: box.xtl,
y: box.ytl,
width: box.xbr - box.xtl,
height: box.ybr - box.ytl,
});
}
};
public enable(callback: (selected: ObjectState[]) => void, filter?: SelectionFilter): void {
if (!this.isEnabled) {
window.document.addEventListener('mouseup', this.onMouseUp);
this.canvas.node.addEventListener('mousedown', this.onMouseDown);
this.canvas.node.addEventListener('mousemove', this.onMouseMove);
this.canvas.node.addEventListener('click', this.findObjectOnClick);
this.selectedObjects = {};
this.onSelectCallback = (_selected: ObjectState[]): void => {
const appendToSelection = (objectState: ObjectState): (() => void) => {
const { clientID } = objectState;
const shape = this.canvas.select(`#cvat_canvas_shape_${clientID}`).first();
if (shape) {
shape.addClass('cvat_canvas_shape_selection');
if (objectState.shapeType === 'mask') {
const { points } = objectState;
const colorRGB = [252, 251, 252];
const [left, top, right, bottom] = points.slice(-4);
const imageBitmap = expandChannels(colorRGB[0], colorRGB[1], colorRGB[2], points);
const bbox = shape.bbox();
const image = this.canvas.image().attr({
'color-rendering': 'optimizeQuality',
'shape-rendering': 'geometricprecision',
'data-z-order': Number.MAX_SAFE_INTEGER,
'grouping-copy-for': clientID,
}).move(bbox.x, bbox.y);
imageDataToDataURL(
imageBitmap,
right - left + 1,
bottom - top + 1,
(dataURL: string) => new Promise((resolve, reject) => {
image.loaded(() => {
resolve();
});
image.error(() => {
reject();
});
image.load(dataURL);
}),
);
image.style('filter', 'drop-shadow(2px 4px 6px black)'); // for better visibility
image.attr('opacity', 0.5);
return () => {
image.remove();
shape.removeClass('cvat_canvas_shape_selection');
};
}
return () => shape.removeClass('cvat_canvas_shape_selection');
}
return () => {};
};
for (const state of _selected) {
if (!Object.hasOwn(this.resetAppearance, state.clientID)) {
this.resetAppearance[state.clientID] = appendToSelection(state);
}
}
for (const clientID of Object.keys(this.resetAppearance)) {
if (!_selected.some((state) => state.clientID === +clientID)) {
this.resetAppearance[clientID]();
delete this.resetAppearance[clientID];
}
}
callback(_selected);
};
this.selectionFilter = filter;
this.isEnabled = true;
}
}
public disable(): void {
window.document.removeEventListener('mouseup', this.onMouseUp);
this.canvas.node.removeEventListener('mousedown', this.onMouseDown);
this.canvas.node.removeEventListener('mousemove', this.onMouseMove);
this.canvas.node.removeEventListener('click', this.findObjectOnClick);
if (this.selectionRect) {
this.selectionRect.remove();
this.selectionRect = null;
}
for (const clientID of Object.keys(this.resetAppearance)) {
this.resetAppearance[clientID]();
}
this.onSelectCallback = null;
this.resetAppearance = {};
this.isEnabled = false;
}
public push(state: ObjectState): void {
if (this.isEnabled) {
if (!Object.hasOwn(this.selectedObjects, state.clientID)) {
const filtered = this.filterObjects([state]);
if (filtered.length) {
filtered.forEach((_state) => {
this.selectedObjects[_state.clientID] = _state;
});
this.onSelectCallback(Object.values(this.selectedObjects));
}
} else {
delete this.selectedObjects[state.clientID];
this.onSelectCallback(Object.values(this.selectedObjects));
}
}
}
public transform(geometry: Geometry): void {
this.geometry = geometry;
if (this.selectionRect) {
this.selectionRect.attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / geometry.scale });
}
}
public resetSelected(): void {
if (this.isEnabled) {
for (const clientID of Object.keys(this.resetAppearance)) {
this.resetAppearance[clientID]();
}
this.selectedObjects = {};
this.resetAppearance = {};
if (this.onSelectCallback) {
this.onSelectCallback([]);
}
}
}
}

View File

@ -0,0 +1,134 @@
// Copyright (C) 2020-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import * as SVG from 'svg.js';
import consts from './consts';
import { translateToSVG } from './shared';
import { Geometry } from './canvasModel';
export interface RegionSelector {
select(enabled: boolean): void;
cancel(): void;
transform(geometry: Geometry): void;
}
export class RegionSelectorImpl implements RegionSelector {
private onRegionSelected: (points?: number[]) => void;
private geometry: Geometry;
private canvas: SVG.Container;
private selectionRect: SVG.Rect | null;
private startSelectionPoint: {
x: number;
y: number;
};
private getSelectionBox(event: MouseEvent): { xtl: number; ytl: number; xbr: number; ybr: number } {
const point = translateToSVG((this.canvas.node as any) as SVGSVGElement, [event.clientX, event.clientY]);
const stopSelectionPoint = {
x: point[0],
y: point[1],
};
return {
xtl: Math.min(this.startSelectionPoint.x, stopSelectionPoint.x),
ytl: Math.min(this.startSelectionPoint.y, stopSelectionPoint.y),
xbr: Math.max(this.startSelectionPoint.x, stopSelectionPoint.x),
ybr: Math.max(this.startSelectionPoint.y, stopSelectionPoint.y),
};
}
private onMouseMove = (event: MouseEvent): void => {
if (this.selectionRect) {
const box = this.getSelectionBox(event);
this.selectionRect.attr({
x: box.xtl,
y: box.ytl,
width: box.xbr - box.xtl,
height: box.ybr - box.ytl,
});
}
};
private onMouseDown = (event: MouseEvent): void => {
if (!this.selectionRect && !event.altKey) {
const point = translateToSVG((this.canvas.node as any) as SVGSVGElement, [event.clientX, event.clientY]);
this.startSelectionPoint = {
x: point[0],
y: point[1],
};
this.selectionRect = this.canvas
.rect()
.attr({
'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale,
})
.addClass('cvat_canvas_shape_region_selection');
this.selectionRect.attr({ ...this.startSelectionPoint, width: 1, height: 1 });
}
};
private onMouseUp = (): void => {
const { offset } = this.geometry;
if (this.selectionRect) {
const {
w, h, x, y, x2, y2,
} = this.selectionRect.bbox();
this.selectionRect.remove();
this.selectionRect = null;
if (w <= 1 && h <= 1) {
this.onRegionSelected([x - offset, y - offset]);
} else {
this.onRegionSelected([x - offset, y - offset, x2 - offset, y2 - offset]);
}
}
};
private startSelection(): void {
this.canvas.node.addEventListener('mousemove', this.onMouseMove);
this.canvas.node.addEventListener('mousedown', this.onMouseDown);
this.canvas.node.addEventListener('mouseup', this.onMouseUp);
}
private stopSelection(): void {
this.canvas.node.removeEventListener('mousemove', this.onMouseMove);
this.canvas.node.removeEventListener('mousedown', this.onMouseDown);
this.canvas.node.removeEventListener('mouseup', this.onMouseUp);
}
private release(): void {
this.stopSelection();
}
public constructor(onRegionSelected: RegionSelectorImpl['onRegionSelected'], canvas: SVG.Container, geometry: Geometry) {
this.onRegionSelected = onRegionSelected;
this.geometry = geometry;
this.canvas = canvas;
this.selectionRect = null;
}
public select(enabled: boolean): void {
if (enabled) {
this.startSelection();
} else {
this.release();
}
}
public cancel(): void {
this.release();
this.onRegionSelected();
}
public transform(geometry: Geometry): void {
this.geometry = geometry;
if (this.selectionRect) {
this.selectionRect.attr({
'stroke-width': consts.BASE_STROKE_WIDTH / geometry.scale,
});
}
}
}

View File

@ -0,0 +1,568 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import * as SVG from 'svg.js';
import consts from './consts';
export interface ShapeSizeElement {
sizeElement: any;
update(shape: SVG.Shape): void;
rm(): void;
}
export interface Box {
xtl: number;
ytl: number;
xbr: number;
ybr: number;
}
export interface BBox {
width: number;
height: number;
x: number;
y: number;
}
export interface Point {
x: number;
y: number;
}
interface Vector2D {
i: number;
j: number;
}
export interface DrawnState {
clientID: number;
outside?: boolean;
occluded?: boolean;
hidden?: boolean;
lock: boolean;
source: 'AUTO' | 'SEMI-AUTO' | 'MANUAL' | 'FILE' | 'CONSENSUS';
shapeType: string;
points?: number[];
rotation: number;
attributes: Record<number, string>;
descriptions: string[];
zOrder?: number;
pinned?: boolean;
updated: number;
frame: number;
label: any;
group: any;
color: string;
elements: DrawnState[] | null;
}
// Translate point array from the canvas coordinate system
// to the coordinate system of a client
export function translateFromSVG(svg: SVGSVGElement, points: number[]): number[] {
const output = [];
const transformationMatrix = svg.getScreenCTM() as DOMMatrix;
let pt = svg.createSVGPoint();
for (let i = 0; i < points.length - 1; i += 2) {
pt.x = points[i];
pt.y = points[i + 1];
pt = pt.matrixTransform(transformationMatrix);
output.push(pt.x, pt.y);
}
return output;
}
// Translate point array from the coordinate system of a client
// to the canvas coordinate system
export function translateToSVG(svg: SVGSVGElement, points: number[]): number[] {
const output = [];
const transformationMatrix = (svg.getScreenCTM() as DOMMatrix).inverse();
let pt = svg.createSVGPoint();
for (let i = 0; i < points.length; i += 2) {
pt.x = points[i];
pt.y = points[i + 1];
pt = pt.matrixTransform(transformationMatrix);
output.push(pt.x, pt.y);
}
return output;
}
export function composeShapeDimensions(width: number, height: number, rotation: number | null): string {
const text = `${width.toFixed(1)}x${height.toFixed(1)}px`;
let adjustableRotation = rotation;
if (adjustableRotation) {
// make sure, that rotation is in range [0; 360]
while (adjustableRotation < 0) {
adjustableRotation += 360;
}
adjustableRotation %= 360;
return `${text} ${adjustableRotation.toFixed(1)}\u00B0`;
}
return text;
}
export function getRoundedRotation(shape: SVG.Shape, defaultValue: number = 0): number {
const rotation = shape.transform().rotation ?? defaultValue;
// Due to floating point arithmeic, rotation value may be updated incorrectly
// even when no rotation actually happened.
// E.g. in one call it may be 16.000000000000014
// On the next call it may be 16.00000000000003
// As it may lead to other issues, we round this value up to 5 digits after "."
return +rotation.toFixed(5);
}
export function displayShapeSize(shapesContainer: SVG.Container, textContainer: SVG.Container): ShapeSizeElement {
const shapeSize: ShapeSizeElement = {
sizeElement: textContainer
.text('')
.font({
weight: 'bolder',
})
.fill('white')
.addClass('cvat_canvas_text'),
update(shape: SVG.Shape): void {
const rotation = shape.type === 'rect' || shape.type === 'ellipse' ?
getRoundedRotation(shape) : null;
const text = composeShapeDimensions(shape.width(), shape.height(), rotation);
const [x, y, cx, cy]: number[] = translateToSVG(
(textContainer.node as any) as SVGSVGElement,
translateFromSVG((shapesContainer.node as any) as SVGSVGElement, [
shape.x(),
shape.y(),
shape.cx(),
shape.cy(),
]),
).map((coord: number): number => Math.round(coord));
this.sizeElement
.clear()
.plain(text)
.move(x + consts.TEXT_MARGIN, y + consts.TEXT_MARGIN)
.rotate(rotation ?? 0, cx, cy);
},
rm(): void {
if (this.sizeElement) {
this.sizeElement.remove();
this.sizeElement = null;
}
},
};
return shapeSize;
}
export function rotate2DPoints(cx: number, cy: number, angle: number, points: number[]): number[] {
const rad = (Math.PI / 180) * angle;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const result = [];
for (let i = 0; i < points.length; i += 2) {
const x = points[i];
const y = points[i + 1];
result.push(
(x - cx) * cos - (y - cy) * sin + cx,
(y - cy) * cos + (x - cx) * sin + cy,
);
}
return result;
}
export function pointsToNumberArray(points: string | Point[]): number[] {
if (Array.isArray(points)) {
return points.reduce((acc: number[], point: Point): number[] => {
acc.push(point.x, point.y);
return acc;
}, []);
}
return points
.trim()
.split(/[,\s]+/g)
.map((coord: string): number => +coord);
}
export function parsePoints(source: string | number[]): Point[] {
if (Array.isArray(source)) {
return source.reduce((acc: Point[], _: number, index: number): Point[] => {
if (index % 2) {
acc.push({
x: source[index - 1],
y: source[index],
});
}
return acc;
}, []);
}
return source
.trim()
.split(/\s/)
.map(
(point: string): Point => {
const [x, y] = point.split(',').map((coord: string): number => +coord);
return { x, y };
},
);
}
export function readPointsFromShape(shape: SVG.Shape): number[] {
let points = null;
if (shape.type === 'ellipse') {
const [rx, ry] = [+shape.attr('rx'), +shape.attr('ry')];
const [cx, cy] = [shape.cx(), shape.cy()];
points = `${cx},${cy} ${cx + rx},${cy - ry}`;
} else if (shape.type === 'rect') {
points = `${shape.attr('x')},${shape.attr('y')} ` +
`${shape.attr('x') + shape.attr('width')},${shape.attr('y') + shape.attr('height')}`;
} else if (shape.type === 'circle') {
points = `${shape.cx()},${shape.cy()}`;
} else {
points = shape.attr('points');
}
return pointsToNumberArray(points);
}
export function stringifyPoints(points: number[]): string;
export function stringifyPoints(points: Point[]): string;
export function stringifyPoints(points: (Point | number)[]): string {
if (typeof points[0] === 'number') {
return points.reduce((acc: string, val: number, idx: number): string => {
if (idx % 2) {
return `${acc},${val}`;
}
return `${acc} ${val}`.trim();
}, '');
}
return points.map((point: Point): string => `${point.x},${point.y}`).join(' ');
}
export function clamp(x: number, min: number, max: number): number {
return Math.min(Math.max(x, min), max);
}
export function scalarProduct(a: Vector2D, b: Vector2D): number {
return a.i * b.i + a.j * b.j;
}
export function vectorLength(vector: Vector2D): number {
const sqrI = vector.i ** 2;
const sqrJ = vector.j ** 2;
return Math.sqrt(sqrI + sqrJ);
}
export function translateToCanvas(offset: number, points: number[]): number[] {
return points.map((coord: number): number => coord + offset);
}
export function translateFromCanvas(offset: number, points: number[]): number[] {
return points.map((coord: number): number => coord - offset);
}
export function computeWrappingBox(points: number[], margin = 0): Box & BBox {
let xtl = Number.MAX_SAFE_INTEGER;
let ytl = Number.MAX_SAFE_INTEGER;
let xbr = Number.MIN_SAFE_INTEGER;
let ybr = Number.MIN_SAFE_INTEGER;
for (let i = 0; i < points.length; i += 2) {
const [x, y] = [points[i], points[i + 1]];
xtl = Math.min(xtl, x);
ytl = Math.min(ytl, y);
xbr = Math.max(xbr, x);
ybr = Math.max(ybr, y);
}
const box = {
xtl: xtl - margin,
ytl: ytl - margin,
xbr: xbr + margin,
ybr: ybr + margin,
};
return {
...box,
x: box.xtl,
y: box.ytl,
width: box.xbr - box.xtl,
height: box.ybr - box.ytl,
};
}
export function getSkeletonEdgeCoordinates(edge: SVG.Line): {
x1: number, y1: number, x2: number, y2: number
} {
let x1 = 0;
let y1 = 0;
let x2 = 0;
let y2 = 0;
const parent = edge.parent() as any as SVG.G;
if (parent.type !== 'g') {
throw new Error('Edge parent must be a group');
}
const dataNodeFrom = edge.attr('data-node-from');
const dataNodeTo = edge.attr('data-node-to');
const nodeFrom = parent.children()
.find((element: SVG.Element): boolean => element.attr('data-node-id') === dataNodeFrom);
const nodeTo = parent.children()
.find((element: SVG.Element): boolean => element.attr('data-node-id') === dataNodeTo);
if (!nodeFrom || !nodeTo) {
throw new Error(`Edge's nodeFrom ${dataNodeFrom} or nodeTo ${dataNodeTo} do not to refer to any node`);
}
x1 = nodeFrom.cx();
y1 = nodeFrom.cy();
x2 = nodeTo.cx();
y2 = nodeTo.cy();
if (nodeFrom.hasClass('cvat_canvas_hidden') || nodeTo.hasClass('cvat_canvas_hidden')) {
edge.addClass('cvat_canvas_hidden');
} else {
edge.removeClass('cvat_canvas_hidden');
}
if (nodeFrom.hasClass('cvat_canvas_shape_occluded') || nodeTo.hasClass('cvat_canvas_shape_occluded')) {
edge.addClass('cvat_canvas_shape_occluded');
}
if ([x1, y1, x2, y2].some((coord: number): boolean => typeof coord !== 'number')) {
throw new Error(`Edge coordinates must be numbers, got [${x1}, ${y1}, ${x2}, ${y2}]`);
}
return {
x1, y1, x2, y2,
};
}
export function makeSVGFromTemplate(template: string): SVG.G {
const SVGElement = new SVG.G();
/* eslint-disable-next-line no-unsanitized/property */
SVGElement.node.innerHTML = template;
return SVGElement;
}
export function setupSkeletonEdges(skeleton: SVG.G, referenceSVG: SVG.G): void {
for (const child of referenceSVG.children()) {
// search for all edges on template
const dataType = child.attr('data-type');
if (child.type === 'line' && dataType === 'edge') {
const dataNodeFrom = child.attr('data-node-from');
const dataNodeTo = child.attr('data-node-to');
if (!Number.isInteger(dataNodeFrom) || !Number.isInteger(dataNodeTo)) {
throw new Error(`Edge nodeFrom and nodeTo must be numbers, got ${dataNodeFrom}, ${dataNodeTo}`);
}
// try to find the same edge on the skeleton
let edge = skeleton.children().find((_child: SVG.Element) => (
_child.attr('data-node-from') === dataNodeFrom && _child.attr('data-node-to') === dataNodeTo
)) as SVG.Line;
// if not found, lets create it
if (!edge) {
edge = skeleton.line(0, 0, 0, 0).attr({
'data-node-from': dataNodeFrom,
'data-node-to': dataNodeTo,
'stroke-width': 'inherit',
}).addClass('cvat_canvas_skeleton_edge') as SVG.Line;
}
skeleton.node.prepend(edge.node);
const points = getSkeletonEdgeCoordinates(edge);
edge.attr({ ...points, 'stroke-width': 'inherit' });
}
}
}
export function imageDataToDataURL(
imageBitmap: Uint8ClampedArray,
width: number,
height: number,
handleResult: (dataURL: string) => Promise<void>,
): void {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.getContext('2d').putImageData(
new ImageData(imageBitmap, width, height), 0, 0,
);
canvas.toBlob((blob) => {
const dataURL = URL.createObjectURL(blob);
handleResult(dataURL).finally(() => {
URL.revokeObjectURL(dataURL);
});
}, 'image/png');
}
export function zipChannels(imageData: Uint8ClampedArray): number[] {
const rle = [];
let prev = 0;
let summ = 0;
for (let i = 3; i < imageData.length; i += 4) {
const alpha = imageData[i] > 0 ? 1 : 0;
if (prev !== alpha) {
rle.push(summ);
prev = alpha;
summ = 1;
} else {
summ++;
}
}
rle.push(summ);
return rle;
}
export function expandChannels(r: number, g: number, b: number, encoded: number[]): Uint8ClampedArray {
function rle2Mask(rle: number[], width: number, height: number): Uint8ClampedArray {
const decoded = new Uint8ClampedArray(width * height * 4).fill(0);
const { length } = rle;
let decodedIdx = 0;
let value = 0;
let i = 0;
while (i < length - 4) {
let count = rle[i];
while (count > 0) {
decoded[decodedIdx + 0] = r;
decoded[decodedIdx + 1] = g;
decoded[decodedIdx + 2] = b;
decoded[decodedIdx + 3] = value * 255;
decodedIdx += 4;
count--;
}
i++;
value = Math.abs(value - 1);
}
return decoded;
}
const [left, top, right, bottom] = encoded.slice(-4);
return rle2Mask(encoded, right - left + 1, bottom - top + 1);
}
export function findIntersection(seg1: Segment, seg2: Segment): [number, number] | null {
const determinant2D = (a: number, b: number, c: number, d: number): number => a * d - b * c;
const numberIsBetween = (a: number, b: number, c: number): boolean => Math.min(a, b) <= c && c <= Math.max(a, b);
const projectionIntersected = (a: number, b: number, c: number, d: number): boolean => {
let [p1, p2] = [a, b];
let [p3, p4] = [c, d];
if (p1 > p2) {
[p1, p2] = [p2, p1];
}
if (p3 > p4) {
[p3, p4] = [p4, p3];
}
return Math.max(p1, p3) <= Math.min(p2, p4);
};
const [[x1, y1], [x2, y2]] = seg1;
const [[x3, y3], [x4, y4]] = seg2;
const A1 = y1 - y2;
const A2 = y3 - y4;
const B1 = x2 - x1;
const B2 = x4 - x3;
const C1 = -A1 * x1 - B1 * y1;
const C2 = -A2 * x3 - B2 * y3;
const determinant = determinant2D(A1, B1, A2, B2);
if (determinant === 0) {
if (
determinant2D(A1, C1, A2, C2) === 0 &&
determinant2D(B1, C1, B2, C2) === 0 &&
projectionIntersected(x1, x2, x3, x4) &&
projectionIntersected(y1, y2, y3, y4)
) {
// lines match
return [NaN, NaN];
}
// lines are parallel
return null;
}
const x = -determinant2D(C1, B1, C2, B2) / determinant;
const y = -determinant2D(A1, C1, A2, C2) / determinant;
if (numberIsBetween(x1, x2, x) &&
numberIsBetween(y1, y2, y) &&
numberIsBetween(x3, x4, x) &&
numberIsBetween(y3, y4, y)
) {
return [x, y];
}
return null;
}
export function findClosestPointOnSegment(
segment: [[number, number], [number, number]],
point: [number, number],
): [number, number] {
const numberIsBetween = (a: number, b: number, c: number): boolean => Math.min(a, b) <= c && c <= Math.max(a, b);
const [[x1, y1], [x2, y2]] = segment;
const [x3, y3] = point;
const x = (x1 * x1 * x3 - 2 * x1 * x2 * x3 + x2 * x2 * x3 + x2 *
(y1 - y2) * (y1 - y3) - x1 * (y1 - y2) * (y2 - y3)) /
((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
const y = (x2 * x2 * y1 + x1 * x1 * y2 + x2 * x3 * (y2 - y1) - x1 *
(x3 * (y2 - y1) + x2 * (y1 + y2)) + (y1 - y2) * (y1 - y2) * y3) /
((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
if (numberIsBetween(x1, x2, x) && numberIsBetween(y1, y2, y)) {
return [x, y];
}
// perpendicular point is not on the segment
// shortest distance is distance to one of edge points
const d1 = Math.sqrt((x - x1) ** 2 + (y - y1) ** 2);
const d2 = Math.sqrt((x - x2) ** 2 + (y - y2) ** 2);
if (d1 < d2) {
return [x1, y1];
}
return [x2, y2];
}
export function segmentsFromPoints(points: number[], circuit = false): Segment[] {
return points.reduce<Segment[]>((acc, val, idx, arr) => {
if (idx % 2 !== 0) {
if (idx === arr.length - 1) {
if (circuit) {
acc.push([[arr[idx - 1], val], [arr[0], arr[1]]]);
}
} else {
acc.push([[arr[idx - 1], val], [arr[idx + 1], arr[idx + 2]]]);
}
}
return acc;
}, []);
}
export function toReversed<T>(array: Array<T>): Array<T> {
// actually toReversed already exists in ESMA specification
// but not all CVAT customers uses a browser fresh enough to use it
// instead of using a library with polyfills I will prefer just to rewrite it with reduceRight
return array.reduceRight<Array<T>>((acc, val: T) => {
acc.push(val);
return acc;
}, []);
}
export type Segment = [[number, number], [number, number]];
export type PropType<T, Prop extends keyof T> = T[Prop];

View File

@ -0,0 +1,593 @@
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import * as SVG from 'svg.js';
import {
stringifyPoints, translateToCanvas, translateFromCanvas, translateToSVG,
findIntersection, zipChannels, Segment, findClosestPointOnSegment, segmentsFromPoints,
toReversed,
} from './shared';
import {
Geometry, SliceData, Configuration, CanvasHint,
} from './canvasModel';
import consts from './consts';
import { ObjectSelector } from './objectSelector';
export interface SliceHandler {
slice(sliceData: any): void;
transform(geometry: Geometry): void;
configure(config: Configuration): void;
cancel(): void;
}
type EnhancedSliceData = {
enabled: boolean;
contour: number[];
state: any;
shapeType: 'mask' | 'polygon';
};
function drawOverOffscreenCanvas(context: OffscreenCanvasRenderingContext2D, image: CanvasImageSource): void {
context.fillStyle = 'black';
context.globalCompositeOperation = 'source-over';
context.drawImage(image, 0, 0);
}
function applyOffscreenCanvasMask(context: OffscreenCanvasRenderingContext2D, polygon: number[]): void {
const currentCompositeOperation = context.globalCompositeOperation;
context.globalCompositeOperation = 'destination-in';
context.beginPath();
context.moveTo(polygon[0], polygon[1]);
polygon.forEach((_, idx) => {
if (idx > 1 && !(idx % 2)) {
context.lineTo(polygon[idx], polygon[idx + 1]);
}
});
context.closePath();
context.fill();
context.globalCompositeOperation = currentCompositeOperation;
}
function indexGenerator(length: number, from: number, to: number, direction: 'forward' | 'backward'): number[] {
const result = [];
const value = direction === 'forward' ? 1 : -1;
if (from < 0 || from >= length || to < 0 || to >= length) {
throw new Error('Incorrect index generator input');
}
let i = from;
while (i !== to) {
result.push(i);
i += value;
if (i >= length) {
i = 0;
}
if (i < 0) {
i = length - 1;
}
}
result.push(i);
return result;
}
function getAllIntersections(segment: Segment, segments: Segment[]): Record<number, [number, number]> {
const intersections: Record<number, [number, number]> = {};
for (let i = 0; i < segments.length; i++) {
const checkedSegment = segments[i];
const intersection = findIntersection(checkedSegment, segment);
if (intersection !== null) {
intersections[i] = intersection;
}
}
return intersections;
}
export class SliceHandlerImpl implements SliceHandler {
private canvas: SVG.Container;
private startTimestamp: number;
private controlPointSize: number;
private outlinedBorders: string;
private enabled: boolean;
private shapeContour: SVG.PolyLine | null;
private slicingLine: SVG.PolyLine | null;
private slicingPoints: SVG.Circle[];
private hideObject: (clientID: number) => void;
private showObject: (clientID: number) => void;
private onSliceDone: (state?: any, results?: number[][], duration?: number) => void;
private onMessage: (messages: CanvasHint[] | null, topic: string) => void;
private onError: (exception: unknown) => void;
private getObjects: () => any[];
private geometry: Geometry;
private objectSelector: ObjectSelector;
private hiddenClientIDs: number[];
public constructor(
hideObject: SliceHandlerImpl['hideObject'],
showObject: SliceHandlerImpl['showObject'],
onSliceDone: SliceHandlerImpl['onSliceDone'],
onMessage: SliceHandlerImpl['onMessage'],
onError: SliceHandlerImpl['onError'],
getObjects: () => any[],
geometry: Geometry,
canvas: SVG.Container,
objectSelector: ObjectSelector,
) {
this.hideObject = hideObject;
this.showObject = showObject;
this.onSliceDone = onSliceDone;
this.onMessage = onMessage;
this.onError = onError;
this.getObjects = getObjects;
this.geometry = geometry;
this.canvas = canvas;
this.enabled = false;
this.startTimestamp = Date.now();
this.controlPointSize = consts.BASE_POINT_SIZE;
this.outlinedBorders = 'black';
this.shapeContour = null;
this.slicingPoints = [];
this.slicingLine = null;
this.objectSelector = objectSelector;
this.hiddenClientIDs = [];
}
private showInitialMessage(): void {
this.onMessage([{
type: 'text',
icon: 'info',
content: 'Set initial point on the shape contour',
}, {
type: 'list',
content: [
'Slicing line must not intersect itself',
'Slicing line must not intersect contour more than twice',
],
className: 'cvat-canvas-notification-list-warning',
}], 'slice');
}
private initialize(sliceData: EnhancedSliceData): void {
this.showInitialMessage();
const { clientID } = sliceData.state;
this.hiddenClientIDs = (this.canvas.select('.cvat_canvas_shape') as any).members
.map((shape) => +shape.attr('clientID')).filter((_clientID: number) => _clientID !== clientID);
this.hiddenClientIDs.forEach((clientIDs) => {
this.hideObject(clientIDs);
});
const translatedContour = translateToCanvas(this.geometry.offset, sliceData.contour);
this.shapeContour = this.canvas.polygon(stringifyPoints(translatedContour));
this.shapeContour.attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale });
this.shapeContour.attr('stroke', this.outlinedBorders);
this.shapeContour.addClass('cvat_canvas_sliced_contour');
const contourSegments = segmentsFromPoints(translatedContour, true);
let points: [number, number][] = [];
let firstIntersectedSegmentIdx: number | null = null;
const filterIntersections = (
segment: Segment,
intersections: ReturnType<typeof getAllIntersections>,
): ReturnType<typeof getAllIntersections> => {
for (const key of Object.keys(intersections)) {
const point = intersections[key];
const d1 = Math.sqrt((point[0] - segment[0][0]) ** 2 + (point[1] - segment[0][1]) ** 2);
const d2 = Math.sqrt((point[0] - segment[0][0]) ** 2 + (point[1] - segment[0][1]) ** 2);
// if intersection is too close to edge points
// it is an intersection in a point, ignore it
if (d1 < 2e-3 || d2 < 2e-3) {
delete intersections[key];
}
}
return intersections;
};
const initialClick = (event: MouseEvent): void => {
const [x, y] = translateToSVG(this.canvas.node as any as SVGSVGElement, [event.clientX, event.clientY]);
let shortestDistance = Number.MAX_SAFE_INTEGER;
let closestPoint: [number, number] = [x, y];
let segmentIdx = -1;
contourSegments.forEach((segment, idx) => {
const point = findClosestPointOnSegment(segment, [x, y]);
const distance = Math.sqrt((x - point[0]) ** 2 + (y - point[1]) ** 2);
if (distance < shortestDistance) {
closestPoint = point;
shortestDistance = distance;
segmentIdx = idx;
}
});
const THRESHOLD = 20 / this.geometry.scale;
if (shortestDistance <= THRESHOLD) {
points.push([...closestPoint], [...closestPoint]);
firstIntersectedSegmentIdx = segmentIdx;
this.slicingLine = this.canvas.polyline(stringifyPoints(points.flat()));
this.slicingLine.addClass('cvat_canvas_slicing_line');
this.slicingLine.attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale });
this.slicingLine.attr('stroke', this.outlinedBorders);
const circle = this.canvas
.circle((this.controlPointSize * 2) / this.geometry.scale)
.center(closestPoint[0], closestPoint[1]);
circle.attr('fill', 'white');
circle.attr('stroke-width', consts.BASE_STROKE_WIDTH / this.geometry.scale);
this.slicingPoints.push(circle);
this.onMessage([{
type: 'text',
icon: 'info',
content: 'Set more points within the shape contour, if necessary. Intersect contour at another point to slice',
}, {
type: 'list',
content: [
'Hold <Shift> to enable slip mode',
'Do <Right Click> to cancel the latest point',
],
className: 'cvat-canvas-notification-list-shortcuts',
}], 'slice');
}
};
const click = (event: MouseEvent): void => {
const [prevX, prevY] = points[points.length - 2];
const [x, y] = translateToSVG(this.canvas.node as any as SVGSVGElement, [event.clientX, event.clientY]);
points[points.length - 1] = [x, y];
// check slicing line does not intersect itself
const segment = [[prevX, prevY], [x, y]] as Segment;
const slicingLineSegments = segmentsFromPoints(points.slice(0, -1).flat());
const selfIntersections = filterIntersections(
segment,
getAllIntersections(segment, slicingLineSegments),
);
if (Object.keys(selfIntersections).length) {
// not allowed
return;
}
// find all intersections with contour
const intersections = filterIntersections(
[[prevX, prevY], [x, y]],
getAllIntersections([[prevX, prevY], [x, y]], contourSegments),
);
const numberOfIntersections = Object.keys(intersections).length;
if (numberOfIntersections !== 1) {
// not allowed
return;
}
// found two intersections, finish algorithm
const intermediatePoints: [number, number][] = points.slice(1, -1);
const secondIntersectedSegmentIdx = +Object.keys(intersections)[0];
const firstIntersectionPoint = points[0];
const secondIntersectionPoint = intersections[secondIntersectedSegmentIdx];
let contour1 = [];
let contour2 = [];
if (firstIntersectedSegmentIdx === secondIntersectedSegmentIdx) {
// the same segment. Results in this case are:
contour1 = [
...firstIntersectionPoint, // first intersection
...intermediatePoints.flat(), // intermediate points
...secondIntersectionPoint, // last intersection
];
contour2 = [...contour1];
const otherPoints = Array(contourSegments.length).fill(0).map((_, idx) => {
if (firstIntersectedSegmentIdx + idx < contourSegments.length) {
return firstIntersectedSegmentIdx + idx;
}
return firstIntersectedSegmentIdx + idx - contourSegments.length;
}).map((idx) => contourSegments[idx][1]);
const p1 = firstIntersectionPoint;
const p2 = secondIntersectionPoint;
const p = otherPoints[0];
const d1 = Math.sqrt((p1[0] - p[0]) ** 2 + (p1[1] - p[1]) ** 2);
const d2 = Math.sqrt((p2[0] - p[0]) ** 2 + (p2[1] - p[1]) ** 2);
if (d2 > d1) {
contour2.push(...toReversed<[number, number]>(otherPoints).flat());
} else {
contour2.push(...otherPoints.flat());
}
} else {
const firstSegmentIdx = Math.min(firstIntersectedSegmentIdx, secondIntersectedSegmentIdx);
const secondSegmentIdx = Math.max(firstIntersectedSegmentIdx, secondIntersectedSegmentIdx);
const firstSegmentPoint = firstIntersectedSegmentIdx < secondIntersectedSegmentIdx ?
firstIntersectionPoint : secondIntersectionPoint;
const secondSegmentPoint = firstIntersectedSegmentIdx < secondIntersectedSegmentIdx ?
secondIntersectionPoint : firstIntersectionPoint;
// intersected different segments. Results in this case are:
contour1 = [
...firstSegmentPoint, // first intersection
// intermediate points (reversed if intersections order was swopped)
...(firstSegmentIdx === firstIntersectedSegmentIdx ?
intermediatePoints : toReversed<[number, number]>(intermediatePoints)
).flat(),
// second intersection
...secondSegmentPoint,
// all the following contours points N, N+1, .. until (including) the first intersected segment
...indexGenerator(contourSegments.length, secondSegmentIdx, firstSegmentIdx, 'forward')
.map((idx) => contourSegments[idx][1]).slice(0, -1).flat(),
];
contour2 = [
...firstSegmentPoint, // first intersection
// intermediate points (reversed if intersections order was swopped)
...(firstSegmentIdx === firstIntersectedSegmentIdx ?
intermediatePoints : toReversed<[number, number]>(intermediatePoints)
).flat(),
...secondSegmentPoint,
// all the previous contours points N, N-1, .. until (including) the first intersected segment
...indexGenerator(contourSegments.length, secondSegmentIdx, firstSegmentIdx, 'backward')
.map((idx) => contourSegments[idx][0]).slice(0, -1).flat(),
];
}
if (sliceData.shapeType === 'mask') {
const shape = this.canvas
.select(`#cvat_canvas_shape_${clientID}`).get(0).node;
const width = +shape.getAttribute('width');
const height = +shape.getAttribute('height');
const left = +shape.getAttribute('x');
const top = +shape.getAttribute('y');
const polygon1 = contour1.map((val, idx) => {
if (idx % 2) return val - top;
return val - left;
});
const polygon2 = contour2.map((val, idx) => {
if (idx % 2) return val - top;
return val - left;
});
const offscreenCanvas = new OffscreenCanvas(width, height);
const context = offscreenCanvas.getContext('2d');
drawOverOffscreenCanvas(context, shape as any as SVGImageElement);
applyOffscreenCanvasMask(context, polygon1);
const firstShape = zipChannels(context.getImageData(0, 0, width, height).data);
// @ts-ignore error TS2339 https://github.com/microsoft/TypeScript/issues/55162
context.reset();
drawOverOffscreenCanvas(context, shape as any as SVGImageElement);
applyOffscreenCanvasMask(context, polygon2);
const secondShape = zipChannels(context.getImageData(0, 0, width, height).data);
this.onSliceDone(sliceData.state, [firstShape, secondShape], Date.now() - this.startTimestamp);
} else if (sliceData.shapeType === 'polygon') {
this.onSliceDone(
sliceData.state,
[
translateFromCanvas(this.geometry.offset, contour1),
translateFromCanvas(this.geometry.offset, contour2),
], Date.now() - this.startTimestamp,
);
} else {
this.slice({ enabled: false });
}
};
const handleCanvasMousedown = (event: MouseEvent): void => {
if (event.altKey) {
return;
}
if (event.button === 0 && !points.length) {
initialClick(event);
} else if (event.button === 0 && event.target !== this.shapeContour.node) {
click(event);
} else if (event.button === 2) {
if (points.length > 2) {
points.splice(-2, 1);
this.slicingLine.plot(stringifyPoints(points.flat()));
} else if (points.length) {
this.slicingPoints.forEach((circle) => {
circle.remove();
});
this.showInitialMessage();
this.slicingLine.remove();
points = [];
firstIntersectedSegmentIdx = null;
this.slicingPoints = [];
this.slicingLine = null;
}
}
};
const handleShapeMousedown = (event: MouseEvent, slipping = false): void => {
if (points.length && event.button === 0 && !event.altKey) {
const [x, y] = translateToSVG(this.canvas.node as any as SVGSVGElement, [event.clientX, event.clientY]);
points[points.length - 1] = [x, y];
this.slicingLine.plot(stringifyPoints(points.flat()));
const [prevX, prevY] = points[points.length - 2];
const segment = [[prevX, prevY], [x, y]] as Segment;
const slicingLineSegments = segmentsFromPoints(points.slice(0, -1).flat());
const selfIntersections = filterIntersections(
segment,
getAllIntersections(segment, slicingLineSegments),
);
if (Object.keys(selfIntersections).length !== 0) {
return;
}
// find all intersections with contour
const contourIntersection = filterIntersections(
[[prevX, prevY], [x, y]],
getAllIntersections([[prevX, prevY], [x, y]], contourSegments),
);
const numberOfIntersections = Object.keys(contourIntersection).length;
if (!slipping && numberOfIntersections !== 0) {
// shape was clicked with intersections (via out of contour trajectory)
// not allowed
return;
}
if (numberOfIntersections === 0 && event.target === this.shapeContour.node) {
// mousemove over the shape, left new point
click(event);
} else if (numberOfIntersections === 1 && points.length > 2) {
// maybe out of contour, maybe within
// require at least one more intermediate points in this case
click(event);
} else {
return;
}
if (this.enabled) {
// check if slicing is still enabled
// because click() may finish slicing from inside
// e.g. when click out of contour with enabled shift
points.push([x, y]);
this.slicingLine.plot(stringifyPoints(points.flat()));
}
}
};
const handleCanvasMousemove = (event: MouseEvent): void => {
if (points.length) {
const [x, y] = translateToSVG(this.canvas.node as any as SVGSVGElement, [event.clientX, event.clientY]);
const [prevX, prevY] = points[points.length - 2];
points[points.length - 1] = [x, y];
if (event.shiftKey) {
const d = Math.sqrt((prevX - x) ** 2 + (prevY - y) ** 2);
const threshold = 10 / this.geometry.scale;
if (d > threshold) {
handleShapeMousedown(event, true);
}
} else {
this.slicingLine.plot(stringifyPoints(points.flat()));
}
}
};
this.shapeContour.on('mousedown.slice', handleShapeMousedown);
this.canvas.on('mousedown.slice', handleCanvasMousedown);
this.canvas.on('mousemove.slice', handleCanvasMousemove);
}
private release(): void {
this.objectSelector.disable();
this.hiddenClientIDs.forEach((clientIDs) => {
this.showObject(clientIDs);
});
if (this.slicingLine) {
this.slicingLine.remove();
this.slicingLine = null;
}
if (this.shapeContour) {
this.shapeContour.off('mousedown.slice');
this.shapeContour.remove();
this.shapeContour = null;
}
this.slicingPoints.forEach((circle) => {
circle.remove();
});
this.slicingPoints = [];
this.canvas.off('mousedown.slice');
this.canvas.off('mousemove.slice');
this.enabled = false;
this.onSliceDone();
this.onMessage(null, 'slice');
}
public slice(sliceData: SliceData): void {
const initializeWithContour = (state: any): void => {
this.startTimestamp = Date.now();
const { startTimestamp } = this;
this.onMessage([{
type: 'text',
content: 'Getting shape contour',
icon: 'loading',
}], 'force');
sliceData.getContour(state).then((contour) => {
const { shapeType } = state;
if (this.startTimestamp === startTimestamp && this.enabled) {
// checking if a user does not left mode / reinit it
this.initialize({
enabled: true,
contour,
state,
shapeType,
});
}
}).catch((error: unknown) => {
this.release();
this.onError(error);
});
};
if (sliceData.enabled && !this.enabled && sliceData.getContour) {
this.enabled = true;
if (sliceData.clientID) {
const state = this.getObjects().find((_state) => _state.clientID === sliceData.clientID);
if (state && state.objectType === 'shape' &&
['polygon', 'mask'].includes(state.shapeType)) {
initializeWithContour(state);
return;
}
}
this.onMessage([{
type: 'text',
content: 'Click a mask or polygon shape you would like to slice',
icon: 'info',
}], 'slice');
this.objectSelector.enable(([state]) => {
this.objectSelector.disable();
initializeWithContour(state);
}, { maxCount: 1, shapeType: ['polygon', 'mask'], objectType: ['shape'] });
} else if (this.enabled && !sliceData.enabled) {
this.release();
}
}
public cancel(): void {
if (this.enabled) {
this.release();
}
}
public transform(geometry: Geometry): void {
this.geometry = geometry;
if (this.slicingLine) {
this.slicingLine.attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / geometry.scale });
}
if (this.shapeContour) {
this.shapeContour.attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / geometry.scale });
}
this.slicingPoints.forEach((point) => {
point.radius(this.controlPointSize / geometry.scale);
point.attr('stroke-width', consts.BASE_STROKE_WIDTH / this.geometry.scale);
});
}
public configure(config: Configuration): void {
this.controlPointSize = config.controlPointsSize || consts.BASE_POINT_SIZE;
this.outlinedBorders = config.outlinedBorders || 'black';
if (this.slicingLine) this.slicingLine.attr('stroke', this.outlinedBorders);
if (this.shapeContour) this.shapeContour.attr('stroke', this.outlinedBorders);
}
}

View File

@ -0,0 +1,110 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import * as SVG from 'svg.js';
import { SplitData } from './canvasModel';
export interface SplitHandler {
split(splitData: SplitData): void;
select(state: any): void;
cancel(): void;
}
export class SplitHandlerImpl implements SplitHandler {
// callback is used to notify about splitting end
private onSplitDone: (object?: any, duration?: number) => void;
private onFindObject: (event: MouseEvent) => void;
private canvas: SVG.Container;
private highlightedShape: SVG.Shape | null;
private initialized: boolean;
private splitDone: boolean;
private startTimestamp: number;
private resetShape(): void {
if (this.highlightedShape) {
this.highlightedShape.removeClass('cvat_canvas_shape_splitting');
this.highlightedShape.off('click.split');
this.highlightedShape = null;
}
}
private release(): void {
if (this.initialized) {
this.resetShape();
this.canvas.node.removeEventListener('mousemove', this.findObject);
this.initialized = false;
}
}
private initSplitting(): void {
this.canvas.node.addEventListener('mousemove', this.findObject);
this.initialized = true;
this.splitDone = false;
this.startTimestamp = Date.now();
}
private closeSplitting(): void {
// Split done is true if an object was split
// Split also can be called with { enabled: false } without splitting an object
if (!this.splitDone) {
this.onSplitDone(null);
}
this.release();
}
private findObject = (e: MouseEvent): void => {
this.resetShape();
this.onFindObject(e);
};
public constructor(
onSplitDone: SplitHandlerImpl['onSplitDone'],
onFindObject: SplitHandlerImpl['onFindObject'],
canvas: SVG.Container,
) {
this.onSplitDone = onSplitDone;
this.onFindObject = onFindObject;
this.canvas = canvas;
this.highlightedShape = null;
this.initialized = false;
this.splitDone = false;
this.startTimestamp = Date.now();
}
public split(splitData: SplitData): void {
if (splitData.enabled) {
this.initSplitting();
} else {
this.closeSplitting();
}
}
public select(state: any): void {
if (state.objectType === 'track') {
const shape = this.canvas.select(`#cvat_canvas_shape_${state.clientID}`).first();
if (shape && shape !== this.highlightedShape) {
this.resetShape();
this.highlightedShape = shape;
this.highlightedShape.addClass('cvat_canvas_shape_splitting');
this.canvas.node.append(this.highlightedShape.node);
this.highlightedShape.on(
'click.split',
(): void => {
this.splitDone = true;
this.onSplitDone(state, Date.now() - this.startTimestamp);
}, { once: true },
);
}
}
}
public cancel(): void {
this.release();
this.onSplitDone(null);
// here is a cycle
// onSplitDone => controller => model => view => closeSplitting
// one call of closeMerging is unuseful, but it's okey
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,137 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import * as SVG from 'svg.js';
import consts from './consts';
import { translateToSVG } from './shared';
import { Geometry } from './canvasModel';
export interface ZoomHandler {
zoom(): void;
cancel(): void;
transform(geometry: Geometry): void;
}
export class ZoomHandlerImpl implements ZoomHandler {
private onZoomRegion: (x: number, y: number, width: number, height: number) => void;
private bindedOnSelectStart: (event: MouseEvent) => void;
private bindedOnSelectUpdate: (event: MouseEvent) => void;
private bindedOnSelectStop: (event: MouseEvent) => void;
private geometry: Geometry;
private canvas: SVG.Container;
private selectionRect: SVG.Rect | null;
private startSelectionPoint: {
x: number;
y: number;
};
private onSelectStart(event: MouseEvent): void {
if (!this.selectionRect && event.which === 1) {
const point = translateToSVG((this.canvas.node as any) as SVGSVGElement, [event.clientX, event.clientY]);
this.startSelectionPoint = {
x: point[0],
y: point[1],
};
this.selectionRect = this.canvas.rect().addClass('cvat_canvas_zoom_selection');
this.selectionRect.attr({
'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale,
...this.startSelectionPoint,
});
}
}
private getSelectionBox(
event: MouseEvent,
): {
x: number;
y: number;
width: number;
height: number;
} {
const point = translateToSVG((this.canvas.node as any) as SVGSVGElement, [event.clientX, event.clientY]);
const stopSelectionPoint = {
x: point[0],
y: point[1],
};
const xtl = Math.min(this.startSelectionPoint.x, stopSelectionPoint.x);
const ytl = Math.min(this.startSelectionPoint.y, stopSelectionPoint.y);
const xbr = Math.max(this.startSelectionPoint.x, stopSelectionPoint.x);
const ybr = Math.max(this.startSelectionPoint.y, stopSelectionPoint.y);
return {
x: xtl,
y: ytl,
width: xbr - xtl,
height: ybr - ytl,
};
}
private onSelectUpdate(event: MouseEvent): void {
if (this.selectionRect) {
this.selectionRect.attr({
...this.getSelectionBox(event),
});
}
}
private onSelectStop(event: MouseEvent): void {
if (this.selectionRect) {
const box = this.getSelectionBox(event);
this.selectionRect.remove();
this.selectionRect = null;
this.startSelectionPoint = {
x: 0,
y: 0,
};
const threshold = 5;
if (box.width > threshold && box.height > threshold) {
this.onZoomRegion(box.x, box.y, box.width, box.height);
}
}
}
public constructor(
onZoomRegion: ZoomHandlerImpl['onZoomRegion'],
canvas: SVG.Container,
geometry: Geometry,
) {
this.onZoomRegion = onZoomRegion;
this.canvas = canvas;
this.geometry = geometry;
this.selectionRect = null;
this.startSelectionPoint = {
x: 0,
y: 0,
};
this.bindedOnSelectStart = this.onSelectStart.bind(this);
this.bindedOnSelectUpdate = this.onSelectUpdate.bind(this);
this.bindedOnSelectStop = this.onSelectStop.bind(this);
}
public zoom(): void {
this.canvas.node.addEventListener('mousedown', this.bindedOnSelectStart);
this.canvas.node.addEventListener('mousemove', this.bindedOnSelectUpdate);
this.canvas.node.addEventListener('mouseup', this.bindedOnSelectStop);
}
public cancel(): void {
this.canvas.node.removeEventListener('mousedown', this.bindedOnSelectStart);
this.canvas.node.removeEventListener('mousemove', this.bindedOnSelectUpdate);
this.canvas.node.removeEventListener('mouseup ', this.bindedOnSelectStop);
}
public transform(geometry: Geometry): void {
this.geometry = geometry;
if (this.selectionRect) {
this.selectionRect.style({
'stroke-width': consts.BASE_STROKE_WIDTH / geometry.scale,
});
}
}
}

23
cvat-canvas/tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"esModuleInterop": true,
"noEmit": true,
"declaration": true,
"declarationDir": "dist/declaration",
"paths": {
"cvat-canvas.node": ["dist/cvat-canvas.node"]
},
"baseUrl": "."
},
"include": ["src/typescript/canvas.ts"]
}

View File

@ -0,0 +1,77 @@
// Copyright (C) 2020-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
// eslint-disable-next-line @typescript-eslint/no-var-requires
const path = require('path');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const BundleDeclarationsWebpackPlugin = require('bundle-declarations-webpack-plugin');
const styleLoaders = [
'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 2,
},
},
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [
[
'postcss-preset-env', {},
],
],
},
},
},
'sass-loader',
];
module.exports = {
target: 'web',
mode: 'production',
devtool: 'source-map',
entry: {
'cvat-canvas': './src/typescript/canvas.ts',
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
library: 'canvas',
libraryTarget: 'window',
},
resolve: {
extensions: ['.ts', '.js', '.json'],
},
module: {
rules: [
{
test: /\.ts$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
plugins: ['@babel/plugin-proposal-class-properties'],
presets: ['@babel/preset-env', '@babel/typescript'],
sourceType: 'unambiguous',
},
},
},
{
test: /\.scss$/,
exclude: /node_modules/,
use: styleLoaders,
},
],
},
plugins: [
new BundleDeclarationsWebpackPlugin({
outFile: "declaration/src/cvat-canvas.d.ts",
}),
],
};

View File

@ -0,0 +1,17 @@
// Copyright (C) 2021-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
module.exports = {
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
},
ignorePatterns: [
'.eslintrc.cjs',
'webpack.config.js',
'node_modules/**',
'dist/**',
],
};

53
cvat-canvas3d/README.md Normal file
View File

@ -0,0 +1,53 @@
# Module CVAT-CANVAS-3D
## Description
The CVAT module written in TypeScript language.
It presents a canvas to viewing, drawing and editing of 3D annotations.
## Commands
- Building of the module from sources in the `dist` directory:
```bash
yarn run build
yarn run build --mode=development # without a minification
```
### API Methods
```ts
interface Canvas3d {
html(): ViewsDOM;
setup(frameData: any, objectStates: any[]): void;
isAbleToChangeFrame(): boolean;
mode(): Mode;
render(): void;
keyControls(keys: KeyboardEvent): void;
draw(drawData: DrawData): void;
cancel(): void;
dragCanvas(enable: boolean): void;
activate(clientID: number | null, attributeID?: number): void;
configureShapes(shapeProperties: ShapeProperties): void;
fitCanvas(): void;
fit(): void;
group(groupData: GroupData): void;
}
```
### WEB
```js
// Create an instance of a canvas
const canvas = new window.canvas.Canvas3d();
console.log('Version ', window.canvas.CanvasVersion);
console.log('Current mode is ', window.canvas.mode());
// Put canvas to a html container
const views = canvas.html();
htmlContainer.appendChild(views.perspective);
htmlContainer.appendChild(views.top);
htmlContainer.appendChild(views.side);
htmlContainer.appendChild(views.front);
```

View File

@ -0,0 +1,24 @@
{
"name": "cvat-canvas3d",
"version": "0.0.10",
"type": "module",
"description": "Part of Computer Vision Annotation Tool which presents its canvas3D library",
"main": "src/canvas3d.ts",
"scripts": {
"build": "tsc && webpack --config ./webpack.config.cjs"
},
"author": "CVAT.ai",
"license": "MIT",
"browserslist": [
"Chrome >= 99",
"Firefox >= 110",
"not IE 11",
"> 2%"
],
"dependencies": {
"@types/three": "^0.156.0",
"camera-controls": "^1.25.3",
"cvat-core": "link:./../cvat-core",
"three": "^0.156.1"
}
}

View File

@ -0,0 +1,128 @@
// Copyright (C) 2021-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import { Canvas3dController, Canvas3dControllerImpl } from './canvas3dController';
import {
Canvas3dModel,
Canvas3dModelImpl,
Mode,
DrawData,
ViewType,
MouseInteraction,
ShapeProperties,
GroupData,
SplitData,
MergeData,
} from './canvas3dModel';
import {
Canvas3dView, Canvas3dViewImpl, ViewsDOM, CameraAction,
} from './canvas3dView';
import { Master } from './master';
interface Canvas3d {
html(): ViewsDOM;
setup(frameData: any, objectStates: any[]): void;
isAbleToChangeFrame(): boolean;
mode(): Mode;
render(): void;
keyControls(keys: KeyboardEvent): void;
draw(drawData: DrawData): void;
cancel(): void;
dragCanvas(enable: boolean): void;
activate(clientID: number | null, attributeID?: number): void;
configureShapes(shapeProperties: ShapeProperties): void;
fitCanvas(): void;
fit(): void;
group(groupData: GroupData): void;
merge(mergeData: MergeData): void;
split(splitData: SplitData): void;
destroy(): void;
}
class Canvas3dImpl implements Canvas3d {
private readonly model: Canvas3dModel & Master;
private readonly controller: Canvas3dController;
private view: Canvas3dView;
public constructor() {
this.model = new Canvas3dModelImpl();
this.controller = new Canvas3dControllerImpl(this.model);
this.view = new Canvas3dViewImpl(this.model, this.controller);
}
public html(): ViewsDOM {
return this.view.html();
}
public keyControls(keys: KeyboardEvent): void {
this.view.keyControls(keys);
}
public render(): void {
this.view.render();
}
public draw(drawData: DrawData): void {
this.model.draw(drawData);
}
public setup(frameData: any, objectStates: any[]): void {
this.model.setup(frameData, objectStates);
}
public mode(): Mode {
return this.model.mode;
}
public group(groupData: GroupData): void {
this.model.group(groupData);
}
public split(splitData: SplitData): void {
this.model.split(splitData);
}
public merge(mergeData: MergeData): void {
this.model.merge(mergeData);
}
public isAbleToChangeFrame(): boolean {
return this.model.isAbleToChangeFrame();
}
public cancel(): void {
this.model.cancel();
}
public dragCanvas(enable: boolean): void {
this.model.dragCanvas(enable);
}
public configureShapes(shapeProperties: ShapeProperties): void {
this.model.configureShapes(shapeProperties);
}
public activate(clientID: number | null, attributeID: number | null = null): void {
this.model.activate(typeof clientID === 'number' ? String(clientID) : null, attributeID);
}
public fit(): void {
this.model.fit();
}
public fitCanvas(): void {
this.model.fit();
}
public destroy(): void {
this.model.destroy();
}
}
export {
Canvas3dImpl as Canvas3d, ViewType, MouseInteraction, CameraAction, Mode as CanvasMode,
};
export type { ViewsDOM };

View File

@ -0,0 +1,70 @@
// Copyright (C) 2021-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import { ObjectState } from '.';
import {
Canvas3dModel, Mode, DrawData, ActiveElement,
GroupData, MergeData, SplitData,
} from './canvas3dModel';
export interface Canvas3dController {
readonly drawData: DrawData;
readonly activeElement: ActiveElement;
readonly groupData: GroupData;
readonly imageIsDeleted: boolean;
readonly objects: ObjectState[];
mode: Mode;
group(groupData: GroupData): void;
merge(mergeData: MergeData): void;
split(splitData: SplitData): void;
}
export class Canvas3dControllerImpl implements Canvas3dController {
private model: Canvas3dModel;
public constructor(model: Canvas3dModel) {
this.model = model;
}
public set mode(value: Mode) {
this.model.mode = value;
}
public get mode(): Mode {
return this.model.mode;
}
public get drawData(): DrawData {
return this.model.data.drawData;
}
public get activeElement(): ActiveElement {
return this.model.data.activeElement;
}
public get imageIsDeleted(): any {
return this.model.imageIsDeleted;
}
public get groupData(): GroupData {
return this.model.groupData;
}
public get objects(): ObjectState[] {
return this.model.objects;
}
public group(groupData: GroupData): void {
this.model.group(groupData);
}
public merge(mergeData: MergeData): void {
this.model.merge(mergeData);
}
public split(splitData: SplitData): void {
this.model.split(splitData);
}
}

View File

@ -0,0 +1,440 @@
// Copyright (C) 2021-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import { ObjectState } from '.';
import { MasterImpl } from './master';
export interface Size {
width: number;
height: number;
}
export interface ActiveElement {
clientID: string | null;
attributeID: number | null;
}
export interface GroupData {
enabled: boolean;
}
export interface MergeData {
enabled: boolean;
}
export interface SplitData {
enabled: boolean;
}
export interface Image {
renderWidth: number;
renderHeight: number;
imageData: Blob;
}
export interface DrawData {
enabled: boolean;
initialState?: any;
redraw?: number;
shapeType?: string;
}
export enum FrameZoom {
MIN = 0.1,
MAX = 10,
}
export enum Planes {
TOP = 'topPlane',
SIDE = 'sidePlane',
FRONT = 'frontPlane',
PERSPECTIVE = 'perspectivePlane',
}
export enum ViewType {
PERSPECTIVE = 'perspective',
TOP = 'top',
SIDE = 'side',
FRONT = 'front',
}
export enum MouseInteraction {
CLICK = 'click',
DOUBLE_CLICK = 'dblclick',
HOVER = 'hover',
}
export interface OrientationVisibility {
x: boolean;
y: boolean;
z: boolean;
}
export interface ShapeProperties {
opacity: number;
outlined: boolean;
outlineColor: string;
selectedOpacity: number;
colorBy: string;
orientationVisibility: OrientationVisibility;
}
export enum UpdateReasons {
IMAGE_CHANGED = 'image_changed',
OBJECTS_UPDATED = 'objects_updated',
DRAW = 'draw',
SELECT = 'select',
CANCEL = 'cancel',
DRAG_CANVAS = 'drag_canvas',
SHAPE_ACTIVATED = 'shape_activated',
GROUP = 'group',
MERGE = 'merge',
SPLIT = 'split',
FITTED_CANVAS = 'fitted_canvas',
SHAPES_CONFIG_UPDATED = 'shapes_config_updated',
}
export enum Mode {
IDLE = 'idle',
DRAW = 'draw',
EDIT = 'edit',
DRAG_CANVAS = 'drag_canvas',
GROUP = 'group',
MERGE = 'merge',
SPLIT = 'split',
}
export interface Canvas3dDataModel {
activeElement: ActiveElement;
canvasSize: Size;
image: { imageData: Blob } | null;
imageID: number | null;
imageOffset: number;
imageSize: Size;
imageIsDeleted: boolean;
drawData: DrawData;
mode: Mode;
objects: ObjectState[];
shapeProperties: ShapeProperties;
groupData: GroupData;
mergeData: MergeData;
splitData: SplitData;
isFrameUpdating: boolean;
nextSetupRequest: {
frameData: any;
objectStates: ObjectState[];
} | null;
}
export interface Canvas3dModel {
mode: Mode;
data: Canvas3dDataModel;
readonly imageIsDeleted: boolean;
readonly groupData: GroupData;
readonly mergeData: MergeData;
readonly objects: ObjectState[];
setup(frameData: any, objectStates: ObjectState[]): void;
isAbleToChangeFrame(): boolean;
draw(drawData: DrawData): void;
cancel(): void;
dragCanvas(enable: boolean): void;
activate(clientID: string | null, attributeID: number | null): void;
configureShapes(shapeProperties: ShapeProperties): void;
fit(): void;
group(groupData: GroupData): void;
split(splitData: SplitData): void;
merge(mergeData: MergeData): void;
destroy(): void;
updateCanvasObjects(): void;
unlockFrameUpdating(): void;
}
export class Canvas3dModelImpl extends MasterImpl implements Canvas3dModel {
public data: Canvas3dDataModel;
public constructor() {
super();
this.data = {
activeElement: {
clientID: null,
attributeID: null,
},
canvasSize: {
height: 0,
width: 0,
},
objects: [],
image: null,
imageID: null,
imageOffset: 0,
imageSize: {
height: 0,
width: 0,
},
imageIsDeleted: false,
drawData: {
enabled: false,
initialState: null,
},
mode: Mode.IDLE,
groupData: {
enabled: false,
},
mergeData: {
enabled: false,
},
splitData: {
enabled: false,
},
shapeProperties: {
opacity: 40,
outlined: false,
outlineColor: '#000000',
selectedOpacity: 60,
colorBy: 'Label',
orientationVisibility: {
x: false,
y: false,
z: false,
},
},
isFrameUpdating: false,
nextSetupRequest: null,
};
}
public updateCanvasObjects(): void {
this.notify(UpdateReasons.OBJECTS_UPDATED);
}
public unlockFrameUpdating(): void {
this.data.isFrameUpdating = false;
if (this.data.nextSetupRequest) {
try {
const { frameData, objectStates } = this.data.nextSetupRequest;
this.setup(frameData, objectStates);
} finally {
this.data.nextSetupRequest = null;
}
}
}
public setup(frameData: any, objectStates: ObjectState[]): void {
if (this.data.imageID !== frameData.number) {
if ([Mode.EDIT].includes(this.data.mode)) {
throw Error(`Canvas is busy. Action: ${this.data.mode}`);
}
}
if (this.data.isFrameUpdating) {
this.data.nextSetupRequest = {
frameData, objectStates,
};
return;
}
if (frameData.number === this.data.imageID && frameData.deleted === this.data.imageIsDeleted) {
this.data.objects = objectStates;
this.notify(UpdateReasons.OBJECTS_UPDATED);
return;
}
this.data.isFrameUpdating = true;
this.data.imageID = frameData.number;
frameData
.data((): void => {
this.data.image = null;
this.notify(UpdateReasons.IMAGE_CHANGED);
})
.then((data: { imageData: Blob }): void => {
this.data.imageSize = {
height: frameData.height as number,
width: frameData.width as number,
};
this.data.imageIsDeleted = frameData.deleted;
this.data.image = data;
this.data.objects = objectStates;
this.notify(UpdateReasons.IMAGE_CHANGED);
})
.catch((exception: any): void => {
this.data.isFrameUpdating = false;
// don't notify when the frame is no longer needed
if (typeof exception !== 'number' || exception === this.data.imageID) {
throw exception;
}
});
}
public set mode(value: Mode) {
this.data.mode = value;
}
public get mode(): Mode {
return this.data.mode;
}
public get objects(): ObjectState[] {
return [...this.data.objects];
}
public isAbleToChangeFrame(): boolean {
const isUnable = [Mode.EDIT].includes(this.data.mode) ||
this.data.isFrameUpdating || (this.data.mode === Mode.DRAW && typeof this.data.drawData.redraw === 'number');
return !isUnable;
}
public draw(drawData: DrawData): void {
if (drawData.enabled && this.data.drawData.enabled && !drawData.initialState) {
throw new Error('Drawing has been already started');
}
if ([Mode.DRAW, Mode.EDIT].includes(this.data.mode) && !drawData.initialState) {
return;
}
this.data.drawData.enabled = drawData.enabled;
this.data.mode = Mode.DRAW;
if (typeof drawData.redraw === 'number') {
const clientID = drawData.redraw;
const [state] = this.data.objects.filter((_state: any): boolean => _state.clientID === clientID);
if (state) {
this.data.drawData = { ...drawData };
this.data.drawData.initialState = { ...this.data.drawData.initialState, label: state.label };
this.data.drawData.shapeType = state.shapeType;
} else {
return;
}
} else {
this.data.drawData = { ...drawData };
if (this.data.drawData.initialState) {
this.data.drawData.shapeType = this.data.drawData.initialState.shapeType;
}
}
this.notify(UpdateReasons.DRAW);
}
public cancel(): void {
this.notify(UpdateReasons.CANCEL);
}
public dragCanvas(enable: boolean): void {
if (enable && this.data.mode !== Mode.IDLE) {
throw Error(`Canvas is busy. Action: ${this.data.mode}`);
}
if (!enable && this.data.mode !== Mode.DRAG_CANVAS) {
throw Error(`Canvas is not in the drag mode. Action: ${this.data.mode}`);
}
this.data.mode = enable ? Mode.DRAG_CANVAS : Mode.IDLE;
this.notify(UpdateReasons.DRAG_CANVAS);
}
public activate(clientID: string | null, attributeID: number | null): void {
if (this.data.activeElement.clientID === clientID && this.data.activeElement.attributeID === attributeID) {
return;
}
if (this.data.mode !== Mode.IDLE && clientID !== null) {
throw Error(`Canvas is busy. Action: ${this.data.mode}`);
}
if (typeof clientID === 'number') {
const [state] = this.data.objects.filter((_state: any): boolean => _state.clientID === clientID);
if (!state || state.objectType === 'tag') {
return;
}
}
this.data.activeElement = {
clientID,
attributeID,
};
this.notify(UpdateReasons.SHAPE_ACTIVATED);
}
public group(groupData: GroupData): void {
if (![Mode.IDLE, Mode.GROUP].includes(this.data.mode)) {
throw Error(`Canvas is busy. Action: ${this.data.mode}`);
}
if (this.data.groupData.enabled && groupData.enabled) {
return;
}
if (!this.data.groupData.enabled && !groupData.enabled) {
return;
}
this.data.mode = groupData.enabled ? Mode.GROUP : Mode.IDLE;
this.data.groupData = { ...groupData };
this.notify(UpdateReasons.GROUP);
}
public split(splitData: SplitData): void {
if (![Mode.IDLE, Mode.SPLIT].includes(this.data.mode)) {
throw Error(`Canvas is busy. Action: ${this.data.mode}`);
}
this.data.mode = splitData.enabled ? Mode.SPLIT : Mode.IDLE;
this.data.splitData = { ...splitData };
this.notify(UpdateReasons.SPLIT);
}
public merge(mergeData: MergeData): void {
if (![Mode.IDLE, Mode.MERGE].includes(this.data.mode)) {
throw Error(`Canvas is busy. Action: ${this.data.mode}`);
}
this.data.mode = mergeData.enabled ? Mode.MERGE : Mode.IDLE;
this.data.mergeData = { ...mergeData };
this.notify(UpdateReasons.MERGE);
}
public configureShapes(shapeProperties: ShapeProperties): void {
if (typeof shapeProperties.opacity === 'number') {
this.data.shapeProperties.opacity = Math.max(0, Math.min(shapeProperties.opacity, 100));
}
if (typeof shapeProperties.selectedOpacity === 'number') {
this.data.shapeProperties.selectedOpacity = Math.max(0, Math.min(shapeProperties.selectedOpacity, 100));
}
if (['Label', 'Instance', 'Group'].includes(shapeProperties.colorBy)) {
this.data.shapeProperties.colorBy = shapeProperties.colorBy;
}
if (typeof shapeProperties.outlined === 'boolean') {
this.data.shapeProperties.outlined = shapeProperties.outlined;
}
if (typeof shapeProperties.outlineColor === 'string') {
this.data.shapeProperties.outlineColor = shapeProperties.outlineColor;
}
if (typeof shapeProperties.orientationVisibility === 'object') {
const current = this.data.shapeProperties.orientationVisibility;
current.x = !!(shapeProperties.orientationVisibility?.x ?? current.x);
current.y = !!(shapeProperties.orientationVisibility?.y ?? current.y);
current.z = !!(shapeProperties.orientationVisibility?.z ?? current.z);
}
this.notify(UpdateReasons.SHAPES_CONFIG_UPDATED);
}
public fit(): void {
this.notify(UpdateReasons.FITTED_CANVAS);
}
public get groupData(): GroupData {
return { ...this.data.groupData };
}
public get mergeData(): MergeData {
return { ...this.data.mergeData };
}
public get imageIsDeleted(): boolean {
return this.data.imageIsDeleted;
}
public destroy(): void {}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,50 @@
// Copyright (C) 2021-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
const BASE_GRID_WIDTH = 2;
const MOVEMENT_FACTOR = 200;
const DOLLY_FACTOR = 5;
const MAX_DISTANCE = 100;
const MIN_DISTANCE = 0.3;
const ZOOM_FACTOR = 7;
const ROTATION_HELPER_OFFSET = 0.75;
const CAMERA_REFERENCE = 'camRef';
const CUBOID_EDGE_NAME = 'edges';
const ROTATION_HELPER_NAME = '2DRotationHelper';
const PLANE_ROTATION_HELPER = 'planeRotationHelper';
const RESIZE_HELPER_NAME = '2DResizeHelper';
const FOV_DEFAULT = 1;
const FOV_MAX = 2;
const FOV_MIN = 0;
const FOV_INC = 0.08;
const DEFAULT_GROUP_COLOR = '#e0e0e0';
const DEFAULT_OUTLINE_COLOR = '#000000';
const GROUPING_COLOR = '#8b008b';
const MERGING_COLOR = '#0000ff';
const SPLITTING_COLOR = '#1e90ff';
export default {
BASE_GRID_WIDTH,
MOVEMENT_FACTOR,
DOLLY_FACTOR,
MAX_DISTANCE,
MIN_DISTANCE,
ZOOM_FACTOR,
ROTATION_HELPER_OFFSET,
CAMERA_REFERENCE,
CUBOID_EDGE_NAME,
ROTATION_HELPER_NAME,
PLANE_ROTATION_HELPER,
RESIZE_HELPER_NAME,
FOV_DEFAULT,
FOV_MAX,
FOV_MIN,
FOV_INC,
DEFAULT_GROUP_COLOR,
DEFAULT_OUTLINE_COLOR,
GROUPING_COLOR,
MERGING_COLOR,
SPLITTING_COLOR,
};

View File

@ -0,0 +1,299 @@
// Copyright (C) 2021-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import * as THREE from 'three';
import { OrientationVisibility, ViewType } from './canvas3dModel';
import constants from './consts';
export interface Indexable {
[key: string]: any;
}
export interface ObjectArrowHelper {
x: THREE.ArrowHelper;
y: THREE.ArrowHelper;
z: THREE.ArrowHelper;
}
export function makeCornerPointsMatrix(x: number, y: number, z: number): number[][] {
return ([
[1 * x, 1 * y, 1 * z],
[1 * x, 1 * y, -1 * z],
[1 * x, -1 * y, 1 * z],
[1 * x, -1 * y, -1 * z],
[-1 * x, 1 * y, 1 * z],
[-1 * x, 1 * y, -1 * z],
[-1 * x, -1 * y, 1 * z],
[-1 * x, -1 * y, -1 * z],
]);
}
export class CuboidModel {
public perspective: THREE.Mesh;
public top: THREE.Mesh;
public side: THREE.Mesh;
public front: THREE.Mesh;
public wireframe: THREE.LineSegments;
public orientationArrows: Record<ViewType, ObjectArrowHelper> = {
[ViewType.PERSPECTIVE]: null,
[ViewType.TOP]: null,
[ViewType.SIDE]: null,
[ViewType.FRONT]: null,
};
public constructor(outline: string, outlineColor: string) {
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshBasicMaterial({
color: 0x00ff00,
wireframe: false,
transparent: true,
opacity: 0.4,
});
this.perspective = new THREE.Mesh(geometry, material);
const geo = new THREE.EdgesGeometry(this.perspective.geometry);
this.wireframe = new THREE.LineSegments(
geo,
outline === 'line' ? new THREE.LineBasicMaterial({ color: outlineColor, linewidth: 4 }) :
new THREE.LineDashedMaterial({
color: outlineColor,
dashSize: 0.05,
gapSize: 0.05,
}),
);
this.wireframe.computeLineDistances();
this.wireframe.renderOrder = 1;
this.perspective.add(this.wireframe);
this.top = new THREE.Mesh(geometry, material);
this.side = new THREE.Mesh(geometry, material);
this.front = new THREE.Mesh(geometry, material);
[ViewType.PERSPECTIVE, ViewType.TOP, ViewType.SIDE, ViewType.FRONT].forEach((view): void => {
this.orientationArrows[view] = this.createArrows();
Object.values(this.orientationArrows[view]).forEach((arrow) => {
this[view].add(arrow);
});
});
const planeTop = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1, 1, 1),
new THREE.MeshBasicMaterial({
color: 0xff0000,
visible: false,
}),
);
const planeSide = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1, 1, 1),
new THREE.MeshBasicMaterial({
color: 0xff0000,
visible: false,
}),
);
const planeFront = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1, 1, 1),
new THREE.MeshBasicMaterial({
color: 0xff0000,
visible: false,
}),
);
this.top.add(planeTop);
planeTop.rotation.set(0, 0, 0);
planeTop.position.set(0, 0, 0.5);
planeTop.name = constants.PLANE_ROTATION_HELPER;
this.side.add(planeSide);
planeSide.rotation.set(-Math.PI / 2, 0, Math.PI);
planeTop.position.set(0, 0.5, 0);
planeSide.name = constants.PLANE_ROTATION_HELPER;
this.front.add(planeFront);
planeFront.rotation.set(0, Math.PI / 2, 0);
planeTop.position.set(0.5, 0, 0);
planeFront.name = constants.PLANE_ROTATION_HELPER;
const cornerPoints = makeCornerPointsMatrix(0.5, 0.5, 0.5);
for (let i = 0; i < cornerPoints.length; i++) {
const point = new THREE.Vector3().fromArray(cornerPoints[i]);
const helper = new THREE.Mesh(new THREE.SphereGeometry(0.1));
helper.visible = false;
helper.name = `cuboidNodeHelper_${i}`;
this.perspective.add(helper);
helper.position.copy(point);
}
const camRotateHelper = new THREE.Object3D();
camRotateHelper.translateX(-2);
camRotateHelper.name = 'camRefRot';
camRotateHelper.up = new THREE.Vector3(0, 0, 1);
camRotateHelper.lookAt(new THREE.Vector3(0, 0, 0));
this.front.add(camRotateHelper.clone());
}
private createArrows(): ObjectArrowHelper {
return {
x: new THREE.ArrowHelper(new THREE.Vector3(1, 0, 0), new THREE.Vector3(0.5, 0, 0), 1, 0xff0000),
y: new THREE.ArrowHelper(new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0.5, 0), 1, 0x00ff00),
z: new THREE.ArrowHelper(new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, 0.5), 1, 0x0000ff),
};
}
public setOrientationVisibility(orientationVisibility: OrientationVisibility): void {
[ViewType.PERSPECTIVE, ViewType.TOP, ViewType.SIDE, ViewType.FRONT].forEach((view): void => {
Object.entries(this.orientationArrows[view]).forEach(([axis, arrow]) => {
arrow.visible = orientationVisibility[axis];
});
});
}
public setPosition(x: number, y: number, z: number): void {
[ViewType.PERSPECTIVE, ViewType.TOP, ViewType.SIDE, ViewType.FRONT].forEach((view): void => {
(this as Indexable)[view].position.set(x, y, z);
});
}
public setScale(x: number, y: number, z: number): void {
[ViewType.PERSPECTIVE, ViewType.TOP, ViewType.SIDE, ViewType.FRONT].forEach((view): void => {
(this as Indexable)[view].scale.set(x, y, z);
// Arrow direction specifies its local Y axis, where it points to.
// When we change its direction to align with the X or Z axis,
// the arrows local coordinate system rotates accordingly.
// To maintain correct proportions, we apply the X or Z scaling of the cuboid
// to the arrow's Y axis (its original forward direction).
const xscale = 1.0 / x;
const yscale = 1.0 / y;
const zscale = 1.0 / z;
this.orientationArrows[view].x.scale.set(yscale, xscale, zscale);
this.orientationArrows[view].y.scale.set(xscale, yscale, zscale);
this.orientationArrows[view].z.scale.set(xscale, zscale, yscale);
});
}
public setRotation(x: number, y: number, z: number): void {
[ViewType.PERSPECTIVE, ViewType.TOP, ViewType.SIDE, ViewType.FRONT].forEach((view): void => {
(this as Indexable)[view].rotation.set(x, y, z);
});
}
public attachCameraReference(): void {
const topCameraReference = new THREE.Object3D();
topCameraReference.translateZ(2);
topCameraReference.name = constants.CAMERA_REFERENCE;
this.top.add(topCameraReference);
const sideCameraReference = new THREE.Object3D();
sideCameraReference.translateY(2);
sideCameraReference.name = constants.CAMERA_REFERENCE;
this.side.add(sideCameraReference);
const frontCameraReference = new THREE.Object3D();
frontCameraReference.translateX(2);
frontCameraReference.name = constants.CAMERA_REFERENCE;
this.front.add(frontCameraReference);
}
public getReferenceCoordinates(viewType: string): THREE.Vector3 {
const camRef = (this as Indexable)[viewType].getObjectByName(constants.CAMERA_REFERENCE);
return camRef.getWorldPosition(new THREE.Vector3());
}
public setName(clientId: any): void {
[ViewType.PERSPECTIVE, ViewType.TOP, ViewType.SIDE, ViewType.FRONT].forEach((view): void => {
(this as Indexable)[view].name = clientId;
});
}
public setColor(color: string): void {
this.setOutlineColor(color);
[ViewType.PERSPECTIVE, ViewType.TOP, ViewType.SIDE, ViewType.FRONT].forEach((view): void => {
((this as Indexable)[view].material as THREE.MeshBasicMaterial).color.set(color);
});
}
public setOutlineColor(color: string): void {
(this.wireframe.material as THREE.MeshBasicMaterial).color.set(color);
}
public setOpacity(opacity: number): void {
[ViewType.PERSPECTIVE, ViewType.TOP, ViewType.SIDE, ViewType.FRONT].forEach((view): void => {
((this as Indexable)[view].material as THREE.MeshBasicMaterial).opacity = opacity / 100;
});
}
}
export function createCuboidEdges(instance: THREE.Mesh): THREE.LineSegments {
const geometry = new THREE.EdgesGeometry(instance.geometry);
const edges = new THREE.LineSegments(geometry, new THREE.LineBasicMaterial({ color: '#ffffff', linewidth: 3 }));
edges.name = constants.CUBOID_EDGE_NAME;
instance.add(edges);
return edges;
}
export function removeCuboidEdges(instance: THREE.Mesh): void {
const edges = instance.getObjectByName(constants.CUBOID_EDGE_NAME);
instance.remove(edges);
}
export function createResizeHelper(instance: THREE.Mesh): void {
const sphereGeometry = new THREE.SphereGeometry(0.2);
const sphereMaterial = new THREE.MeshBasicMaterial({ color: '#ff0000', opacity: 1 });
const cornerPoints = makeCornerPointsMatrix(0.5, 0.5, 0.5);
for (let i = 0; i < cornerPoints.length; i++) {
const point = new THREE.Vector3().fromArray(cornerPoints[i]);
const tmpSphere = new THREE.Mesh(new THREE.SphereGeometry(0.1));
instance.add(tmpSphere);
tmpSphere.position.copy(point);
const globalPosition = tmpSphere.getWorldPosition(new THREE.Vector3());
instance.remove(tmpSphere);
const helper = new THREE.Mesh(sphereGeometry.clone(), sphereMaterial.clone());
helper.position.copy(globalPosition);
helper.name = `${constants.RESIZE_HELPER_NAME}_${i}`;
instance.parent.add(helper);
}
}
export function removeResizeHelper(instance: THREE.Mesh): void {
instance.parent.children.filter((child: THREE.Object3D) => child.name.startsWith(constants.RESIZE_HELPER_NAME))
.forEach((helper) => {
instance.parent.remove(helper);
});
}
export function createRotationHelper(instance: THREE.Mesh, viewType: ViewType): void {
if ([ViewType.TOP, ViewType.SIDE, ViewType.FRONT].includes(viewType)) {
// Create a temporary element to get correct position
const tmpSphere = new THREE.Mesh(new THREE.SphereGeometry(0.2));
instance.add(tmpSphere);
if (viewType === ViewType.TOP) {
tmpSphere.translateY(constants.ROTATION_HELPER_OFFSET);
} else {
tmpSphere.translateZ(constants.ROTATION_HELPER_OFFSET);
}
const globalPosition = tmpSphere.getWorldPosition(new THREE.Vector3());
instance.remove(tmpSphere);
// Create rotation helper itself first
const sphereGeometry = new THREE.SphereGeometry(0.2);
const sphereMaterial = new THREE.MeshBasicMaterial({ color: '#33b864', opacity: 1 });
const rotationHelper = new THREE.Mesh(sphereGeometry, sphereMaterial);
rotationHelper.name = constants.ROTATION_HELPER_NAME;
instance.parent.add(rotationHelper);
rotationHelper.position.copy(globalPosition);
}
}
export function removeRotationHelper(instance: THREE.Mesh): void {
const helper = instance.parent.getObjectByName(constants.ROTATION_HELPER_NAME);
if (helper) {
instance.parent.remove(helper);
}
}

View File

@ -0,0 +1,11 @@
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import ObjectState from 'cvat-core/src/object-state';
import { Label } from 'cvat-core/src/labels';
import { ShapeType, ObjectType } from 'cvat-core/src/enums';
export {
ObjectState, Label, ShapeType, ObjectType,
};

View File

@ -0,0 +1,44 @@
// Copyright (C) 2021-2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
export interface Master {
subscribe(listener: Listener): void;
unsubscribe(listener: Listener): void;
unsubscribeAll(): void;
notify(reason: string): void;
}
export interface Listener {
notify(master: Master, reason: string): void;
}
export class MasterImpl implements Master {
private listeners: Listener[];
public constructor() {
this.listeners = [];
}
public subscribe(listener: Listener): void {
this.listeners.push(listener);
}
public unsubscribe(listener: Listener): void {
for (let i = 0; i < this.listeners.length; i++) {
if (this.listeners[i] === listener) {
this.listeners.splice(i, 1);
}
}
}
public unsubscribeAll(): void {
this.listeners = [];
}
public notify(reason: string): void {
for (const listener of this.listeners) {
listener.notify(this, reason);
}
}
}

View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"esModuleInterop": true,
"noEmit": true,
"declaration": true,
"declarationDir": "dist/declaration",
"paths": {
"cvat-canvas.node": ["dist/cvat-canvas3d.node"]
},
"baseUrl": "."
},
"include": ["src/typescript/canvas3d.ts"]
}

View File

@ -0,0 +1,77 @@
// Copyright (C) 2021-2022 Intel Corporation
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
// eslint-disable-next-line @typescript-eslint/no-var-requires
const path = require('path');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const BundleDeclarationsWebpackPlugin = require('bundle-declarations-webpack-plugin');
const styleLoaders = [
'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 2,
},
},
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [
[
'postcss-preset-env', {},
],
],
},
},
},
'sass-loader',
];
module.exports = {
target: 'web',
mode: 'production',
devtool: 'source-map',
entry: {
'cvat-canvas3d': './src/typescript/canvas3d.ts',
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
library: 'canvas3d',
libraryTarget: 'window',
},
resolve: {
extensions: ['.ts', '.js', '.json'],
},
module: {
rules: [
{
test: /\.ts$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
plugins: ['@babel/plugin-proposal-class-properties'],
presets: ['@babel/preset-env', '@babel/typescript'],
sourceType: 'unambiguous',
},
},
},
{
test: /\.scss$/,
exclude: /node_modules/,
use: styleLoaders,
},
],
},
plugins: [
new BundleDeclarationsWebpackPlugin({
outFile: "declaration/src/cvat-canvas.d.ts",
}),
],
};

2
cvat-cli/MANIFEST.in Normal file
View File

@ -0,0 +1,2 @@
include README.md
include requirements/base.txt

75
cvat-cli/README.md Normal file
View File

@ -0,0 +1,75 @@
# Command-line client for CVAT
A simple command line interface for working with CVAT. At the moment it
implements a basic feature set but may serve as the starting point for a more
comprehensive CVAT administration tool in the future.
The following subcommands are supported:
- Projects:
- `create` - create a new project
- `delete` - delete projects
- `ls` - list all projects
- Tasks:
- `create` - create a new task
- `create-from-backup` - create a task from a backup file
- `delete` - delete tasks
- `ls` - list all tasks
- `frames` - download frames from a task
- `export-dataset` - export a task as a dataset
- `import-dataset` - import annotations into a task from a dataset
- `backup` - back up a task
- `auto-annotate` - automatically annotate a task using a local function
- Functions (Enterprise/Cloud only):
- `create-native` - create a function that can be powered by an agent
- `delete` - delete a function
- `run-agent` - process requests for a native function
## Installation
`pip install cvat-cli`
## Usage
The general form of a CLI command is:
```console
$ cvat-cli <common options> <resource> <action> <options>
```
where:
- `<common options>` are options shared between all subcommands;
- `<resource>` is a CVAT resource, such as `task`;
- `<action>` is the action to do with the resource, such as `create`;
- `<options>` is any options specific to a particular resource and action.
You can list available subcommands and options using the `--help` option:
```
$ cvat-cli --help # get help on available common options and resources
$ cvat-cli <resource> --help # get help on actions for the given resource
$ cvat-cli <resource> <action> --help # get help on action-specific options
```
## Examples
Create a task with local images:
```bash
cvat-cli --auth user task create
--labels '[{"name": "car"}, {"name": "person"}]'
"test_task"
"local"
"image1.jpg" "image2.jpg"
```
List tasks on a custom server with auth:
```bash
cvat-cli --auth admin:password \
--server-host cvat.my.server.com --server-port 30123 \
task ls
```

View File

@ -0,0 +1,19 @@
# Developer guide
Install testing requirements:
```bash
pip install -r requirements/testing.txt
```
Run unit tests:
```
cd cvat/
python manage.py test --settings cvat.settings.testing cvat-cli/
```
Install package in the editable mode:
```bash
pip install -e .
```

9
cvat-cli/pyproject.toml Normal file
View File

@ -0,0 +1,9 @@
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
[tool.isort]
profile = "black"
forced_separate = ["tests"]
line_length = 100
skip_gitignore = true # align tool behavior with Black

View File

@ -0,0 +1,5 @@
cvat-sdk==2.44.3
attrs>=24.2.0
Pillow>=10.3.0
setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability

67
cvat-cli/setup.py Normal file
View File

@ -0,0 +1,67 @@
# Copyright (C) 2022 Intel Corporation
#
# SPDX-License-Identifier: MIT
import os.path as osp
import re
from setuptools import find_packages, setup
def find_version(project_dir=None):
if not project_dir:
project_dir = osp.dirname(osp.abspath(__file__))
file_path = osp.join(project_dir, "version.py")
with open(file_path, "r") as version_file:
version_text = version_file.read()
# PEP440:
# https://www.python.org/dev/peps/pep-0440/#appendix-b-parsing-version-strings-with-regular-expressions
pep_regex = r"([1-9]\d*!)?(0|[1-9]\d*)(\.(0|[1-9]\d*))*((a|b|rc)(0|[1-9]\d*))?(\.post(0|[1-9]\d*))?(\.dev(0|[1-9]\d*))?"
version_regex = r"VERSION\s*=\s*.(" + pep_regex + ")."
match = re.match(version_regex, version_text)
if not match:
raise RuntimeError("Failed to find version string in '%s'" % file_path)
version = version_text[match.start(1) : match.end(1)]
return version
BASE_REQUIREMENTS_FILE = "requirements/base.txt"
def parse_requirements(filename=BASE_REQUIREMENTS_FILE):
with open(filename) as fh:
return fh.readlines()
BASE_REQUIREMENTS = parse_requirements(BASE_REQUIREMENTS_FILE)
with open("README.md", "r") as fh:
long_description = fh.read()
setup(
name="cvat-cli",
version=find_version(project_dir="src/cvat_cli"),
description="Command-line client for CVAT",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/cvat-ai/cvat/",
package_dir={"": "src"},
packages=find_packages(where="src"),
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
],
python_requires=">=3.9",
install_requires=BASE_REQUIREMENTS,
entry_points={
"console_scripts": [
"cvat-cli=cvat_cli.__main__:main",
],
},
include_package_data=True,
)

View File

View File

@ -0,0 +1,45 @@
# Copyright (C) 2020-2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import argparse
import logging
import sys
import urllib3.exceptions
from cvat_sdk import exceptions
from ._internal.commands_all import COMMANDS
from ._internal.common import (
CriticalError,
build_client,
configure_common_arguments,
configure_logger,
)
from ._internal.utils import popattr
logger = logging.getLogger(__name__)
def main(args: list[str] = None):
parser = argparse.ArgumentParser(description=COMMANDS.description)
configure_common_arguments(parser)
COMMANDS.configure_parser(parser)
parsed_args = parser.parse_args(args)
configure_logger(logger, parsed_args)
try:
with build_client(parsed_args, logger=logger) as client:
popattr(parsed_args, "_executor")(client, **vars(parsed_args))
except (exceptions.ApiException, urllib3.exceptions.HTTPError, CriticalError) as e:
logger.critical(e)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,951 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from __future__ import annotations
import concurrent.futures
import contextlib
import json
import multiprocessing
import random
import secrets
import shutil
import tempfile
import threading
from collections import OrderedDict
from collections.abc import Generator, Iterator, Sequence
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Optional, Union
import attrs
import cvat_sdk.auto_annotation as cvataa
import cvat_sdk.datasets as cvatds
import PIL.Image
import urllib3.exceptions
from cvat_sdk import Client, models
from cvat_sdk.auto_annotation.driver import (
_AnnotationMapper,
_DetectionFunctionContextImpl,
_SpecNameMapping,
)
from cvat_sdk.datasets.caching import make_cache_manager
from cvat_sdk.exceptions import ApiException
from typing_extensions import TypeAlias
from .common import CriticalError, FunctionLoader
if TYPE_CHECKING:
from _typeshed import SupportsReadline
FUNCTION_PROVIDER_NATIVE = "native"
FUNCTION_KIND_DETECTOR = "detector"
FUNCTION_KIND_TRACKER = "tracker"
REQUEST_CATEGORY_BATCH = "batch"
REQUEST_CATEGORY_INTERACTIVE = "interactive"
REQUEST_CATEGORIES_WITH_DECREASING_PRIORITY = (REQUEST_CATEGORY_INTERACTIVE, REQUEST_CATEGORY_BATCH)
_POLLING_INTERVAL_MEAN_FREQUENT = timedelta(seconds=60)
_POLLING_INTERVAL_MEAN_RARE = timedelta(minutes=10)
_JITTER_AMOUNT = 0.15
_UPDATE_INTERVAL = timedelta(seconds=30)
_MAX_AGE_OF_TRACKING_STATE = timedelta(hours=8)
class _RecoverableExecutor:
# A wrapper around ProcessPoolExecutor that recreates the underlying
# executor when a worker crashes.
def __init__(self, initializer, initargs):
self._mp_context = multiprocessing.get_context("spawn")
self._initializer = initializer
self._initargs = initargs
self._executor = self._new_executor()
def _new_executor(self):
return concurrent.futures.ProcessPoolExecutor(
max_workers=1,
mp_context=self._mp_context,
initializer=self._initializer,
initargs=self._initargs,
)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
self._executor.shutdown()
def submit(self, func, /, *args, **kwargs):
return self._executor.submit(func, *args, **kwargs)
def result(self, future: concurrent.futures.Future):
try:
return future.result()
except concurrent.futures.BrokenExecutor:
self._executor.shutdown()
self._executor = self._new_executor()
raise
_TrackingStateIdGenerator: TypeAlias = Callable[[], str]
def _default_tracking_state_id_generator() -> str:
# This is defined as a separate function so that tests can monkeypatch it
# in order to get deterministic state IDs.
return secrets.token_urlsafe(32)
_current_function: cvataa.AutoAnnotationFunction
_tracking_states: _TrackingStateContainer
_tracking_state_id_generator: _TrackingStateIdGenerator
@attrs.define
class _ExtendedTrackingState:
inner_state: Any # the state produced by the AA function
original_shape_type: str
original_task_id: int
original_image_dims: tuple[int, int]
last_accessed_at: datetime = attrs.field(factory=lambda: datetime.now(tz=timezone.utc))
class _TrackingStateContainer:
def __init__(self):
self._id_to_ext_state: OrderedDict[str, _ExtendedTrackingState] = OrderedDict()
def store(self, state: Any, shape_type: str, task_id: int, image_dims: tuple[int, int]) -> str:
state_id = _tracking_state_id_generator()
self._id_to_ext_state[state_id] = _ExtendedTrackingState(
inner_state=state,
original_shape_type=shape_type,
original_task_id=task_id,
original_image_dims=image_dims,
)
return state_id
def retrieve(self, state_id: str, task_id: int, image_dims: tuple[int, int]) -> Any:
ext_state = self._id_to_ext_state.get(state_id)
if not ext_state:
raise _BadArError(f"Tracking state {state_id!r} not found - possibly expired")
if ext_state.original_task_id != task_id:
# This is a defense-in-depth measure. State IDs are supposed to be unguessable,
# but even if an attacker manages to obtain one, they will not be able to use it
# to get any information about a task they don't have access to.
raise _BadArError(f"Tracking state {state_id!r} is not for task #{task_id}")
if image_dims != ext_state.original_image_dims:
raise _BadArError(f"Image sizes of the start frame and the current frame are different")
ext_state.last_accessed_at = datetime.now(tz=timezone.utc)
self._id_to_ext_state.move_to_end(state_id)
return ext_state.inner_state, ext_state.original_shape_type
def prune(self) -> None:
cutoff = datetime.now(tz=timezone.utc) - _MAX_AGE_OF_TRACKING_STATE
while (
self._id_to_ext_state
and next(iter(self._id_to_ext_state.values())).last_accessed_at < cutoff
):
self._id_to_ext_state.popitem(last=False)
def _worker_init(function_loader: FunctionLoader, state_id_generator):
global _current_function
_current_function = function_loader.load()
if isinstance(_current_function.spec, cvataa.TrackingFunctionSpec):
global _tracking_states
_tracking_states = _TrackingStateContainer()
global _tracking_state_id_generator
_tracking_state_id_generator = state_id_generator
def _worker_job_get_function_spec():
return _current_function.spec
def _worker_job_detect(
context: _DetectionFunctionContextImpl, image: PIL.Image.Image
) -> list[cvataa.DetectionAnnotation]:
return _current_function.detect(context, image)
def _worker_job_init_tracking(
task_id: int,
image: PIL.Image.Image,
shapes: list[cvataa.TrackableShape],
) -> list[str]:
_tracking_states.prune()
if hasattr(_current_function, "preprocess_image"):
pp_image = _current_function.preprocess_image(_TrackingFunctionContextImpl(), image)
else:
pp_image = image
return [
_tracking_states.store(
state=_current_function.init_tracking_state(
_TrackingFunctionShapeContextImpl(original_shape_type=shape.type), pp_image, shape
),
shape_type=shape.type,
task_id=task_id,
image_dims=image.size,
)
for shape in shapes
]
def _worker_job_track(
task_id: int, image: PIL.Image.Image, states: list[str]
) -> list[Optional[cvataa.TrackableShape]]:
_tracking_states.prune()
pp_image = _current_function.preprocess_image(_TrackingFunctionContextImpl(), image)
def track(state_id):
inner_state, original_shape_type = _tracking_states.retrieve(
state_id=state_id, task_id=task_id, image_dims=image.size
)
output_shape = _current_function.track(
_TrackingFunctionShapeContextImpl(original_shape_type=original_shape_type),
pp_image,
inner_state,
)
if output_shape and output_shape.type != original_shape_type:
raise cvataa.BadFunctionError(
f"function output shape of type {output_shape.type!r}, "
f"but original shape was of type {original_shape_type!r}"
)
return output_shape
return list(map(track, states))
@attrs.frozen
class _Event:
type: str
data: str
@attrs.frozen
class _NewReconnectionDelay:
delay: timedelta
class _TaskCacheLimiter:
"""
This class deletes least-recently used tasks from the dataset cache,
so that at any time the cache contains at most _MAX_TASKS_WITH_CHUNKS
tasks with downloaded chunks, and at most _MAX_TASKS_WITHOUT_CHUNKS without.
This helps manage disk usage, since agents may run indefinitely, and
we don't want the dataset cache to keep growing.
"""
_MAX_TASKS_WITH_CHUNKS = 1
_MAX_TASKS_WITHOUT_CHUNKS = 10
def __init__(self, client: Client) -> None:
self._client = client
self._cache_manager = make_cache_manager(client, cvatds.UpdatePolicy.IF_MISSING_OR_STALE)
self._cached_with_chunks_task_ids = []
self._cached_without_chunks_task_ids = []
self._task_ids_in_use = set()
@contextlib.contextmanager
def using_cache_for_task(
self, task_id: int, *, with_chunks: bool
) -> Generator[None, None, None]:
if task_id in self._task_ids_in_use:
# If with_chunks is True, we would have to ensure that task_id is returned to the
# "with chunks" list after it leaves _task_ids_in_use, regardless of the value of
# with_chunks in the call that initially put it in. That would be tricky to implement,
# and we don't have a use case for it, so just ban it.
assert not with_chunks
yield
return
if task_id in self._cached_with_chunks_task_ids:
# If the task already had cached chunks, we have to return it back to
# _cached_with_chunks_task_ids in the end.
with_chunks = True
self._cached_with_chunks_task_ids.remove(task_id)
elif task_id in self._cached_without_chunks_task_ids:
self._cached_without_chunks_task_ids.remove(task_id)
self._task_ids_in_use.add(task_id)
if with_chunks:
cached_task_ids = self._cached_with_chunks_task_ids
max_cached_tasks = self._MAX_TASKS_WITH_CHUNKS
else:
cached_task_ids = self._cached_without_chunks_task_ids
max_cached_tasks = self._MAX_TASKS_WITHOUT_CHUNKS
if len(cached_task_ids) + len(self._task_ids_in_use) > max_cached_tasks:
self._delete_task_cache(cached_task_ids.pop(0))
try:
yield
finally:
self._task_ids_in_use.remove(task_id)
cached_task_ids.append(task_id)
def _delete_task_cache(self, task_id: int) -> None:
self._client.logger.info("Deleting task %d from the cache to make room...", task_id)
shutil.rmtree(self._cache_manager.task_dir(task_id), ignore_errors=True)
def _parse_event_stream(
stream: SupportsReadline[bytes],
) -> Iterator[Union[_Event, _NewReconnectionDelay]]:
# https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
event_type = event_data = ""
while True:
line_bytes = stream.readline()
if not line_bytes:
return
line = line_bytes.decode("UTF-8").removesuffix("\n").removesuffix("\r")
# Technically, a standalone \r is supposed to be treated as a line terminator,
# but it's annoying to implement, and there's no reason for CVAT to use that.
if "\r" in line:
raise ValueError("CR found in event stream")
if not line:
yield _Event(event_type, event_data.removesuffix("\n"))
event_type = event_data = ""
continue
if line.startswith(":"):
# it's a comment/keepalive
continue
if ":" in line:
field_name, field_value = line.split(":", maxsplit=1)
field_value = field_value.removeprefix(" ")
else:
field_name = line
field_value = ""
if field_name == "event":
event_type = field_value
elif field_name == "data":
event_data += field_value + "\n"
elif field_name == "retry":
if field_value.isascii() and field_value.isdecimal():
yield _NewReconnectionDelay(timedelta(milliseconds=int(field_value)))
class _BadArError(Exception):
pass
class _IncompatibleFunctionError(Exception):
# This should only be thrown from inside _validate_X_function_compatibility methods.
pass
class _TrackingFunctionContextImpl(cvataa.TrackingFunctionContext):
pass
@attrs.frozen(kw_only=True)
class _TrackingFunctionShapeContextImpl(cvataa.TrackingFunctionShapeContext):
original_shape_type: str
class _Agent:
def __init__(self, client: Client, executor: _RecoverableExecutor, function_id: int):
self._rng = random.Random() # nosec
self._client = client
self._executor = executor
self._function_id = function_id
self._function_spec = self._executor.result(
self._executor.submit(_worker_job_get_function_spec)
)
_, response = self._client.api_client.call_api(
"/api/functions/{function_id}",
"GET",
path_params={"function_id": self._function_id},
)
remote_function = json.loads(response.data)
self._validate_function_compatibility(remote_function)
self._agent_id = secrets.token_hex(16)
self._client.logger.info("Agent starting with ID %r", self._agent_id)
self._task_cache_limiter = _TaskCacheLimiter(client)
self._queue_watch_response = None
self._queue_watch_response_lock = threading.Lock()
self._queue_watcher_should_stop = threading.Event()
self._potential_work_condition = threading.Condition(threading.Lock())
self._potential_work_per_category = {
category: True for category in REQUEST_CATEGORIES_WITH_DECREASING_PRIORITY
}
self._polling_interval = _POLLING_INTERVAL_MEAN_FREQUENT
# If we fail to connect to the queue event stream, it might be because
# the server is too old and doesn't support the watch endpoint.
# In this case, it doesn't make sense to continue trying to connect frequently,
# although we should still be trying occasionally in case the error is transient.
# Once we're successful, we'll rely on the server to set a new reconnection delay.
self._queue_reconnection_delay = _POLLING_INTERVAL_MEAN_RARE
def _validate_function_compatibility(self, remote_function: dict) -> None:
function_id = remote_function["id"]
if remote_function["provider"] != FUNCTION_PROVIDER_NATIVE:
raise CriticalError(
f"Function #{function_id} has provider {remote_function['provider']!r}. "
f"Agents can only be run for functions with provider {FUNCTION_PROVIDER_NATIVE!r}."
)
try:
if isinstance(self._function_spec, cvataa.DetectionFunctionSpec):
self._validate_detection_function_compatibility(remote_function)
self._calculate_result_for_ar = self._calculate_result_for_detection_ar
elif isinstance(self._function_spec, cvataa.TrackingFunctionSpec):
self._validate_tracking_function_compatibility(remote_function)
self._calculate_result_for_ar = self._calculate_result_for_tracking_ar
else:
raise CriticalError(
f"Unsupported function spec type: {type(self._function_spec).__name__}"
)
except _IncompatibleFunctionError as ex:
raise CriticalError(
f"Function #{function_id} is incompatible with function object: {ex}"
) from ex
def _validate_detection_function_compatibility(self, remote_function: dict) -> None:
self._validate_remote_function_kind(remote_function, FUNCTION_KIND_DETECTOR)
labels_by_name = {label.name: label for label in self._function_spec.labels}
for remote_label in remote_function["labels_v2"]:
label_desc = f"label {remote_label['name']!r}"
label = labels_by_name.get(remote_label["name"])
self._validate_sublabel_compatibility(remote_label, label, label_desc)
sublabels_by_name = {sl.name: sl for sl in getattr(label, "sublabels", [])}
for remote_sl in remote_label.get("sublabels", []):
sl_desc = f"sublabel {remote_sl['name']!r} of {label_desc}"
sl = sublabels_by_name.get(remote_sl["name"])
self._validate_sublabel_compatibility(remote_sl, sl, sl_desc)
def _validate_sublabel_compatibility(
self, remote_sl: dict, sl: Optional[models.Sublabel], sl_desc: str
):
if not sl:
raise CriticalError(f"{sl_desc} is not supported.")
if remote_sl["type"] not in {"any", "unknown"} and remote_sl["type"] != sl.type:
raise _IncompatibleFunctionError(
f"{sl_desc} has type {remote_sl['type']!r}, "
f"but the function object declares type {sl.type!r}."
)
attrs_by_name = {attr.name: attr for attr in getattr(sl, "attributes", [])}
for remote_attr in remote_sl["attributes"]:
attr_desc = f"attribute {remote_attr['name']!r} of {sl_desc}"
attr = attrs_by_name.get(remote_attr["name"])
if not attr:
raise _IncompatibleFunctionError(f"{attr_desc} is not supported.")
if remote_attr["input_type"] != attr.input_type.value:
raise _IncompatibleFunctionError(
f"{attr_desc} has input type {remote_attr['input_type']!r},"
f" but the function object declares input type {attr.input_type.value!r}."
)
if remote_attr["values"] != attr.values:
raise _IncompatibleFunctionError(
f"{attr_desc} has values {remote_attr['values']!r},"
f" but the function object declares values {attr.values!r}."
)
def _validate_tracking_function_compatibility(self, remote_function: dict) -> None:
self._validate_remote_function_kind(remote_function, FUNCTION_KIND_TRACKER)
remote_supported_shape_types = frozenset(remote_function["supported_shape_types"])
unsupported = remote_supported_shape_types - self._function_spec.supported_shape_types
if unsupported:
raise _IncompatibleFunctionError(
"the function object does not support the following shape types: "
+ ", ".join(map(repr, unsupported))
)
def _validate_remote_function_kind(self, remote_function: dict, expected_kind: str) -> None:
if remote_function["kind"] != expected_kind:
raise _IncompatibleFunctionError(
f"kind is {remote_function['kind']!r} (expected {expected_kind!r})."
)
def _wait_between_polls(self):
# offset the interval randomly to avoid synchronization between workers
timeout_multiplier = self._rng.uniform(1 - _JITTER_AMOUNT, 1 + _JITTER_AMOUNT)
with self._potential_work_condition:
wait_succeeded = self._potential_work_condition.wait_for(
lambda: any(self._potential_work_per_category.values()),
timeout=self._polling_interval.total_seconds() * timeout_multiplier,
)
if not wait_succeeded:
# If we timed out, there is a possibility that the queue watcher is broken or
# that it somehow missed an event. Either way, we'll force a poll to make sure
# we don't miss anything.
for category in self._potential_work_per_category:
self._potential_work_per_category[category] = True
def _dispatch_queue_event(self, event: _Event) -> None:
if event.type == "newrequest":
event_data_object = json.loads(event.data)
request_category = event_data_object["request_category"]
with self._potential_work_condition:
if request_category in self._potential_work_per_category:
self._client.logger.info(
"Received notification about a new request of category %r",
request_category,
)
self._potential_work_per_category[request_category] = True
self._potential_work_condition.notify()
else:
self._client.logger.warning(
"Received notification about a new request of unknown category: %r",
request_category,
)
else:
self._client.logger.warning("Received event of unknown type: %r", event.type)
def _wait_before_reconnecting_to_queue(self):
delay_multiplier = self._rng.uniform(1, 1 + _JITTER_AMOUNT)
self._queue_watcher_should_stop.wait(
timeout=self._queue_reconnection_delay.total_seconds() * delay_multiplier
)
# Apply exponential backoff.
self._queue_reconnection_delay = min(
self._queue_reconnection_delay * 2, _POLLING_INTERVAL_MEAN_RARE
)
def _watch_queue(self) -> None:
while not self._queue_watcher_should_stop.is_set():
# Until we can (re)connect to the event stream, poll more frequently.
self._polling_interval = _POLLING_INTERVAL_MEAN_FREQUENT
with self._queue_watch_response_lock:
self._client.logger.info("Attempting to watch the function's queue...")
try:
_, self._queue_watch_response = self._client.api_client.call_api(
"/api/functions/queues/{queue_id}/watch",
"GET",
path_params={"queue_id": f"function:{self._function_id}"},
_parse_response=False,
)
except Exception:
self._client.logger.error(
"Failed to connect to the queue event stream; will retry",
exc_info=True,
)
self._wait_before_reconnecting_to_queue()
continue
else:
self._client.logger.info("Connected to the queue event stream")
# Now we can rely on notifications, so slow down polling.
self._polling_interval = _POLLING_INTERVAL_MEAN_RARE
try:
for message in _parse_event_stream(self._queue_watch_response):
if isinstance(message, _Event):
self._dispatch_queue_event(message)
elif isinstance(message, _NewReconnectionDelay):
self._queue_reconnection_delay = message.delay
self._client.logger.info(
"New queue event stream reconnection delay is %fs",
self._queue_reconnection_delay.total_seconds(),
)
else:
assert False, f"unexpected message type {type(message)}"
self._queue_watch_response.release_conn()
# We should normally not get here unless the function is deleted on the server.
# However, we don't know that for sure, so instead of quitting immediately,
# we'll ask the main thread to poll for an AR.
# If the function did get deleted, the main thread will get a 404 and quit.
# Otherwise, we'll just reconnect again.
with self._potential_work_condition:
for category in self._potential_work_per_category:
self._potential_work_per_category[category] = True
self._potential_work_condition.notify()
self._client.logger.warning("Event stream ended; will reconnect")
except Exception:
# This is an extra check to prevent useless messages.
# If we crashed, but the main thread wants us to stop anyway,
# we should just stop and not spam the log.
if self._queue_watcher_should_stop.is_set():
break
self._client.logger.error(
"Event stream interrupted or other error; will reconnect", exc_info=True
)
finally:
self._queue_watch_response.close()
self._wait_before_reconnecting_to_queue()
def run(self, *, burst: bool) -> None:
if burst:
self._process_all_available_ars()
self._client.logger.info("No annotation requests left in queue; exiting.")
else:
watcher = threading.Thread(name="Queue Watcher", target=self._watch_queue)
watcher.start()
try:
while True:
self._process_all_available_ars()
self._wait_between_polls()
finally:
self._queue_watcher_should_stop.set()
with self._queue_watch_response_lock:
if self._queue_watch_response:
with contextlib.suppress(Exception):
# shutdown() requires urllib3 2.3.0, whereas we only require 1.25
# (via the SDK). The reason we can't bump the requirement is that
# the testsuite depends on botocore, which is incompatible with urllib3
# 2.x on Python 3.9 and earlier.
# Since pip will, by default, install the latest dependency versions,
# most users should not be affected. For the ones that are, shutdown
# will be broken, but everything else should still work fine.
# This should be revisited once we drop Python 3.9 support.
self._queue_watch_response.shutdown()
watcher.join()
def _process_all_available_ars(self):
for category in REQUEST_CATEGORIES_WITH_DECREASING_PRIORITY:
self._process_available_ars(category)
def _process_available_ars(self, category) -> None:
with self._potential_work_condition:
if not self._potential_work_per_category[category]:
return
self._potential_work_per_category[category] = False
while ar_assignment := self._poll_for_ar(category):
self._process_ar(ar_assignment)
def _process_ar(self, ar_assignment: dict) -> None:
ar_id = ar_assignment["ar_id"]
ar_params = ar_assignment["ar_params"]
self._client.logger.info(
"Got assigned annotation request %r of type %r (%s)",
ar_id,
ar_params["type"],
# Log only a few key parameters to avoid cluttering the info-level log.
" ".join([f"{k}={ar_params[k]!r}" for k in ("task", "frame") if k in ar_params]),
)
self._client.logger.debug("AR %r parameters: %r", ar_id, ar_params)
try:
result = self._calculate_result_for_ar(ar_id, ar_params)
self._client.logger.info("Submitting result for AR %r...", ar_id)
self._client.api_client.call_api(
"/api/functions/queues/{queue_id}/requests/{request_id}/complete",
"POST",
path_params={"queue_id": f"function:{self._function_id}", "request_id": ar_id},
body={"agent_id": self._agent_id, **result},
)
self._client.logger.info("AR %r completed", ar_id)
except Exception as ex:
self._client.logger.error("Failed to process AR %r", ar_id, exc_info=True)
# Arbitrary exceptions may contain details of the client's system or code, which
# shouldn't be exposed to the server (and to users of the function).
# Therefore, we only produce a limited amount of detail, and only in known failure cases.
error_message = "Unknown error"
if isinstance(ex, ApiException):
if ex.status:
error_message = f"Received HTTP status {ex.status}"
else:
error_message = "Failed an API call"
elif isinstance(ex, urllib3.exceptions.RequestError):
if isinstance(ex, urllib3.exceptions.MaxRetryError):
ex_type = type(ex.reason)
else:
ex_type = type(ex)
error_message = f"Failed to make an HTTP request to {ex.url} ({ex_type.__name__})"
elif isinstance(ex, urllib3.exceptions.HTTPError):
error_message = "Failed to make an HTTP request"
elif isinstance(ex, cvataa.BadFunctionError):
error_message = "Underlying function returned incorrect result: " + str(ex)
elif isinstance(ex, _BadArError):
error_message = "Invalid annotation request: " + str(ex)
elif isinstance(ex, concurrent.futures.BrokenExecutor):
error_message = "Worker process crashed"
try:
self._client.api_client.call_api(
"/api/functions/queues/{queue_id}/requests/{request_id}/fail",
"POST",
path_params={
"queue_id": f"function:{self._function_id}",
"request_id": ar_id,
},
body={"agent_id": self._agent_id, "exc_info": error_message},
)
except Exception:
self._client.logger.error("Couldn't fail AR %r", ar_id, exc_info=True)
else:
self._client.logger.info("AR %r failed", ar_id)
def _poll_for_ar(self, category: str) -> Optional[dict]:
while True:
self._client.logger.info(
"Trying to acquire an annotation request of category %r...", category
)
try:
_, response = self._client.api_client.call_api(
"/api/functions/queues/{queue_id}/requests/acquire",
"POST",
path_params={"queue_id": f"function:{self._function_id}"},
body={"agent_id": self._agent_id, "request_category": category},
)
break
except (urllib3.exceptions.HTTPError, ApiException) as ex:
if isinstance(ex, ApiException) and ex.status and 400 <= ex.status < 500:
# We did something wrong; no point in retrying.
raise
self._client.logger.error("Acquire request failed; will retry", exc_info=True)
self._wait_between_polls()
response_data = json.loads(response.data)
return response_data["ar_assignment"]
def _calculate_result_for_detection_ar(self, ar_id: str, ar_params) -> dict[str, Any]:
if ar_params["type"] == "annotate_task":
with self._task_cache_limiter.using_cache_for_task(ar_params["task"], with_chunks=True):
return self._calculate_result_for_annotate_task_ar(ar_id, ar_params)
elif ar_params["type"] == "annotate_frame":
with self._task_cache_limiter.using_cache_for_task(
ar_params["task"], with_chunks=False
):
return self._calculate_result_for_annotate_frame_ar(ar_id, ar_params)
else:
raise _BadArError(f"unsupported type: {ar_params['type']!r}")
def _create_annotation_mapper_for_detection_ar(
self, ar_params: dict, ds_labels: Sequence[models.ILabel]
) -> _AnnotationMapper:
spec_nm = _SpecNameMapping.from_api(
{
k: models.LabelMappingEntryRequest._from_openapi_data(**v)
for k, v in ar_params["mapping"].items()
}
)
return _AnnotationMapper(
self._client.logger,
self._function_spec.labels,
ds_labels,
allow_unmatched_labels=False,
spec_nm=spec_nm,
conv_mask_to_poly=ar_params["conv_mask_to_poly"],
)
def _create_detection_function_context(
self, ar_params: dict, frame_name: str
) -> cvataa.DetectionFunctionContext:
return _DetectionFunctionContextImpl(
frame_name=frame_name,
conf_threshold=ar_params["threshold"],
conv_mask_to_poly=ar_params["conv_mask_to_poly"],
)
def _calculate_result_for_annotate_task_ar(self, ar_id: str, ar_params) -> dict[str, Any]:
ds = cvatds.TaskDataset(self._client, ar_params["task"], load_annotations=False)
# Fetching the dataset might take a while, so do a progress update to let the server
# know we're still alive.
self._update_ar(ar_id, 0)
last_update_timestamp = datetime.now(tz=timezone.utc)
mapper = self._create_annotation_mapper_for_detection_ar(ar_params, ds.labels)
all_annotations = models.PatchedLabeledDataRequest(tags=[], shapes=[])
for sample_index, sample in enumerate(ds.samples):
context = self._create_detection_function_context(ar_params, sample.frame_name)
annotations = self._executor.result(
self._executor.submit(_worker_job_detect, context, sample.media.load_image())
)
tags, shapes = mapper.validate_and_remap(annotations, sample.frame_index)
all_annotations.tags.extend(tags)
all_annotations.shapes.extend(shapes)
current_timestamp = datetime.now(tz=timezone.utc)
if current_timestamp >= last_update_timestamp + _UPDATE_INTERVAL:
self._update_ar(ar_id, (sample_index + 1) / len(ds.samples))
last_update_timestamp = current_timestamp
# Interactive requests are time sensitive, so if there are any,
# we have to put the current AR on hold and process them ASAP.
self._process_available_ars(REQUEST_CATEGORY_INTERACTIVE)
return {"annotations": all_annotations}
def _calculate_result_for_annotate_frame_ar(self, ar_id: str, ar_params) -> dict[str, Any]:
sample, ds_labels = self._get_sample_from_ar_params(ar_params)
mapper = self._create_annotation_mapper_for_detection_ar(ar_params, ds_labels)
context = self._create_detection_function_context(ar_params, sample.frame_name)
annotations = self._executor.result(
self._executor.submit(_worker_job_detect, context, sample.media.load_image())
)
tags, shapes = mapper.validate_and_remap(annotations, sample.frame_index)
return {"annotations": models.PatchedLabeledDataRequest(tags=tags, shapes=shapes)}
def _calculate_result_for_tracking_ar(self, ar_id: str, ar_params) -> dict[str, Any]:
if ar_params["type"] == "init_tracking":
with self._task_cache_limiter.using_cache_for_task(
ar_params["task"], with_chunks=False
):
return self._calculate_result_for_init_tracking_ar(ar_id, ar_params)
elif ar_params["type"] == "track":
with self._task_cache_limiter.using_cache_for_task(
ar_params["task"], with_chunks=False
):
return self._calculate_result_for_track_ar(ar_id, ar_params)
else:
raise _BadArError(f"unsupported type: {ar_params['type']!r}")
def _calculate_result_for_init_tracking_ar(self, ar_id: str, ar_params) -> dict[str, Any]:
sample, _ = self._get_sample_from_ar_params(ar_params)
def convert_shape(shape: dict) -> cvataa.TrackableShape:
if shape["type"] not in self._function_spec.supported_shape_types:
raise _BadArError(f"Unsupported shape type {shape['type']!r}")
return cvataa.TrackableShape(type=shape["type"], points=shape["points"])
shapes = list(map(convert_shape, ar_params["shapes"]))
states = self._executor.result(
self._executor.submit(
_worker_job_init_tracking,
ar_params["task"],
sample.media.load_image(),
shapes,
)
)
return {"states": states}
def _calculate_result_for_track_ar(self, ar_id: str, ar_params) -> dict[str, Any]:
sample, _ = self._get_sample_from_ar_params(ar_params)
states = ar_params["states"]
shapes = self._executor.result(
self._executor.submit(
_worker_job_track, ar_params["task"], sample.media.load_image(), states
)
)
return {
"states": states,
"shapes": [attrs.asdict(shape) if shape else None for shape in shapes],
}
def _get_sample_from_ar_params(self, ar_params):
ds = cvatds.TaskDataset(
self._client,
ar_params["task"],
load_annotations=False,
media_download_policy=cvatds.MediaDownloadPolicy.FETCH_FRAMES_ON_DEMAND,
)
frame_index = ar_params["frame"]
# Since ds.samples excludes deleted frames, we can't just do sample = ds.samples[frame_index].
# Once we drop Python 3.9, we can change this to use bisect instead of the linear search.
for sample in ds.samples:
if sample.frame_index == frame_index:
break
else:
raise _BadArError(f"Frame with index {frame_index} does not exist in the task")
return sample, ds.labels
def _update_ar(self, ar_id: str, progress: float) -> None:
self._client.logger.info("Updating AR %r progress to %.2f%%", ar_id, progress * 100)
self._client.api_client.call_api(
"/api/functions/queues/{queue_id}/requests/{request_id}/update",
"POST",
path_params={"queue_id": f"function:{self._function_id}", "request_id": ar_id},
body={"agent_id": self._agent_id, "progress": progress},
)
def run_agent(
client: Client, function_loader: FunctionLoader, function_id: int, *, burst: bool
) -> None:
with (
_RecoverableExecutor(
initializer=_worker_init,
initargs=[function_loader, _default_tracking_state_id_generator],
) as executor,
tempfile.TemporaryDirectory() as cache_dir,
):
client.config.cache_dir = Path(cache_dir, "cache")
client.logger.info("Will store cache at %s", client.config.cache_dir)
agent = _Agent(client, executor, function_id)
agent.run(burst=burst)

View File

@ -0,0 +1,126 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import argparse
import json
import textwrap
import types
from abc import ABCMeta, abstractmethod
from collections.abc import Mapping, Sequence
from typing import Callable, Protocol
from cvat_sdk import Client
class Command(Protocol):
@property
def description(self) -> str: ...
def configure_parser(self, parser: argparse.ArgumentParser) -> None: ...
# The exact parameters accepted by `execute` vary between commands,
# so we're forced to declare it like this instead of as a method.
@property
def execute(self) -> Callable[..., None]: ...
class CommandGroup:
def __init__(self, *, description: str) -> None:
self._commands: dict[str, Command] = {}
self.description = description
def command_class(self, name: str):
def decorator(cls: type):
self._commands[name] = cls()
return cls
return decorator
def add_command(self, name: str, command: Command) -> None:
self._commands[name] = command
@property
def commands(self) -> Mapping[str, Command]:
return types.MappingProxyType(self._commands)
def configure_parser(self, parser: argparse.ArgumentParser) -> None:
subparsers = parser.add_subparsers(required=True)
for name, command in self._commands.items():
subparser = subparsers.add_parser(name, description=command.description)
subparser.set_defaults(_executor=command.execute)
command.configure_parser(subparser)
def execute(self) -> None:
# It should be impossible for a command group to be executed,
# because configure_parser requires that a subcommand is specified.
assert False, "unreachable code"
class DeprecatedAlias:
def __init__(self, command: Command, replacement: str) -> None:
self._command = command
self._replacement = replacement
@property
def description(self) -> str:
return textwrap.dedent(
f"""\
{self._command.description}
(Deprecated; use "{self._replacement}" instead.)
"""
)
def configure_parser(self, parser: argparse.ArgumentParser) -> None:
self._command.configure_parser(parser)
def execute(self, client: Client, **kwargs) -> None:
client.logger.warning('This command is deprecated. Use "%s" instead.', self._replacement)
self._command.execute(client, **kwargs)
class GenericCommand(metaclass=ABCMeta):
@abstractmethod
def repo(self, client: Client): ...
@property
@abstractmethod
def resource_type_str(self) -> str: ...
class GenericListCommand(GenericCommand):
@property
def description(self) -> str:
return f"List all CVAT {self.resource_type_str}s in either basic or JSON format."
def configure_parser(self, parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--json",
dest="use_json_output",
default=False,
action="store_true",
help="output JSON data",
)
def execute(self, client: Client, *, use_json_output: bool = False):
results = self.repo(client).list(return_json=use_json_output)
if use_json_output:
print(json.dumps(json.loads(results), indent=2))
else:
for r in results:
print(r.id)
class GenericDeleteCommand(GenericCommand):
@property
def description(self):
return f"Delete a list of {self.resource_type_str}s, ignoring those which don't exist."
def configure_parser(self, parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"ids", type=int, help=f"list of {self.resource_type_str} IDs", nargs="+"
)
def execute(self, client: Client, *, ids: Sequence[int]) -> None:
self.repo(client).remove_by_ids(ids)

View File

@ -0,0 +1,29 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from .command_base import CommandGroup, DeprecatedAlias
from .commands_functions import COMMANDS as COMMANDS_FUNCTIONS
from .commands_projects import COMMANDS as COMMANDS_PROJECTS
from .commands_tasks import COMMANDS as COMMANDS_TASKS
COMMANDS = CommandGroup(description="Perform operations on CVAT resources.")
COMMANDS.add_command("function", COMMANDS_FUNCTIONS)
COMMANDS.add_command("project", COMMANDS_PROJECTS)
COMMANDS.add_command("task", COMMANDS_TASKS)
_legacy_mapping = {
"create": "create",
"ls": "ls",
"delete": "delete",
"frames": "frames",
"dump": "export-dataset",
"upload": "import-dataset",
"export": "backup",
"import": "create-from-backup",
"auto-annotate": "auto-annotate",
}
for _legacy, _new in _legacy_mapping.items():
COMMANDS.add_command(_legacy, DeprecatedAlias(COMMANDS_TASKS.commands[_new], f"task {_new}"))

View File

@ -0,0 +1,166 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import argparse
import json
import textwrap
from collections.abc import Sequence
from typing import Any, Union
import cvat_sdk.auto_annotation as cvataa
from cvat_sdk import Client, models
from .agent import (
FUNCTION_KIND_DETECTOR,
FUNCTION_KIND_TRACKER,
FUNCTION_PROVIDER_NATIVE,
run_agent,
)
from .command_base import CommandGroup
from .common import FunctionLoader, configure_function_implementation_arguments
COMMANDS = CommandGroup(description="Perform operations on CVAT lambda functions.")
@COMMANDS.command_class("create-native")
class FunctionCreateNative:
description = textwrap.dedent(
"""\
Create a CVAT function that can be powered by an agent running the given local function.
"""
)
def configure_parser(self, parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"name",
help="a human-readable name for the function",
)
configure_function_implementation_arguments(parser)
@staticmethod
def _dump_sublabel_spec(
sl_spec: Union[models.SublabelRequest, models.PatchedLabelRequest],
) -> dict:
result = {
"name": sl_spec.name,
"attributes": [
{
"name": attribute_spec.name,
"input_type": attribute_spec.input_type,
"values": attribute_spec.values,
}
for attribute_spec in getattr(sl_spec, "attributes", [])
],
}
if getattr(sl_spec, "type", "any") != "any":
# Add the type conditionally, to stay compatible with older
# CVAT versions when the function doesn't define label types.
result["type"] = sl_spec.type
return result
def execute(
self,
client: Client,
*,
name: str,
function_loader: FunctionLoader,
) -> None:
function = function_loader.load()
remote_function: dict[str, Any] = {
"provider": FUNCTION_PROVIDER_NATIVE,
"name": name,
}
spec = function.spec
if isinstance(spec, cvataa.DetectionFunctionSpec):
remote_function["kind"] = FUNCTION_KIND_DETECTOR
remote_function["labels_v2"] = []
for label_spec in spec.labels:
remote_function["labels_v2"].append(self._dump_sublabel_spec(label_spec))
if sublabels := getattr(label_spec, "sublabels", None):
remote_function["labels_v2"][-1]["sublabels"] = [
self._dump_sublabel_spec(sublabel) for sublabel in sublabels
]
elif isinstance(spec, cvataa.TrackingFunctionSpec):
remote_function["kind"] = FUNCTION_KIND_TRACKER
remote_function["supported_shape_types"] = sorted(spec.supported_shape_types)
else:
raise cvataa.BadFunctionError(f"Unsupported function spec type: {type(spec).__name__}")
_, response = client.api_client.call_api(
"/api/functions",
"POST",
body=remote_function,
)
remote_function = json.loads(response.data)
client.logger.info(
"Created function #%d: %s", remote_function["id"], remote_function["name"]
)
print(remote_function["id"])
@COMMANDS.command_class("delete")
class FunctionDelete:
description = "Delete a list of functions, ignoring those which don't exist."
def configure_parser(self, parser: argparse.ArgumentParser) -> None:
parser.add_argument("function_ids", type=int, help="IDs of functions to delete", nargs="+")
def execute(self, client: Client, *, function_ids: Sequence[int]) -> None:
for function_id in function_ids:
_, response = client.api_client.call_api(
"/api/functions/{function_id}",
"DELETE",
path_params={"function_id": function_id},
_check_status=False,
)
if 200 <= response.status <= 299:
client.logger.info(f"Function #{function_id} deleted")
elif response.status == 404:
client.logger.warning(f"Function #{function_id} not found")
else:
client.logger.error(
f"Failed to delete function #{function_id}: "
f"{response.msg} (status {response.status})"
)
@COMMANDS.command_class("run-agent")
class FunctionRunAgent:
description = "Process requests for a given native function, indefinitely."
def configure_parser(self, parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"function_id",
type=int,
help="ID of the function to process requests for",
)
configure_function_implementation_arguments(parser)
parser.add_argument(
"--burst",
action="store_true",
help="process all pending requests and then exit",
)
def execute(
self,
client: Client,
*,
function_id: int,
function_loader: FunctionLoader,
burst: bool,
) -> None:
run_agent(client, function_loader, function_id, burst=burst)

Some files were not shown because too many files have changed in this diff Show More