diff --git a/CHANGELOG.md b/CHANGELOG.md index ca2dea56..79ed9f96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [Datumaro] Dataset statistics () - Ability to change label color in tasks and predefined labels () - [Datumaro] Multi-dataset merge (https://github.com/opencv/cvat/pull/1695) +- Ability to configure email verification for new users () - Link to django admin page from UI () - Notification message when users use wrong browser () @@ -27,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Issue loading openvino models for semi-automatic and automatic annotation () - Basic functions of CVAT works without activated nuclio dashboard +- Fixed a case in which exported masks could have wrong color order () - Fixed error with creating task with labels with the same name () - Django RQ dashboard view () diff --git a/Dockerfile.ui b/Dockerfile.ui index 48fd3c73..6af7763c 100644 --- a/Dockerfile.ui +++ b/Dockerfile.ui @@ -21,6 +21,8 @@ COPY cvat-canvas/package*.json /tmp/cvat-canvas/ COPY cvat-ui/package*.json /tmp/cvat-ui/ COPY cvat-data/package*.json /tmp/cvat-data/ +RUN npm config set loglevel info + # Install cvat-data dependencies WORKDIR /tmp/cvat-data/ RUN npm install diff --git a/README.md b/README.md index c02bbb5d..275247ac 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,11 @@ annotation team. Try it online [cvat.org](https://cvat.org). - [Annotation mode](https://youtu.be/vH_639N67HI) - [Interpolation of bounding boxes](https://youtu.be/Hc3oudNuDsY) - [Interpolation of polygons](https://youtu.be/K4nis9lk92s) +- [Tag_annotation_video](https://youtu.be/62bI4mF-Xfk) - [Attribute mode](https://youtu.be/iIkJsOkDzVA) -- [Segmentation mode](https://youtu.be/Fh8oKuSUIPs) -- [Tutorial for polygons](https://www.youtube.com/watch?v=XTwfXDh4clI) -- [Semi-automatic segmentation](https://www.youtube.com/watch?v=vnqXZ-Z-VTQ) +- [Segmentation mode](https://youtu.be/9Fe_GzMLo3E) +- [Tutorial for polygons](https://youtu.be/C7-r9lZbjBw) +- [Semi-automatic segmentation](https://youtu.be/9HszWP_qsRQ) ## Supported annotation formats diff --git a/cvat-core/src/api-implementation.js b/cvat-core/src/api-implementation.js index 32dd6c5b..5fc7958c 100644 --- a/cvat-core/src/api-implementation.js +++ b/cvat-core/src/api-implementation.js @@ -81,8 +81,10 @@ cvat.server.register.implementation = async (username, firstName, lastName, email, password1, password2, userConfirmations) => { - await serverProxy.server.register(username, firstName, lastName, email, - password1, password2, userConfirmations); + const user = await serverProxy.server.register(username, firstName, + lastName, email, password1, password2, userConfirmations); + + return new User(user); }; cvat.server.login.implementation = async (username, password) => { diff --git a/cvat-core/src/api.js b/cvat-core/src/api.js index ab5ba39b..9d040683 100644 --- a/cvat-core/src/api.js +++ b/cvat-core/src/api.js @@ -148,6 +148,7 @@ function build() { * @param {string} password1 A password for the new account * @param {string} password2 The confirmation password for the new account * @param {Object} userConfirmations An user confirmations of terms of use if needed + * @returns {Object} response data * @throws {module:API.cvat.exceptions.PluginError} * @throws {module:API.cvat.exceptions.ServerError} */ diff --git a/cvat-core/src/user.js b/cvat-core/src/user.js index 62221c67..555ea83d 100644 --- a/cvat-core/src/user.js +++ b/cvat-core/src/user.js @@ -23,6 +23,7 @@ is_staff: null, is_superuser: null, is_active: null, + email_verification_required: null, }; for (const property in data) { @@ -143,6 +144,16 @@ */ get: () => data.is_active, }, + isVerified: { + /** + * @name isVerified + * @type {boolean} + * @memberof module:API.cvat.classes.User + * @readonly + * @instance + */ + get: () => !data.email_verification_required, + }, })); } } diff --git a/cvat-ui/package-lock.json b/cvat-ui/package-lock.json index 4f97ca1e..fbbd3cea 100644 --- a/cvat-ui/package-lock.json +++ b/cvat-ui/package-lock.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.8.1", + "version": "1.8.4", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/cvat-ui/package.json b/cvat-ui/package.json index 64353225..1065c3f8 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.8.1", + "version": "1.8.4", "description": "CVAT single-page application", "main": "src/index.tsx", "scripts": { diff --git a/cvat-ui/src/actions/auth-actions.ts b/cvat-ui/src/actions/auth-actions.ts index 3aff6ef3..f12049fa 100644 --- a/cvat-ui/src/actions/auth-actions.ts +++ b/cvat-ui/src/actions/auth-actions.ts @@ -75,11 +75,10 @@ export const registerAsync = ( dispatch(authActions.register()); try { - await cvat.server.register(username, firstName, lastName, email, password1, password2, + const user = await cvat.server.register(username, firstName, lastName, email, password1, password2, confirmations); - const users = await cvat.users.get({ self: true }); - dispatch(authActions.registerSuccess(users[0])); + dispatch(authActions.registerSuccess(user)); } catch (error) { dispatch(authActions.registerFailed(error)); } diff --git a/cvat-ui/src/components/create-task-page/advanced-configuration-form.tsx b/cvat-ui/src/components/create-task-page/advanced-configuration-form.tsx index c2dd2552..8fd5b244 100644 --- a/cvat-ui/src/components/create-task-page/advanced-configuration-form.tsx +++ b/cvat-ui/src/components/create-task-page/advanced-configuration-form.tsx @@ -94,7 +94,7 @@ class AdvancedConfigurationForm extends React.PureComponent { delete filteredValues.frameStep; if (values.overlapSize && +values.segmentSize <= +values.overlapSize) { - reject(new Error('Overlap size must be more than segment size')); + reject(new Error('Segment size must be more than overlap size')); } if (typeof (values.startFrame) !== 'undefined' && typeof (values.stopFrame) !== 'undefined' diff --git a/cvat-ui/src/components/cvat-app.tsx b/cvat-ui/src/components/cvat-app.tsx index 83a81ede..5c1af2cf 100644 --- a/cvat-ui/src/components/cvat-app.tsx +++ b/cvat-ui/src/components/cvat-app.tsx @@ -129,7 +129,7 @@ class CVATApplication extends React.PureComponent diff --git a/cvat-ui/src/components/labels-editor/common.ts b/cvat-ui/src/components/labels-editor/common.ts index ed3a6413..a50765da 100644 --- a/cvat-ui/src/components/labels-editor/common.ts +++ b/cvat-ui/src/components/labels-editor/common.ts @@ -44,6 +44,11 @@ function validateParsedAttribute(attr: Attribute): void { + `Attribute values must be an array. Got type ${typeof (attr.values)}`); } + if (!attr.values.length) { + throw new Error(`Attribute: "${attr.name}". Attribute values array mustn't be empty`); + } + + for (const value of attr.values) { if (typeof (value) !== 'string') { throw new Error(`Attribute: "${attr.name}". ` @@ -62,6 +67,11 @@ export function validateParsedLabel(label: Label): void { + `Type of label id must be only a number or undefined. Got value ${label.id}`); } + if (typeof (label.color) !== 'string') { + throw new Error(`Label "${label.name}". ` + + `Label color must be a string. Got ${typeof (label.color)}`); + } + if (!label.color.match(/^#[0-9a-f]{6}$|^$/)) { throw new Error(`Label "${label.name}". ` + `Type of label color must be only a valid color string. Got value ${label.color}`); diff --git a/cvat-ui/src/index.tsx b/cvat-ui/src/index.tsx index cfb1605d..f1f7f7f1 100644 --- a/cvat-ui/src/index.tsx +++ b/cvat-ui/src/index.tsx @@ -144,20 +144,17 @@ ReactDOM.render( document.getElementById('root'), ); -window.onerror = ( - message: Event | string, - source?: string, - lineno?: number, - colno?: number, - error?: Error, -) => { - if (typeof (message) === 'string' && source && typeof (lineno) === 'number' && (typeof (colno) === 'number') && error) { +window.addEventListener('error', (errorEvent: ErrorEvent) => { + if (errorEvent.filename + && typeof (errorEvent.lineno) === 'number' + && typeof (errorEvent.colno) === 'number' + && errorEvent.error) { const logPayload = { - filename: source, - line: lineno, - message: error.message, - column: colno, - stack: error.stack, + filename: errorEvent.filename, + line: errorEvent.lineno, + message: errorEvent.error.message, + column: errorEvent.colno, + stack: errorEvent.error.stack, }; const store = getCVATStore(); @@ -171,4 +168,4 @@ window.onerror = ( logger.log(LogType.sendException, logPayload); } } -}; +}); diff --git a/cvat-ui/src/reducers/interfaces.ts b/cvat-ui/src/reducers/interfaces.ts index cd971cff..fbc4b5ed 100644 --- a/cvat-ui/src/reducers/interfaces.ts +++ b/cvat-ui/src/reducers/interfaces.ts @@ -252,6 +252,7 @@ export interface NotificationsState { }; auth: { changePasswordDone: string; + registerDone: string; }; }; } diff --git a/cvat-ui/src/reducers/notifications-reducer.ts b/cvat-ui/src/reducers/notifications-reducer.ts index a869011f..dfbfdbf5 100644 --- a/cvat-ui/src/reducers/notifications-reducer.ts +++ b/cvat-ui/src/reducers/notifications-reducer.ts @@ -97,6 +97,7 @@ const defaultState: NotificationsState = { }, auth: { changePasswordDone: '', + registerDone: '', }, }, }; @@ -163,6 +164,25 @@ export default function (state = defaultState, action: AnyAction): Notifications }, }; } + case AuthActionTypes.REGISTER_SUCCESS: { + if (!action.payload.user.isVerified) { + return { + ...state, + messages: { + ...state.messages, + auth: { + ...state.messages.auth, + registerDone: `To use your account, you need to confirm the email address. \ + We have sent an email with a confirmation link to ${action.payload.user.email}.`, + }, + }, + }; + } + + return { + ...state, + }; + } case AuthActionTypes.CHANGE_PASSWORD_SUCCESS: { return { ...state, diff --git a/cvat/apps/authentication/auth_basic.py b/cvat/apps/authentication/auth_basic.py index 588baf9d..4c4ab1bc 100644 --- a/cvat/apps/authentication/auth_basic.py +++ b/cvat/apps/authentication/auth_basic.py @@ -1,8 +1,12 @@ -# Copyright (C) 2018 Intel Corporation +# Copyright (C) 2018-2020 Intel Corporation # # SPDX-License-Identifier: MIT -from . import AUTH_ROLE + from django.conf import settings +from allauth.account import app_settings as allauth_settings +from allauth.account.models import EmailAddress + +from . import AUTH_ROLE def create_user(sender, instance, created, **kwargs): from django.contrib.auth.models import Group @@ -10,6 +14,11 @@ def create_user(sender, instance, created, **kwargs): if instance.is_superuser and instance.is_staff: db_group = Group.objects.get(name=AUTH_ROLE.ADMIN) instance.groups.add(db_group) + + # create and verify EmailAdress for superuser accounts + if allauth_settings.EMAIL_REQUIRED: + EmailAddress.objects.get_or_create(user=instance, email=instance.email, primary=True, verified=True) + for group_name in settings.DJANGO_AUTH_DEFAULT_GROUPS: db_group = Group.objects.get(name=getattr(AUTH_ROLE, group_name)) instance.groups.add(db_group) diff --git a/cvat/apps/authentication/templates/account/email/email_confirmation_signup_message.html b/cvat/apps/authentication/templates/account/email/email_confirmation_signup_message.html new file mode 100644 index 00000000..8bdc2508 --- /dev/null +++ b/cvat/apps/authentication/templates/account/email/email_confirmation_signup_message.html @@ -0,0 +1,8 @@ +{% load account %}{% user_display user as user_display %}{% load i18n %}{% autoescape off %}{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Hello from {{ site_name }}! + +

You're receiving this e-mail because user {{ user_display }} has given yours as an e-mail address to connect their account.

+ +

To confirm this is correct, go to {{ activate_url }}

+{% endblocktrans %} +{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}{{ site_domain }}{% endblocktrans %} +{% endautoescape %} diff --git a/cvat/apps/authentication/urls.py b/cvat/apps/authentication/urls.py index a3752ad5..d18ad09a 100644 --- a/cvat/apps/authentication/urls.py +++ b/cvat/apps/authentication/urls.py @@ -2,13 +2,15 @@ # # SPDX-License-Identifier: MIT -from django.urls import path +from django.urls import path, re_path from django.conf import settings from rest_auth.views import ( LoginView, LogoutView, PasswordChangeView, PasswordResetView, PasswordResetConfirmView) -from rest_auth.registration.views import RegisterView -from .views import SigningView +from allauth.account.views import ConfirmEmailView, EmailVerificationSentView +from allauth.account import app_settings as allauth_settings + +from cvat.apps.authentication.views import SigningView, RegisterView urlpatterns = [ path('login', LoginView.as_view(), name='rest_login'), @@ -26,3 +28,11 @@ if settings.DJANGO_AUTH_TYPE == 'BASIC': path('password/change', PasswordChangeView.as_view(), name='rest_password_change'), ] + if allauth_settings.EMAIL_VERIFICATION != \ + allauth_settings.EmailVerificationMethod.NONE: + urlpatterns += [ + re_path(r'^account-confirm-email/(?P[-:\w]+)/$', ConfirmEmailView.as_view(), + name='account_confirm_email'), + path('register/account-email-verification-sent', EmailVerificationSentView.as_view(), + name='account_email_verification_sent'), + ] diff --git a/cvat/apps/authentication/views.py b/cvat/apps/authentication/views.py index f38e3409..6f503199 100644 --- a/cvat/apps/authentication/views.py +++ b/cvat/apps/authentication/views.py @@ -5,6 +5,8 @@ from rest_framework import views from rest_framework.exceptions import ValidationError from rest_framework.response import Response +from rest_auth.registration.views import RegisterView as _RegisterView +from allauth.account import app_settings as allauth_settings from furl import furl from . import signature @@ -43,3 +45,12 @@ class SigningView(views.APIView): url = furl(url).add({signature.QUERY_PARAM: sign}).url return Response(url) + + +class RegisterView(_RegisterView): + def get_response_data(self, user): + data = self.get_serializer(user).data + data['email_verification_required'] = allauth_settings.EMAIL_VERIFICATION == \ + allauth_settings.EmailVerificationMethod.MANDATORY + + return data diff --git a/cvat/apps/documentation/installation.md b/cvat/apps/documentation/installation.md index dc37a025..ce2e9008 100644 --- a/cvat/apps/documentation/installation.md +++ b/cvat/apps/documentation/installation.md @@ -9,6 +9,7 @@ - [Stop all containers](#stop-all-containers) - [Advanced settings](#advanced-settings) - [Share path](#share-path) + - [Email verification](#email-verification) - [Serving over HTTPS](#serving-over-https) - [Prerequisites](#prerequisites) - [Roadmap](#roadmap) @@ -362,6 +363,26 @@ You can change the share device path to your actual share. For user convenience we have defined the environment variable $CVAT_SHARE_URL. This variable contains a text (url for example) which is shown in the client-share browser. +### Email verification + +You can enable email verification for newly registered users. +Specify these options in the [settings file](../../settings/base.py) to configure Django allauth +to enable email verification (ACCOUNT_EMAIL_VERIFICATION = 'mandatory'). +Access is denied until the user's email address is verified. +```python +ACCOUNT_AUTHENTICATION_METHOD = 'username' +ACCOUNT_CONFIRM_EMAIL_ON_GET = True +ACCOUNT_EMAIL_REQUIRED = True +ACCOUNT_EMAIL_VERIFICATION = 'mandatory' + +# Email backend settings for Django +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +``` +Also you need to configure the Django email backend to send emails. +This depends on the email server you are using and is not covered in this tutorial, please see +[Django SMTP backend configuration](https://docs.djangoproject.com/en/3.1/topics/email/#django.core.mail.backends.smtp.EmailBackend) +for details. + ### Serving over HTTPS We will add [letsencrypt.org](https://letsencrypt.org/) issued certificate to secure diff --git a/cvat/requirements/base.txt b/cvat/requirements/base.txt index f7c48a22..6a385a60 100644 --- a/cvat/requirements/base.txt +++ b/cvat/requirements/base.txt @@ -33,7 +33,7 @@ djangorestframework==3.11.1 Pygments==2.6.1 drf-yasg==1.17.1 Shapely==1.7.1 -pdf2image==1.13.1 +pdf2image==1.14.0 pascal_voc_writer==0.1.4 django-rest-auth[with_social]==0.9.5 cython==0.29.21 diff --git a/cvat/settings/base.py b/cvat/settings/base.py index b9c5b0e5..a0eeacfd 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -207,15 +207,17 @@ DJANGO_AUTH_TYPE = 'BASIC' DJANGO_AUTH_DEFAULT_GROUPS = [] LOGIN_URL = 'rest_login' LOGIN_REDIRECT_URL = '/' -AUTH_LOGIN_NOTE = '

Have not registered yet? Register here.

' AUTHENTICATION_BACKENDS = [ 'rules.permissions.ObjectPermissionBackend', - 'django.contrib.auth.backends.ModelBackend' + 'django.contrib.auth.backends.ModelBackend', + 'allauth.account.auth_backends.AuthenticationBackend', ] # https://github.com/pennersr/django-allauth ACCOUNT_EMAIL_VERIFICATION = 'none' +# set UI url to redirect after a successful e-mail confirmation +ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = '/auth/login' OLD_PASSWORD_FIELD_ENABLED = True # Django-RQ diff --git a/cvat/settings/development.py b/cvat/settings/development.py index 40e597ee..0382d41e 100644 --- a/cvat/settings/development.py +++ b/cvat/settings/development.py @@ -37,6 +37,8 @@ UI_URL = '{}://{}'.format(UI_SCHEME, UI_HOST) if UI_PORT and UI_PORT != '80': UI_URL += ':{}'.format(UI_PORT) +# set UI url to redirect to after successful e-mail confirmation +ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = '{}/auth/login'.format(UI_URL) CORS_ORIGIN_WHITELIST = [UI_URL] CORS_REPLACE_HTTPS_REFERER = True diff --git a/cvat/urls.py b/cvat/urls.py index 0d652e9b..db7e65d8 100644 --- a/cvat/urls.py +++ b/cvat/urls.py @@ -27,7 +27,6 @@ urlpatterns = [ path('admin/', admin.site.urls), path('', include('cvat.apps.engine.urls')), path('django-rq/', include('django_rq.urls')), - path('auth/', include('cvat.apps.authentication.urls')), path('documentation/', include('cvat.apps.documentation.urls')), ] diff --git a/datumaro/datumaro/plugins/voc_format/converter.py b/datumaro/datumaro/plugins/voc_format/converter.py index 0de87dfb..65e586d8 100644 --- a/datumaro/datumaro/plugins/voc_format/converter.py +++ b/datumaro/datumaro/plugins/voc_format/converter.py @@ -47,7 +47,7 @@ def _write_xml_bbox(bbox, parent_elem): return bbox_elem -LabelmapType = Enum('LabelmapType', ['voc', 'source', 'guess']) +LabelmapType = Enum('LabelmapType', ['voc', 'source']) class VocConverter(Converter): DEFAULT_IMAGE_EXT = VocPath.IMAGE_EXT @@ -102,6 +102,8 @@ class VocConverter(Converter): self._apply_colormap = apply_colormap self._allow_attributes = allow_attributes + if label_map is None: + label_map = LabelmapType.source self._load_categories(label_map) def apply(self): @@ -446,7 +448,7 @@ class VocConverter(Converter): path = osp.join(self._save_dir, VocPath.LABELMAP_FILE) write_label_map(path, self._label_map) - def _load_categories(self, label_map_source=None): + def _load_categories(self, label_map_source): if label_map_source == LabelmapType.voc.name: # use the default VOC colormap label_map = make_voc_label_map() @@ -456,10 +458,8 @@ class VocConverter(Converter): # generate colormap for input labels labels = self._extractor.categories() \ .get(AnnotationType.label, LabelCategories()) - label_map = OrderedDict() - label_map['background'] = [None, [], []] - for item in labels.items: - label_map[item.name] = [None, [], []] + label_map = OrderedDict((item.name, [None, [], []]) + for item in labels.items) elif label_map_source == LabelmapType.source.name and \ AnnotationType.mask in self._extractor.categories(): @@ -467,60 +467,45 @@ class VocConverter(Converter): labels = self._extractor.categories()[AnnotationType.label] colors = self._extractor.categories()[AnnotationType.mask] label_map = OrderedDict() - has_black = False for idx, item in enumerate(labels.items): color = colors.colormap.get(idx) - if idx is not None: - if color == (0, 0, 0): - has_black = True + if color is not None: label_map[item.name] = [color, [], []] - if not has_black and 'background' not in label_map: - label_map['background'] = [(0, 0, 0), [], []] - label_map.move_to_end('background', last=False) - - elif label_map_source in [LabelmapType.guess.name, None]: - # generate colormap for union of VOC and input dataset labels - label_map = make_voc_label_map() - - rebuild_colormap = False - source_labels = self._extractor.categories() \ - .get(AnnotationType.label, LabelCategories()) - for label in source_labels.items: - if label.name not in label_map: - rebuild_colormap = True - if label.attributes or label.name not in label_map: - label_map[label.name] = [None, [], label.attributes] - - if rebuild_colormap: - for item in label_map.values(): - item[0] = None elif isinstance(label_map_source, dict): - label_map = label_map_source + label_map = OrderedDict( + sorted(label_map_source.items(), key=lambda e: e[0])) elif isinstance(label_map_source, str) and osp.isfile(label_map_source): label_map = parse_label_map(label_map_source) - has_black = find(label_map.items(), - lambda e: e[0] == 'background' or e[1][0] == (0, 0, 0)) - if not has_black and 'background' not in label_map: - label_map['background'] = [(0, 0, 0), [], []] - label_map.move_to_end('background', last=False) - else: raise Exception("Wrong labelmap specified, " "expected one of %s or a file path" % \ ', '.join(t.name for t in LabelmapType)) + # There must always be a label with color (0, 0, 0) at index 0 + bg_label = find(label_map.items(), lambda x: x[1][0] == (0, 0, 0)) + if bg_label is not None: + bg_label = bg_label[0] + else: + bg_label = 'background' + if bg_label not in label_map: + has_colors = any(v[0] is not None for v in label_map.values()) + color = (0, 0, 0) if has_colors else None + label_map[bg_label] = [color, [], []] + label_map.move_to_end(bg_label, last=False) + self._categories = make_voc_categories(label_map) - self._label_map = label_map + # Update colors with assigned values colormap = self._categories[AnnotationType.mask].colormap for label_id, color in colormap.items(): label_desc = label_map[ self._categories[AnnotationType.label].items[label_id].name] label_desc[0] = color + self._label_map = label_map self._label_id_mapping = self._make_label_id_map() def _is_label(self, s): diff --git a/datumaro/datumaro/plugins/voc_format/format.py b/datumaro/datumaro/plugins/voc_format/format.py index 5af79f2d..471866be 100644 --- a/datumaro/datumaro/plugins/voc_format/format.py +++ b/datumaro/datumaro/plugins/voc_format/format.py @@ -137,6 +137,9 @@ def parse_label_map(path): label_desc = line.strip().split(':') name = label_desc[0] + if name in label_map: + raise ValueError("Label '%s' is already defined" % name) + if 1 < len(label_desc) and len(label_desc[1]) != 0: color = label_desc[1].split(',') assert len(color) == 3, \ @@ -173,7 +176,6 @@ def write_label_map(path, label_map): f.write('%s\n' % ':'.join([label_name, color_rgb, parts, actions])) -# pylint: disable=pointless-statement def make_voc_categories(label_map=None): if label_map is None: label_map = make_voc_label_map() @@ -190,16 +192,15 @@ def make_voc_categories(label_map=None): label_categories.add(part) categories[AnnotationType.label] = label_categories - has_colors = sum(v[0] is not None for v in label_map.values()) - if not has_colors: + has_colors = any(v[0] is not None for v in label_map.values()) + if not has_colors: # generate new colors colormap = generate_colormap(len(label_map)) - else: + else: # only copy defined colors label_id = lambda label: label_categories.find(label)[0] colormap = { label_id(name): desc[0] - for name, desc in label_map.items() } + for name, desc in label_map.items() if desc[0] is not None } mask_categories = MaskCategories(colormap) - mask_categories.inverse_colormap # force init + mask_categories.inverse_colormap # pylint: disable=pointless-statement categories[AnnotationType.mask] = mask_categories return categories -# pylint: enable=pointless-statement \ No newline at end of file diff --git a/datumaro/tests/test_voc_format.py b/datumaro/tests/test_voc_format.py index 52f9403c..e83a7430 100644 --- a/datumaro/tests/test_voc_format.py +++ b/datumaro/tests/test_voc_format.py @@ -472,53 +472,6 @@ class VocConverterTest(TestCase): partial(VocConverter.convert, label_map='voc'), test_dir, target_dataset=DstExtractor()) - def test_dataset_with_guessed_labelmap(self): - class SrcExtractor(TestExtractorBase): - def __iter__(self): - yield DatasetItem(id=1, annotations=[ - Bbox(2, 3, 4, 5, label=0, id=1), - Bbox(1, 2, 3, 4, label=1, id=2), - ]) - - def categories(self): - label_cat = LabelCategories() - label_cat.add(VOC.VocLabel(1).name) - label_cat.add('non_voc_label') - return { - AnnotationType.label: label_cat, - } - - class DstExtractor(TestExtractorBase): - def __iter__(self): - yield DatasetItem(id=1, annotations=[ - Bbox(2, 3, 4, 5, label=self._label(VOC.VocLabel(1).name), - id=1, group=1, attributes={ - 'truncated': False, - 'difficult': False, - 'occluded': False, - } - ), - Bbox(1, 2, 3, 4, label=self._label('non_voc_label'), - id=2, group=2, attributes={ - 'truncated': False, - 'difficult': False, - 'occluded': False, - } - ), - ]) - - def categories(self): - label_map = VOC.make_voc_label_map() - label_map['non_voc_label'] = [None, [], []] - for label_desc in label_map.values(): - label_desc[0] = None # rebuild colormap - return VOC.make_voc_categories(label_map) - - with TestDir() as test_dir: - self._test_save_and_load(SrcExtractor(), - partial(VocConverter.convert, label_map='guess'), - test_dir, target_dataset=DstExtractor()) - def test_dataset_with_source_labelmap_undefined(self): class SrcExtractor(TestExtractorBase): def __iter__(self): @@ -602,8 +555,8 @@ class VocConverterTest(TestCase): def categories(self): label_map = OrderedDict() - label_map['label_1'] = [(1, 2, 3), [], []] label_map['background'] = [(0, 0, 0), [], []] + label_map['label_1'] = [(1, 2, 3), [], []] label_map['label_2'] = [(3, 2, 1), [], []] return VOC.make_voc_categories(label_map) @@ -616,11 +569,11 @@ class VocConverterTest(TestCase): class SrcExtractor(TestExtractorBase): def __iter__(self): yield DatasetItem(id=1, annotations=[ - Bbox(2, 3, 4, 5, label=0, id=1), - Bbox(1, 2, 3, 4, label=1, id=2, group=2, + Bbox(2, 3, 4, 5, label=self._label('foreign_label'), id=1), + Bbox(1, 2, 3, 4, label=self._label('label'), id=2, group=2, attributes={'act1': True}), - Bbox(2, 3, 4, 5, label=2, id=3, group=2), - Bbox(2, 3, 4, 6, label=3, id=4, group=2), + Bbox(2, 3, 4, 5, label=self._label('label_part1'), group=2), + Bbox(2, 3, 4, 6, label=self._label('label_part2'), group=2), ]) def categories(self): @@ -633,14 +586,19 @@ class VocConverterTest(TestCase): AnnotationType.label: label_cat, } - label_map = { - 'label': [None, ['label_part1', 'label_part2'], ['act1', 'act2']] - } + label_map = OrderedDict([ + ('label', [None, ['label_part1', 'label_part2'], ['act1', 'act2']]) + ]) + + dst_label_map = OrderedDict([ + ('background', [None, [], []]), + ('label', [None, ['label_part1', 'label_part2'], ['act1', 'act2']]) + ]) class DstExtractor(TestExtractorBase): def __iter__(self): yield DatasetItem(id=1, annotations=[ - Bbox(1, 2, 3, 4, label=0, id=1, group=1, + Bbox(1, 2, 3, 4, label=self._label('label'), id=1, group=1, attributes={ 'act1': True, 'act2': False, @@ -649,12 +607,12 @@ class VocConverterTest(TestCase): 'occluded': False, } ), - Bbox(2, 3, 4, 5, label=1, group=1), - Bbox(2, 3, 4, 6, label=2, group=1), + Bbox(2, 3, 4, 5, label=self._label('label_part1'), group=1), + Bbox(2, 3, 4, 6, label=self._label('label_part2'), group=1), ]) def categories(self): - return VOC.make_voc_categories(label_map) + return VOC.make_voc_categories(dst_label_map) with TestDir() as test_dir: self._test_save_and_load(SrcExtractor(), diff --git a/tests/cypress/integration/issue_1841_hidden_points_cuboids_grouping.js b/tests/cypress/integration/issue_1841_hidden_points_cuboids_grouping.js new file mode 100644 index 00000000..890f36fb --- /dev/null +++ b/tests/cypress/integration/issue_1841_hidden_points_cuboids_grouping.js @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2020 Intel Corporation + * + * SPDX-License-Identifier: MIT + */ + +/// + +context('Hidden objects mustn\'t consider when we want to group visible objects only and use an grouping area for it.', () => { + + const issueId = '1841' + const labelName = `Issue ${issueId}` + const taskName = `New annotation task for ${labelName}` + const attrName = `Attr for ${labelName}` + const textDefaultValue = 'Some default value for type Text' + const image = `image_${issueId}.png` + const width = 800 + const height = 800 + const posX = 10 + const posY = 10 + const color = 'white' + let bgcolor = '' + + before(() => { + cy.visit('auth/login') + cy.login() + cy.imageGenerator('cypress/fixtures', image, width, height, color, posX, posY, labelName) + cy.createAnnotationTask(taskName, labelName, attrName, textDefaultValue, image) + cy.openTaskJob(taskName) + }) + + describe(`Testing issue "${issueId}"`, () => { + it('Change appearance to "Group"', () => { + cy.changeAppearance('Group') + }) + it('Create three points as different objects', () => { + cy.createPoint(300, 410) + cy.get('#cvat-objects-sidebar-state-item-1') + .should('contain', '1').and('contain', 'POINTS SHAPE') + cy.createPoint(350, 410) + cy.get('#cvat-objects-sidebar-state-item-2') + .should('contain', '2').and('contain', 'POINTS SHAPE') + cy.createPoint(400, 410) + cy.get('#cvat-objects-sidebar-state-item-3') + .should('contain', '3').and('contain', 'POINTS SHAPE') + .should('have.attr', 'style').then(($bgcolor) => { + bgcolor = $bgcolor // Get style attr "background-color" + }) + }) + it('Hide the last point', () => { + cy.get('#cvat-objects-sidebar-state-item-3') + .find('.anticon-eye') + .click() + cy.get('#cvat-objects-sidebar-state-item-3') + .find('.anticon-eye-invisible') + .should('exist') + }) + it('Group the created points', () => { + cy.shapeGrouping(250, 380, 430, 450) + }) + it('The hidden point is not grouping', () => { + cy.get('#cvat-objects-sidebar-state-item-3') + .should('have.attr', 'style', bgcolor) // "background-color" should not be changed + }) + }) +}) diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js index faf8e2d1..82848433 100644 --- a/tests/cypress/support/commands.js +++ b/tests/cypress/support/commands.js @@ -99,3 +99,33 @@ Cypress.Commands.add('createTrack', (firstX, firstY, lastX, lastY) => { cy.get('.cvat-canvas-container') .click(lastX, lastY) }) + +Cypress.Commands.add('createPoint', (posX, posY) => { + cy.get('.cvat-draw-points-control').click() + cy.get('.cvat-draw-shape-popover-content') + .find('button') + .contains('Shape') + .click({force: true}) + cy.get('.cvat-canvas-container') + .click(posX, posY) + .trigger('keydown', {key: 'n'}) + .trigger('keyup', {key: 'n'}) +}) + +Cypress.Commands.add('changeAppearance', (colorBy) => { + cy.get('.cvat-objects-appearance-content').within(() => { + cy.get('[type="radio"]') + .check(colorBy, {force: true}) + }) +}) + +Cypress.Commands.add('shapeGrouping', (firstX, firstY, lastX, lastY) => { + cy.get('.cvat-canvas-container') + .trigger('keydown', {key: 'g'}) + .trigger('keyup', {key: 'g'}) + .trigger('mousedown', firstX, firstY, {which: 1}) + .trigger('mousemove', lastX, lastY) + .trigger('mouseup', lastX, lastY) + .trigger('keydown', {key: 'g'}) + .trigger('keyup', {key: 'g'}) +})