From 9a17031a4c009b2d0c450319855c3a03f70afb11 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 28 Apr 2025 12:50:09 +0400 Subject: [PATCH 01/17] Keypoint support draft --- src/superannotate/lib/core/__init__.py | 2 + src/superannotate/lib/core/enums.py | 5 + .../lib/core/serviceproviders.py | 8 + .../lib/core/usecases/projects.py | 214 ++++++++++-------- .../lib/infrastructure/services/project.py | 15 ++ 5 files changed, 149 insertions(+), 95 deletions(-) diff --git a/src/superannotate/lib/core/__init__.py b/src/superannotate/lib/core/__init__.py index 8203ca390..a27bf5ad4 100644 --- a/src/superannotate/lib/core/__init__.py +++ b/src/superannotate/lib/core/__init__.py @@ -10,6 +10,7 @@ from lib.core.enums import ImageQuality from lib.core.enums import ProjectStatus from lib.core.enums import ProjectType +from lib.core.enums import StepsType from lib.core.enums import TrainingStatus from lib.core.enums import UploadState from lib.core.enums import UserRole @@ -186,6 +187,7 @@ def setup_logging(level=DEFAULT_LOGGING_LEVEL, file_path=LOG_FILE_LOCATION): FolderStatus, ProjectStatus, ProjectType, + StepsType, UserRole, UploadState, TrainingStatus, diff --git a/src/superannotate/lib/core/enums.py b/src/superannotate/lib/core/enums.py index cb2631bb6..797c49dd9 100644 --- a/src/superannotate/lib/core/enums.py +++ b/src/superannotate/lib/core/enums.py @@ -116,6 +116,11 @@ class ProjectType(BaseTitledEnum): def images(self): return self.VECTOR.value, self.PIXEL.value, self.TILED.value +class StepsType(Enum): + INITIAL = 1 + BASIC = 2 + KEYPOINT = 3 + class UserRole(BaseTitledEnum): CONTRIBUTOR = "Contributor", 4 diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 1db527fe7..c735a8b4a 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -264,10 +264,18 @@ def set_settings( def list_steps(self, project: entities.ProjectEntity): raise NotImplementedError + @abstractmethod + def list_keypoint_steps(self, project: entities.ProjectEntity): + raise NotImplementedError + @abstractmethod def set_step(self, project: entities.ProjectEntity, step: entities.StepEntity): raise NotImplementedError + @abstractmethod + def set_keypoint_steps(self, project: entities.ProjectEntity, steps): + raise NotImplementedError + @abstractmethod def set_steps(self, project: entities.ProjectEntity, steps: list): raise NotImplementedError diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 23a913ced..3d7530354 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -3,7 +3,7 @@ from collections import defaultdict from typing import List -import lib.core as constances +import lib.core as constants from lib.core.conditions import Condition from lib.core.conditions import CONDITION_EQ as EQ from lib.core.entities import ContributorEntity @@ -228,12 +228,12 @@ def __init__( def validate_settings(self): for setting in self._project.settings[:]: - if setting.attribute not in constances.PROJECT_SETTINGS_VALID_ATTRIBUTES: + if setting.attribute not in constants.PROJECT_SETTINGS_VALID_ATTRIBUTES: self._project.settings.remove(setting) if setting.attribute == "ImageQuality" and isinstance(setting.value, str): - setting.value = constances.ImageQuality(setting.value).value + setting.value = constants.ImageQuality(setting.value).value elif setting.attribute == "FrameRate": - if not self._project.type == constances.ProjectType.VIDEO.value: + if not self._project.type == constants.ProjectType.VIDEO.value: raise AppValidationException( "FrameRate is available only for Video projects" ) @@ -263,14 +263,14 @@ def validate_project_name(self): if ( len( set(self._project.name).intersection( - constances.SPECIAL_CHARACTERS_IN_PROJECT_FOLDER_NAMES + constants.SPECIAL_CHARACTERS_IN_PROJECT_FOLDER_NAMES ) ) > 0 ): self._project.name = "".join( "_" - if char in constances.SPECIAL_CHARACTERS_IN_PROJECT_FOLDER_NAMES + if char in constants.SPECIAL_CHARACTERS_IN_PROJECT_FOLDER_NAMES else char for char in self._project.name ) @@ -291,7 +291,7 @@ def validate_project_name(self): def execute(self): if self.is_valid(): # new projects can only have the status of NotStarted - self._project.status = constances.ProjectStatus.NotStarted.value + self._project.status = constants.ProjectStatus.NotStarted.value response = self._service_provider.projects.create(self._project) if not response.ok: self._response.errors = response.error @@ -326,7 +326,7 @@ def execute(self): data["classes"] = self._project.classes logger.info( f"Created project {entity.name} (ID {entity.id}) " - f"with type {constances.ProjectType(self._response.data.type).name}." + f"with type {constants.ProjectType(self._response.data.type).name}." ) return self._response @@ -368,12 +368,12 @@ def __init__( def validate_settings(self): for setting in self._project.settings[:]: - if setting.attribute not in constances.PROJECT_SETTINGS_VALID_ATTRIBUTES: + if setting.attribute not in constants.PROJECT_SETTINGS_VALID_ATTRIBUTES: self._project.settings.remove(setting) if setting.attribute == "ImageQuality" and isinstance(setting.value, str): - setting.value = constances.ImageQuality(setting.value).value + setting.value = constants.ImageQuality(setting.value).value elif setting.attribute == "FrameRate": - if not self._project.type == constances.ProjectType.VIDEO.value: + if not self._project.type == constants.ProjectType.VIDEO.value: raise AppValidationException( "FrameRate is available only for Video projects" ) @@ -404,14 +404,14 @@ def validate_project_name(self): if ( len( set(self._project.name).intersection( - constances.SPECIAL_CHARACTERS_IN_PROJECT_FOLDER_NAMES + constants.SPECIAL_CHARACTERS_IN_PROJECT_FOLDER_NAMES ) ) > 0 ): self._project.name = "".join( "_" - if char in constances.SPECIAL_CHARACTERS_IN_PROJECT_FOLDER_NAMES + if char in constants.SPECIAL_CHARACTERS_IN_PROJECT_FOLDER_NAMES else char for char in self._project.name ) @@ -484,26 +484,33 @@ def __init__(self, project: ProjectEntity, service_provider: BaseServiceProvider self._service_provider = service_provider def validate_project_type(self): - if self._project.type in constances.LIMITED_FUNCTIONS: + if self._project.type in constants.LIMITED_FUNCTIONS: raise AppValidationException( - constances.LIMITED_FUNCTIONS[self._project.type] + constants.LIMITED_FUNCTIONS[self._project.type] ) def execute(self): if self.is_valid(): - data = [] - steps = self._service_provider.projects.list_steps(self._project).data - for step in steps: - step_data = step.dict() - annotation_classes = self._service_provider.annotation_classes.list( - Condition("project_id", self._project.id, EQ) - ).data - for annotation_class in annotation_classes: - if annotation_class.id == step.class_id: - step_data["className"] = annotation_class.name - break - data.append(step_data) - self._response.data = data + project_settings = self._service_provider.projects.list_settings(project=self._project).data + step_setting = next((i for i in project_settings if i.attribute == "WorkflowType"), None) + if step_setting.value == constants.StepsType.BASIC: + data = [] + steps = self._service_provider.projects.list_steps(self._project).data + for step in steps: + step_data = step.dict() + annotation_classes = self._service_provider.annotation_classes.list( + Condition("project_id", self._project.id, EQ) + ).data + for annotation_class in annotation_classes: + if annotation_class.id == step.class_id: + step_data["className"] = annotation_class.name + break + data.append(step_data) + self._response.data = data + else: + steps = self._service_provider.projects.list_keypoint_steps(self._project).data + raise NotImplementedError + return self._response @@ -524,7 +531,7 @@ def validate_image_quality(self): if setting["attribute"].lower() == "imagequality" and isinstance( setting["value"], str ): - setting["value"] = constances.ImageQuality(setting["value"]).value + setting["value"] = constants.ImageQuality(setting["value"]).value return def validate_project_type(self): @@ -532,11 +539,11 @@ def validate_project_type(self): if attribute.get( "attribute", "" ) == "ImageQuality" and self._project.type in [ - constances.ProjectType.VIDEO.value, - constances.ProjectType.DOCUMENT.value, + constants.ProjectType.VIDEO.value, + constants.ProjectType.DOCUMENT.value, ]: raise AppValidationException( - constances.DEPRICATED_DOCUMENT_VIDEO_MESSAGE + constants.DEPRICATED_DOCUMENT_VIDEO_MESSAGE ) def execute(self): @@ -552,7 +559,7 @@ def execute(self): for new_setting in self._to_update: if ( new_setting["attribute"] - in constances.PROJECT_SETTINGS_VALID_ATTRIBUTES + in constants.PROJECT_SETTINGS_VALID_ATTRIBUTES ): new_settings_to_update.append( SettingEntity( @@ -586,73 +593,90 @@ def __init__( self._project = project def validate_project_type(self): - if self._project.type in constances.LIMITED_FUNCTIONS: + if self._project.type in constants.LIMITED_FUNCTIONS: raise AppValidationException( - constances.LIMITED_FUNCTIONS[self._project.type] + constants.LIMITED_FUNCTIONS[self._project.type] ) + def set_basic_steps(self, annotation_classes): + annotation_classes_map = {} + annotations_classes_attributes_map = {} + for annotation_class in annotation_classes: + annotation_classes_map[annotation_class.name] = annotation_class.id + for attribute_group in annotation_class.attribute_groups: + for attribute in attribute_group.attributes: + annotations_classes_attributes_map[ + f"{annotation_class.name}__{attribute_group.name}__{attribute.name}" + ] = attribute.id + + for step in [step for step in self._steps if "className" in step]: + if step.get("id"): + del step["id"] + step["class_id"] = annotation_classes_map.get(step["className"], None) + if not step["class_id"]: + raise AppException("Annotation class not found.") + self._service_provider.projects.set_steps( + project=self._project, + steps=self._steps, + ) + existing_steps = self._service_provider.projects.list_steps( + self._project + ).data + existing_steps_map = {} + for steps in existing_steps: + existing_steps_map[steps.step] = steps.id + + req_data = [] + for step in self._steps: + annotation_class_name = step["className"] + for attribute in step["attribute"]: + attribute_name = attribute["attribute"]["name"] + attribute_group_name = attribute["attribute"]["attribute_group"][ + "name" + ] + if not annotations_classes_attributes_map.get( + f"{annotation_class_name}__{attribute_group_name}__{attribute_name}", + None, + ): + raise AppException( + f"Attribute group name or attribute name not found {attribute_group_name}." + ) + + if not existing_steps_map.get(step["step"], None): + raise AppException("Couldn't find step in steps") + req_data.append( + { + "workflow_id": existing_steps_map[step["step"]], + "attribute_id": annotations_classes_attributes_map[ + f"{annotation_class_name}__{attribute_group_name}__{attribute_name}" + ], + } + ) + self._service_provider.projects.set_project_step_attributes( + project=self._project, + attributes=req_data, + ) + + def set_keypoint_steps(self, annotation_classes): + self._service_provider.projects.set_keypoint_steps( + project=self._project, + steps=self._steps, + ) def execute(self): if self.is_valid(): + annotation_classes = self._service_provider.annotation_classes.list( Condition("project_id", self._project.id, EQ) ).data - annotation_classes_map = {} - annotations_classes_attributes_map = {} - for annotation_class in annotation_classes: - annotation_classes_map[annotation_class.name] = annotation_class.id - for attribute_group in annotation_class.attribute_groups: - for attribute in attribute_group.attributes: - annotations_classes_attributes_map[ - f"{annotation_class.name}__{attribute_group.name}__{attribute.name}" - ] = attribute.id - - for step in [step for step in self._steps if "className" in step]: - if step.get("id"): - del step["id"] - step["class_id"] = annotation_classes_map.get(step["className"], None) - if not step["class_id"]: - raise AppException("Annotation class not found.") - self._service_provider.projects.set_steps( - project=self._project, - steps=self._steps, - ) - existing_steps = self._service_provider.projects.list_steps( - self._project - ).data - existing_steps_map = {} - for steps in existing_steps: - existing_steps_map[steps.step] = steps.id - - req_data = [] - for step in self._steps: - annotation_class_name = step["className"] - for attribute in step["attribute"]: - attribute_name = attribute["attribute"]["name"] - attribute_group_name = attribute["attribute"]["attribute_group"][ - "name" - ] - if not annotations_classes_attributes_map.get( - f"{annotation_class_name}__{attribute_group_name}__{attribute_name}", - None, - ): - raise AppException( - f"Attribute group name or attribute name not found {attribute_group_name}." - ) - if not existing_steps_map.get(step["step"], None): - raise AppException("Couldn't find step in steps") - req_data.append( - { - "workflow_id": existing_steps_map[step["step"]], - "attribute_id": annotations_classes_attributes_map[ - f"{annotation_class_name}__{attribute_group_name}__{attribute_name}" - ], - } - ) - self._service_provider.projects.set_project_step_attributes( - project=self._project, - attributes=req_data, - ) + project_settings = self._service_provider.projects.list_settings(project=self._project).data + step_setting = next((i for i in project_settings if i.attribute == "WorkflowType"), None) + + if step_setting.value == constants.StepsType.BASIC: + self.set_basic_steps(annotation_classes) + else: + self.set_keypoint_steps(annotation_classes) + return self._response @@ -744,11 +768,11 @@ def execute(self): team_users = set() project_users = {user.user_id for user in self._project.users} for user in self._team.users: - if user.user_role == constances.UserRole.CONTRIBUTOR.value: + if user.user_role == constants.UserRole.CONTRIBUTOR.value: team_users.add(user.email) # collecting pending team users which is not admin for user in self._team.pending_invitations: - if user["user_role"] == constances.UserRole.CONTRIBUTOR.value: + if user["user_role"] == constants.UserRole.CONTRIBUTOR.value: team_users.add(user["email"]) # collecting pending project users which is not admin for user in self._project.unverified_users: @@ -831,9 +855,9 @@ def execute(self): response = self._service_provider.invite_contributors( team_id=self._team.id, # REMINDER UserRole.VIEWER is the contributor for the teams - team_role=constances.UserRole.ADMIN.value + team_role=constants.UserRole.ADMIN.value if self._set_admin - else constances.UserRole.CONTRIBUTOR.value, + else constants.UserRole.CONTRIBUTOR.value, emails=to_add, ) invited, failed = ( diff --git a/src/superannotate/lib/infrastructure/services/project.py b/src/superannotate/lib/infrastructure/services/project.py index b84d2f67a..d08494875 100644 --- a/src/superannotate/lib/infrastructure/services/project.py +++ b/src/superannotate/lib/infrastructure/services/project.py @@ -14,6 +14,8 @@ class ProjectService(BaseProjectService): URL_GET = "project/{}" URL_SETTINGS = "project/{}/settings" URL_STEPS = "project/{}/workflow" + URL_KEYPOINT_STEPS = "api/v1/project/{}/downloadSteps" + URL_SET_KEYPOINT_STEPS = "api/v1/project/{}/uploadSteps" URL_SHARE = "api/v1/project/{}/share/bulk" URL_SHARE_PROJECT = "project/{}/share" URL_STEP_ATTRIBUTE = "project/{}/workflow_attribute" @@ -104,6 +106,12 @@ def list_steps(self, project: entities.ProjectEntity): self.URL_STEPS.format(project.id), item_type=entities.StepEntity ) + def list_keypoint_steps(self, project: entities.ProjectEntity): + return self.client.request( + self.URL_KEYPOINT_STEPS.format(project.id), + "get" + ) + def set_step(self, project: entities.ProjectEntity, step: entities.StepEntity): return self.client.request( self.URL_STEPS.format(project.id), @@ -111,6 +119,13 @@ def set_step(self, project: entities.ProjectEntity, step: entities.StepEntity): data={"steps": [step]}, ) + def set_keypoint_steps(self, project: entities.ProjectEntity, steps): + return self.client.request( + self.URL_SET_KEYPOINT_STEPS.format(project.id), + "post", + data={"steps": steps}, + ) + # TODO check def set_steps(self, project: entities.ProjectEntity, steps: list): return self.client.request( From 2615291507a3b0f1d46f824e598deae43a36d127 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 14 May 2025 11:58:40 +0400 Subject: [PATCH 02/17] Update tests delaysd --- .../annotations/test_upload_annotations.py | 3 ++- tests/integration/items/test_attach_items.py | 3 --- tests/integration/items/test_item_context.py | 11 ++++++----- tests/integration/items/test_list_items.py | 1 - .../work_management/test_user_custom_fields.py | 1 - 5 files changed, 8 insertions(+), 11 deletions(-) diff --git a/tests/integration/annotations/test_upload_annotations.py b/tests/integration/annotations/test_upload_annotations.py index 181ace079..52da43767 100644 --- a/tests/integration/annotations/test_upload_annotations.py +++ b/tests/integration/annotations/test_upload_annotations.py @@ -160,7 +160,8 @@ def setUp(self, *args, **kwargs): ], ) project = sa.controller.get_project(self.PROJECT_NAME) - time.sleep(4) + # todo check + # time.sleep(4) with open(self.EDITOR_TEMPLATE_PATH) as f: res = sa.controller.service_provider.projects.attach_editor_template( sa.controller.team, project, template=json.load(f) diff --git a/tests/integration/items/test_attach_items.py b/tests/integration/items/test_attach_items.py index 3a8332eb7..60956aee1 100644 --- a/tests/integration/items/test_attach_items.py +++ b/tests/integration/items/test_attach_items.py @@ -106,9 +106,6 @@ def test_long_names_limitation_pass(self): } ) sa.attach_items(self.PROJECT_NAME, csv_json) - import time - - time.sleep(4) items = sa.list_items(self.PROJECT_NAME, name__in=[i["name"] for i in csv_json]) assert {i["name"] for i in items} == {i["name"] for i in csv_json} diff --git a/tests/integration/items/test_item_context.py b/tests/integration/items/test_item_context.py index 3a50e2d40..304c23222 100644 --- a/tests/integration/items/test_item_context.py +++ b/tests/integration/items/test_item_context.py @@ -32,7 +32,8 @@ def setUp(self, *args, **kwargs): ) team = sa.controller.team project = sa.controller.get_project(self.PROJECT_NAME) - time.sleep(10) + # todo check + # time.sleep(10) with open(self.EDITOR_TEMPLATE_PATH) as f: res = sa.controller.service_provider.projects.attach_editor_template( team, project, template=json.load(f) @@ -53,7 +54,7 @@ def _base_test(self, path, item): assert ic.get_component_value("component_id_1") is None ic.set_component_value("component_id_1", "value") with self.assertRaisesRegexp( - FileChangedError, "The file has changed and overwrite is set to False." + FileChangedError, "The file has changed and overwrite is set to False." ): with sa.item_context(path, item, overwrite=False) as ic: assert ic.get_component_value("component_id_1") == "value" @@ -65,12 +66,12 @@ def _base_test(self, path, item): def test_overwrite_false(self): # test root by folder name self._attach_item(self.PROJECT_NAME, "dummy") - time.sleep(2) + # time.sleep(2) self._base_test(self.PROJECT_NAME, "dummy") folder = sa.create_folder(self.PROJECT_NAME, folder_name="folder") # test from folder by project and folder names - time.sleep(2) + # time.sleep(2) path = f"{self.PROJECT_NAME}/folder" self._attach_item(path, "dummy") self._base_test(path, "dummy") @@ -107,7 +108,7 @@ def setUp(self, *args, **kwargs): ) team = sa.controller.team project = sa.controller.get_project(self.PROJECT_NAME) - time.sleep(10) + # time.sleep(10) with open(self.EDITOR_TEMPLATE_PATH) as f: res = sa.controller.service_provider.projects.attach_editor_template( team, project, template=json.load(f) diff --git a/tests/integration/items/test_list_items.py b/tests/integration/items/test_list_items.py index f0a80fb79..fa0cc7af9 100644 --- a/tests/integration/items/test_list_items.py +++ b/tests/integration/items/test_list_items.py @@ -92,7 +92,6 @@ def setUp(self, *args, **kwargs): ], ) project = sa.controller.get_project(self.PROJECT_NAME) - time.sleep(10) with open(self.EDITOR_TEMPLATE_PATH) as f: res = sa.controller.service_provider.projects.attach_editor_template( sa.controller.team, project, template=json.load(f) diff --git a/tests/integration/work_management/test_user_custom_fields.py b/tests/integration/work_management/test_user_custom_fields.py index 293fb79e6..9dc256868 100644 --- a/tests/integration/work_management/test_user_custom_fields.py +++ b/tests/integration/work_management/test_user_custom_fields.py @@ -142,7 +142,6 @@ def test_list_users(self): custom_field_name="SDK_test_date_picker", value=value, ) - time.sleep(1) scapegoat = sa.list_users( include=["custom_fields"], email=scapegoat["email"], From 7d883ad370a2c09d0250bc9b4039abd5f78f8f08 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Fri, 16 May 2025 15:46:10 +0400 Subject: [PATCH 03/17] Add keypoint handling --- CHANGELOG.rst | 2 +- docs/source/api_reference/api_project.rst | 2 +- .../lib/app/interface/sdk_interface.py | 81 +++++- src/superannotate/lib/core/enums.py | 1 + .../lib/core/serviceproviders.py | 2 +- .../lib/core/usecases/projects.py | 107 ++++++-- .../lib/infrastructure/controller.py | 5 +- .../lib/infrastructure/services/project.py | 14 +- tests/integration/steps/a.json | 72 +++++ tests/integration/steps/test_steps.py | 245 ++++++++++++++++++ 10 files changed, 495 insertions(+), 36 deletions(-) create mode 100644 tests/integration/steps/a.json create mode 100644 tests/integration/steps/test_steps.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1ff2ebff8..e0a859dbb 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,7 +7,7 @@ History All release highlights of this project will be documented in this file. 4.4.34 - April 11, 2025 -______________________ +_______________________ **Added** diff --git a/docs/source/api_reference/api_project.rst b/docs/source/api_reference/api_project.rst index 52be1c3b6..858030333 100644 --- a/docs/source/api_reference/api_project.rst +++ b/docs/source/api_reference/api_project.rst @@ -24,6 +24,6 @@ Projects .. automethod:: superannotate.SAClient.add_contributors_to_project .. automethod:: superannotate.SAClient.get_project_settings .. automethod:: superannotate.SAClient.set_project_default_image_quality_in_editor -.. automethod:: superannotate.SAClient.set_project_steps .. automethod:: superannotate.SAClient.get_project_steps +.. automethod:: superannotate.SAClient.set_project_steps .. automethod:: superannotate.SAClient.get_component_config diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 7defac98c..86cd136f5 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -74,7 +74,6 @@ from lib.core.entities.work_managament import WMUserTypeEnum from lib.core.jsx_conditions import EmptyQuery - logger = logging.getLogger("sa") NotEmptyStr = constr(strict=True, min_length=1) @@ -1485,10 +1484,11 @@ def get_project_steps(self, project: Union[str, dict]): :param project: project name or metadata :type project: str or dict - :return: project steps - :rtype: list of dicts + :return: A list of step dictionaries, + or a dictionary containing both steps and their connections (for Keypoint workflows). + :rtype: list of dicts or dict - Response Example: + Response Example for General Annotation Project: :: [ @@ -1507,6 +1507,34 @@ def get_project_steps(self, project: Union[str, dict]): } ] + Response Example for Keypoint Annotation Project: + :: + + { + "steps": [ + { + "step": 1, + "className": "Left Shoulder", + "class_id": "1", + "attribute": [ + { + "attribute": { + "id": 123, + "group_id": 12 + } + } + ] + }, + { + "step": 2, + "class_id": "2", + "className": "Right Shoulder", + } + ], + "connections": [ + [1, 2] + ] + } """ project_name, _ = extract_project_folder(project) project = self.controller.get_project(project_name) @@ -2503,7 +2531,12 @@ def download_export( if response.errors: raise AppException(response.errors) - def set_project_steps(self, project: Union[NotEmptyStr, dict], steps: List[dict]): + def set_project_steps( + self, + project: Union[NotEmptyStr, dict], + steps: List[dict], + connections: List[List[int]] = None, + ): """Sets project's steps. :param project: project name or metadata @@ -2512,7 +2545,11 @@ def set_project_steps(self, project: Union[NotEmptyStr, dict], steps: List[dict] :param steps: new workflow list of dicts :type steps: list of dicts - Request Example: + :param connections: Defines connections between keypoint annotation steps. + Each inner list specifies a pair of step IDs indicating a connection. + :type connections: list of dicts + + Request Example for General Annotation Project: :: sa.set_project_steps( @@ -2533,10 +2570,40 @@ def set_project_steps(self, project: Union[NotEmptyStr, dict], steps: List[dict] } ] ) + + Request Example for Keypoint Annotation Project: + :: + + sa.set_project_steps( + project="Pose Estimation Project", + steps=[ + { + "step": 1, + "class_id": 12, + "attribute": [ + { + "attribute": { + "id": 123, + "group_id": 12 + } + } + ] + }, + { + "step": 2, + "class_id": 13 + } + ], + connections=[ + [1, 2] + ] + ) """ project_name, _ = extract_project_folder(project) project = self.controller.get_project(project_name) - response = self.controller.projects.set_steps(project, steps=steps) + response = self.controller.projects.set_steps( + project, steps=steps, connections=connections + ) if response.errors: raise AppException(response.errors) diff --git a/src/superannotate/lib/core/enums.py b/src/superannotate/lib/core/enums.py index 797c49dd9..f780d877a 100644 --- a/src/superannotate/lib/core/enums.py +++ b/src/superannotate/lib/core/enums.py @@ -116,6 +116,7 @@ class ProjectType(BaseTitledEnum): def images(self): return self.VECTOR.value, self.PIXEL.value, self.TILED.value + class StepsType(Enum): INITIAL = 1 BASIC = 2 diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index c735a8b4a..9ffba499f 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -273,7 +273,7 @@ def set_step(self, project: entities.ProjectEntity, step: entities.StepEntity): raise NotImplementedError @abstractmethod - def set_keypoint_steps(self, project: entities.ProjectEntity, steps): + def set_keypoint_steps(self, project: entities.ProjectEntity, steps, connections): raise NotImplementedError @abstractmethod diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 3d7530354..3eafee61a 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -21,7 +21,6 @@ from lib.core.usecases.base import BaseUseCase from lib.core.usecases.base import BaseUserBasedUseCase - logger = logging.getLogger("sa") @@ -491,8 +490,12 @@ def validate_project_type(self): def execute(self): if self.is_valid(): - project_settings = self._service_provider.projects.list_settings(project=self._project).data - step_setting = next((i for i in project_settings if i.attribute == "WorkflowType"), None) + project_settings = self._service_provider.projects.list_settings( + project=self._project + ).data + step_setting = next( + (i for i in project_settings if i.attribute == "WorkflowType"), None + ) if step_setting.value == constants.StepsType.BASIC: data = [] steps = self._service_provider.projects.list_steps(self._project).data @@ -508,9 +511,11 @@ def execute(self): data.append(step_data) self._response.data = data else: - steps = self._service_provider.projects.list_keypoint_steps(self._project).data - raise NotImplementedError - + self._response.data = ( + self._service_provider.projects.list_keypoint_steps( + self._project + ).data["steps"] + ) return self._response @@ -586,10 +591,12 @@ def __init__( service_provider: BaseServiceProvider, steps: list, project: ProjectEntity, + connections: List[List[int]] = None, ): super().__init__() self._service_provider = service_provider self._steps = steps + self._connections = connections self._project = project def validate_project_type(self): @@ -597,6 +604,27 @@ def validate_project_type(self): raise AppValidationException( constants.LIMITED_FUNCTIONS[self._project.type] ) + + def validate_connections(self): + if not self._connections: + return + + if len(self._connections) > len(self._steps): + raise AppValidationException( + "Invalid connections: more connections than steps." + ) + + possible_connections = set(range(1, len(self._steps) + 1)) + for connection_group in self._connections: + if len(set(connection_group)) != len(connection_group): + raise AppValidationException( + "Invalid connections: duplicates in a connection group." + ) + if not set(connection_group).issubset(possible_connections): + raise AppValidationException( + "Invalid connections: index out of allowed range." + ) + def set_basic_steps(self, annotation_classes): annotation_classes_map = {} annotations_classes_attributes_map = {} @@ -618,9 +646,7 @@ def set_basic_steps(self, annotation_classes): project=self._project, steps=self._steps, ) - existing_steps = self._service_provider.projects.list_steps( - self._project - ).data + existing_steps = self._service_provider.projects.list_steps(self._project).data existing_steps_map = {} for steps in existing_steps: existing_steps_map[steps.step] = steps.id @@ -630,12 +656,10 @@ def set_basic_steps(self, annotation_classes): annotation_class_name = step["className"] for attribute in step["attribute"]: attribute_name = attribute["attribute"]["name"] - attribute_group_name = attribute["attribute"]["attribute_group"][ - "name" - ] + attribute_group_name = attribute["attribute"]["attribute_group"]["name"] if not annotations_classes_attributes_map.get( - f"{annotation_class_name}__{attribute_group_name}__{attribute_name}", - None, + f"{annotation_class_name}__{attribute_group_name}__{attribute_name}", + None, ): raise AppException( f"Attribute group name or attribute name not found {attribute_group_name}." @@ -656,10 +680,44 @@ def set_basic_steps(self, annotation_classes): attributes=req_data, ) - def set_keypoint_steps(self, annotation_classes): + @staticmethod + def _validate_keypoint_steps(annotation_classes, steps): + class_group_attrs_map = {} + for annotation_class in annotation_classes: + class_group_attrs_map[annotation_class.id] = dict() + for group in annotation_class.attribute_groups: + class_group_attrs_map[annotation_class.id][group.id] = [] + for attribute in group.attributes: + class_group_attrs_map[annotation_class.id][group.id].append( + attribute.id + ) + for step in steps: + class_id = step.get("class_id", None) + if not class_id or class_id not in class_group_attrs_map: + raise AppException("Annotation class not found.") + attributes = step.get("attribute", None) + if not attributes: + continue + for attr in attributes: + try: + _id, group_id = attr["attribute"].get("id", None), attr[ + "attribute" + ].get("group_id", None) + assert _id in class_group_attrs_map[class_id][group_id] + except (KeyError, AssertionError): + raise AppException("Invalid steps provided.") + + def set_keypoint_steps(self, annotation_classes, steps, connections): + self._validate_keypoint_steps(annotation_classes, steps) + for i in range(1, len(self._steps) + 1): + step = self._steps[i - 1] + step["id"] = i + if "attribute" not in step: + step["attribute"] = [] self._service_provider.projects.set_keypoint_steps( project=self._project, - steps=self._steps, + steps=steps, + connections=connections, ) def execute(self): @@ -669,13 +727,24 @@ def execute(self): Condition("project_id", self._project.id, EQ) ).data - project_settings = self._service_provider.projects.list_settings(project=self._project).data - step_setting = next((i for i in project_settings if i.attribute == "WorkflowType"), None) + project_settings = self._service_provider.projects.list_settings( + project=self._project + ).data + step_setting = next( + (i for i in project_settings if i.attribute == "WorkflowType"), None + ) + if ( + self._connections is not None + and step_setting.value == constants.StepsType.BASIC.value + ): + raise AppException("Can't update steps type. ") if step_setting.value == constants.StepsType.BASIC: self.set_basic_steps(annotation_classes) else: - self.set_keypoint_steps(annotation_classes) + self.set_keypoint_steps( + annotation_classes, self._steps, self._connections + ) return self._response diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index ed8f0a238..48e6cea63 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -489,10 +489,13 @@ def list_steps(self, project: ProjectEntity): ) return use_case.execute() - def set_steps(self, project: ProjectEntity, steps: List): + def set_steps( + self, project: ProjectEntity, steps: List, connections: List[List[int]] = None + ): use_case = usecases.SetStepsUseCase( service_provider=self.service_provider, steps=steps, + connections=connections, project=project, ) return use_case.execute() diff --git a/src/superannotate/lib/infrastructure/services/project.py b/src/superannotate/lib/infrastructure/services/project.py index d08494875..112d7ebde 100644 --- a/src/superannotate/lib/infrastructure/services/project.py +++ b/src/superannotate/lib/infrastructure/services/project.py @@ -107,10 +107,7 @@ def list_steps(self, project: entities.ProjectEntity): ) def list_keypoint_steps(self, project: entities.ProjectEntity): - return self.client.request( - self.URL_KEYPOINT_STEPS.format(project.id), - "get" - ) + return self.client.request(self.URL_KEYPOINT_STEPS.format(project.id), "get") def set_step(self, project: entities.ProjectEntity, step: entities.StepEntity): return self.client.request( @@ -119,11 +116,16 @@ def set_step(self, project: entities.ProjectEntity, step: entities.StepEntity): data={"steps": [step]}, ) - def set_keypoint_steps(self, project: entities.ProjectEntity, steps): + def set_keypoint_steps(self, project: entities.ProjectEntity, steps, connections): return self.client.request( self.URL_SET_KEYPOINT_STEPS.format(project.id), "post", - data={"steps": steps}, + data={ + "steps": { + "steps": steps, + "connections": connections if connections else [], + } + }, ) # TODO check diff --git a/tests/integration/steps/a.json b/tests/integration/steps/a.json new file mode 100644 index 000000000..0fb9263c6 --- /dev/null +++ b/tests/integration/steps/a.json @@ -0,0 +1,72 @@ +[ + { + "steps": [ + { + "class_id": 5619764, + "tool": 4, + "attribute": [ + { + "id": 11181258, + "group_id": 5464852 + } + ], + "id": 1 + }, + { + "class_id": 5619763, + "tool": 4, + "attribute": [ + { + "id": 11181255, + "group_id": 5464851 + } + ], + "id": 2 + } + ], + "connections": [ + [ + 1, + 2 + ] + ] + }, + { + "steps": [ + { + "id": 1, + "attribute": [ + { + "attribute": { + "id": 11181248, + "group_id": 5464847 + } + } + ], + "className": "1", + "class_id": 5619759, + "tool": 4 + }, + { + "id": 2, + "attribute": [ + { + "attribute": { + "id": 11181249, + "group_id": 5464848 + } + } + ], + "className": "2", + "class_id": 5619760, + "tool": 4 + } + ], + "connections": [ + [ + 2, + 1 + ] + ] + } +] \ No newline at end of file diff --git a/tests/integration/steps/test_steps.py b/tests/integration/steps/test_steps.py new file mode 100644 index 000000000..27e38f401 --- /dev/null +++ b/tests/integration/steps/test_steps.py @@ -0,0 +1,245 @@ +import json +import os +import tempfile +from pathlib import Path + +from numpy.ma.core import arange +from src.superannotate import AppException +from src.superannotate import SAClient +from tests import DATA_SET_PATH +from tests.integration.base import BaseTestCase + +sa = SAClient() + + +class TestProjectSteps(BaseTestCase): + PROJECT_NAME = "TestProjectSteps" + PROJECT_TYPE = "Vector" + + def setUp(self, *args, **kwargs): + super().setUp() + sa.create_annotation_class( + self.PROJECT_NAME, + "transport", + "#FF0000", + attribute_groups=[ + { + "name": "transport_group", + "attributes": [{"name": "Car"}, {"name": "Track"}, {"name": "Bus"}], + "default_value": "Bus", + } + ], + ) + sa.create_annotation_class( + self.PROJECT_NAME, + "passenger", + "#FF1000", + attribute_groups=[ + { + "name": "passenger_group", + "attributes": [{"name": "white"}, {"name": "black"}], + } + ], + ) + self._classes = sa.search_annotation_classes(self.PROJECT_NAME) + + def test_create_steps(self): + sa.set_project_steps( + self.PROJECT_NAME, + steps=[ + { + "class_id": self._classes[0]["id"], + "attribute": [ + { + "attribute": { + "id": self._classes[0]["attribute_groups"][0][ + "attributes" + ][0]["id"], + "group_id": self._classes[0]["attribute_groups"][0][ + "id" + ], + } + } + ], + }, + { + "class_id": self._classes[1]["id"], + "attribute": [ + { + "attribute": { + "id": self._classes[1]["attribute_groups"][0][ + "attributes" + ][0]["id"], + "group_id": self._classes[1]["attribute_groups"][0][ + "id" + ], + } + } + ], + }, + ], + connections=[[1, 2]], + ) + steps = sa.get_project_steps(self.PROJECT_NAME) + assert len(steps) == 2 + + def test_missing_ids(self): + with self.assertRaisesRegexp(AppException, "Annotation class not found."): + sa.set_project_steps( + self.PROJECT_NAME, + steps=[ + { + "class_id": 1, # invalid class id + "attribute": [ + { + "attribute": { + "id": self._classes[0]["attribute_groups"][0][ + "attributes" + ][0]["id"], + "group_id": self._classes[0]["attribute_groups"][0][ + "id" + ], + } + } + ], + }, + { + "class_id": self._classes[1]["id"], + "attribute": [ + { + "attribute": { + "id": self._classes[1]["attribute_groups"][0][ + "attributes" + ][0]["id"], + "group_id": self._classes[1]["attribute_groups"][0][ + "id" + ], + } + } + ], + }, + ], + connections=[[1, 2]], + ) + + with self.assertRaisesRegexp(AppException, "Invalid steps provided."): + sa.set_project_steps( + self.PROJECT_NAME, + steps=[ + { + "class_id": self._classes[1]["id"], + "attribute": [ + { + "attribute": { + "id": self._classes[0]["attribute_groups"][0][ + "attributes" + ][0]["id"], + "group_id": 1, + } # invalid group id + } + ], + }, + { + "class_id": self._classes[1]["id"], + "attribute": [ + { + "attribute": { + "id": self._classes[1]["attribute_groups"][0][ + "attributes" + ][0]["id"], + "group_id": self._classes[1]["attribute_groups"][0][ + "id" + ], + } + } + ], + }, + ], + connections=[[1, 2]], + ) + + with self.assertRaisesRegexp(AppException, "Invalid steps provided."): + sa.set_project_steps( + self.PROJECT_NAME, + steps=[ + { + "class_id": self._classes[1]["id"], + "attribute": [ + { + "attribute": { + "id": 1, # invalid attr id + "group_id": self._classes[0]["attribute_groups"][0][ + "id" + ], + } + } + ], + }, + { + "class_id": self._classes[1]["id"], + "attribute": [ + { + "attribute": { + "id": self._classes[1]["attribute_groups"][0][ + "attributes" + ][0]["id"], + "group_id": self._classes[1]["attribute_groups"][0][ + "id" + ], + } + } + ], + }, + ], + connections=[[1, 2]], + ) + + def test_create_invalid_connection(self): + args = ( + self.PROJECT_NAME, + [ + { + "class_id": self._classes[0]["id"], + "attribute": [ + { + "attribute": { + "id": self._classes[0]["attribute_groups"][0][ + "attributes" + ][0]["id"], + "group_id": self._classes[0]["attribute_groups"][0][ + "id" + ], + } + } + ], + }, + { + "class_id": self._classes[1]["id"], + "attribute": [ + { + "attribute": { + "id": self._classes[1]["attribute_groups"][0][ + "attributes" + ][0]["id"], + "group_id": self._classes[1]["attribute_groups"][0][ + "id" + ], + } + } + ], + }, + ], + ) + with self.assertRaisesRegexp( + AppException, "Invalid connections: duplicates in a connection group." + ): + sa.set_project_steps( + *args, + connections=[ + [1, 2, 1], + ] + ) + with self.assertRaisesRegexp( + AppException, "Invalid connections: index out of allowed range." + ): + sa.set_project_steps(*args, connections=[[1, 3]]) From fdc5f440c6da4d077e34dd5bffbc5262d8f20641 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Fri, 16 May 2025 16:11:54 +0400 Subject: [PATCH 04/17] Update llm upload --- .../lib/core/usecases/annotations.py | 15 +++++++-------- .../test_pause_resume_user_activity.py | 3 --- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index c6628f152..73afab79f 100644 --- a/src/superannotate/lib/core/usecases/annotations.py +++ b/src/superannotate/lib/core/usecases/annotations.py @@ -107,10 +107,10 @@ def log_report( class ItemToUpload(BaseModel): item: BaseItemEntity - annotation_json: Optional[dict] - path: Optional[str] - file_size: Optional[int] - mask: Optional[io.BytesIO] + annotation_json: Optional[dict] = None + path: Optional[str] = None + file_size: Optional[int] = None + mask: Optional[io.BytesIO] = None class Config: arbitrary_types_allowed = True @@ -282,12 +282,12 @@ def validate_project_type(self): raise AppException("Unsupported project type.") def _validate_json(self, json_data: dict) -> list: - if self._project.type >= constants.ProjectType.PIXEL.value: + if self._project.type >= int(constants.ProjectType.PIXEL): return [] use_case = ValidateAnnotationUseCase( reporter=self.reporter, team_id=self._project.team_id, - project_type=self._project.type.value, + project_type=self._project.type, annotation=json_data, service_provider=self._service_provider, ) @@ -2103,8 +2103,7 @@ def execute(self): for item_name in uploaded_annotations: category = ( name_annotation_map[item_name]["metadata"] - .get("item_category", {}) - .get("value") + .get("item_category", None) ) if category: item_id_category_map[name_item_map[item_name].id] = category diff --git a/tests/integration/work_management/test_pause_resume_user_activity.py b/tests/integration/work_management/test_pause_resume_user_activity.py index 5cbdfeb70..50ae15919 100644 --- a/tests/integration/work_management/test_pause_resume_user_activity.py +++ b/tests/integration/work_management/test_pause_resume_user_activity.py @@ -36,9 +36,6 @@ def test_pause_and_resume_user_activity(self): scapegoat = [ u for u in users if u["role"] == "Contributor" and u["state"] == "Confirmed" ][0] - import pdb - - pdb.set_trace() sa.add_contributors_to_project(self.PROJECT_NAME, [scapegoat["email"]], "QA") with self.assertLogs("sa", level="INFO") as cm: sa.pause_user_activity(pk=scapegoat["email"], projects=[self.PROJECT_NAME]) From 71592f3b9c4ed8674f11c15329d9c9dc4c0b36fe Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Fri, 16 May 2025 16:18:44 +0400 Subject: [PATCH 05/17] Update version --- src/superannotate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 57c8bd727..3eb8aee09 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.34" +__version__ = "4.4.35dev1" os.environ.update({"sa_version": __version__}) From f03b2bac4b4b6c49967769e1f366271763449c18 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Fri, 16 May 2025 17:32:11 +0400 Subject: [PATCH 06/17] Fix connections validaiton --- src/superannotate/__init__.py | 2 +- src/superannotate/lib/core/usecases/annotations.py | 5 ++--- src/superannotate/lib/core/usecases/projects.py | 11 ++++++++--- tests/integration/steps/test_steps.py | 10 ++-------- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 79f6f6959..ae8804fe8 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.36dev1" +__version__ = "4.4.35dev2" os.environ.update({"sa_version": __version__}) diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index 73afab79f..950c510af 100644 --- a/src/superannotate/lib/core/usecases/annotations.py +++ b/src/superannotate/lib/core/usecases/annotations.py @@ -2101,9 +2101,8 @@ def execute(self): if categorization_enabled: item_id_category_map = {} for item_name in uploaded_annotations: - category = ( - name_annotation_map[item_name]["metadata"] - .get("item_category", None) + category = name_annotation_map[item_name]["metadata"].get( + "item_category", None ) if category: item_id_category_map[name_item_map[item_name].id] = category diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 3eafee61a..4455d2424 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -1,5 +1,6 @@ import decimal import logging +import math from collections import defaultdict from typing import List @@ -608,10 +609,14 @@ def validate_project_type(self): def validate_connections(self): if not self._connections: return - - if len(self._connections) > len(self._steps): + if not all([len(i) == 2 for i in self._connections]): + raise AppException("Invalid connections.") + steps_count = len(self._steps) + if len(self._connections) > max( + math.factorial(steps_count) / (2 * math.factorial(steps_count - 2)), 1 + ): raise AppValidationException( - "Invalid connections: more connections than steps." + "Invalid connections: duplicates in a connection group." ) possible_connections = set(range(1, len(self._steps) + 1)) diff --git a/tests/integration/steps/test_steps.py b/tests/integration/steps/test_steps.py index 27e38f401..6dbddf6dc 100644 --- a/tests/integration/steps/test_steps.py +++ b/tests/integration/steps/test_steps.py @@ -1,12 +1,5 @@ -import json -import os -import tempfile -from pathlib import Path - -from numpy.ma.core import arange from src.superannotate import AppException from src.superannotate import SAClient -from tests import DATA_SET_PATH from tests.integration.base import BaseTestCase sa = SAClient() @@ -236,7 +229,8 @@ def test_create_invalid_connection(self): sa.set_project_steps( *args, connections=[ - [1, 2, 1], + [1, 2], + [2, 1], ] ) with self.assertRaisesRegexp( From 1b897818bdfdb5d2f3c74c67a948c4067685c788 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Mon, 19 May 2025 15:23:14 +0400 Subject: [PATCH 07/17] fix in set_project_steps --- .../lib/core/usecases/projects.py | 18 ++++++++++-------- .../annotations/test_upload_annotations.py | 1 - tests/integration/items/test_item_context.py | 5 ++--- tests/integration/items/test_list_items.py | 1 - 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 4455d2424..2575d6044 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -738,18 +738,20 @@ def execute(self): step_setting = next( (i for i in project_settings if i.attribute == "WorkflowType"), None ) - if ( - self._connections is not None - and step_setting.value == constants.StepsType.BASIC.value - ): - raise AppException("Can't update steps type. ") - - if step_setting.value == constants.StepsType.BASIC: + if self._connections is None and step_setting.value in [ + constants.StepsType.INITIAL.value, + constants.StepsType.BASIC.value, + ]: self.set_basic_steps(annotation_classes) - else: + elif self._connections is not None and step_setting.value in [ + constants.StepsType.INITIAL.value, + constants.StepsType.KEYPOINT.value, + ]: self.set_keypoint_steps( annotation_classes, self._steps, self._connections ) + else: + raise AppException("Can't update steps type.") return self._response diff --git a/tests/integration/annotations/test_upload_annotations.py b/tests/integration/annotations/test_upload_annotations.py index 52da43767..051c3c3d9 100644 --- a/tests/integration/annotations/test_upload_annotations.py +++ b/tests/integration/annotations/test_upload_annotations.py @@ -1,7 +1,6 @@ import json import os import tempfile -import time from pathlib import Path from src.superannotate import AppException diff --git a/tests/integration/items/test_item_context.py b/tests/integration/items/test_item_context.py index 304c23222..641acf6b4 100644 --- a/tests/integration/items/test_item_context.py +++ b/tests/integration/items/test_item_context.py @@ -1,6 +1,5 @@ import json import os -import time from pathlib import Path from src.superannotate import FileChangedError @@ -54,7 +53,7 @@ def _base_test(self, path, item): assert ic.get_component_value("component_id_1") is None ic.set_component_value("component_id_1", "value") with self.assertRaisesRegexp( - FileChangedError, "The file has changed and overwrite is set to False." + FileChangedError, "The file has changed and overwrite is set to False." ): with sa.item_context(path, item, overwrite=False) as ic: assert ic.get_component_value("component_id_1") == "value" @@ -108,7 +107,7 @@ def setUp(self, *args, **kwargs): ) team = sa.controller.team project = sa.controller.get_project(self.PROJECT_NAME) - # time.sleep(10) + # time.sleep(10) with open(self.EDITOR_TEMPLATE_PATH) as f: res = sa.controller.service_provider.projects.attach_editor_template( team, project, template=json.load(f) diff --git a/tests/integration/items/test_list_items.py b/tests/integration/items/test_list_items.py index fa0cc7af9..625f1573b 100644 --- a/tests/integration/items/test_list_items.py +++ b/tests/integration/items/test_list_items.py @@ -2,7 +2,6 @@ import os import random import string -import time from pathlib import Path from src.superannotate import AppException From 9e735b1f179395ed14ba07d588bd74af44fbc262 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Mon, 19 May 2025 15:39:34 +0400 Subject: [PATCH 08/17] fix get_steps --- src/superannotate/lib/core/usecases/projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 2575d6044..6963e49d5 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -497,7 +497,7 @@ def execute(self): step_setting = next( (i for i in project_settings if i.attribute == "WorkflowType"), None ) - if step_setting.value == constants.StepsType.BASIC: + if step_setting.value == constants.StepsType.BASIC.value: data = [] steps = self._service_provider.projects.list_steps(self._project).data for step in steps: From be2c65936221c995bec4fbd1951221eb68a28b81 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Mon, 19 May 2025 15:54:32 +0400 Subject: [PATCH 09/17] fix get_steps --- src/superannotate/lib/core/usecases/projects.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 6963e49d5..3877c5e56 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -497,7 +497,13 @@ def execute(self): step_setting = next( (i for i in project_settings if i.attribute == "WorkflowType"), None ) - if step_setting.value == constants.StepsType.BASIC.value: + if step_setting.value == constants.StepsType.KEYPOINT.value: + self._response.data = ( + self._service_provider.projects.list_keypoint_steps( + self._project + ).data["steps"] + ) + else: data = [] steps = self._service_provider.projects.list_steps(self._project).data for step in steps: @@ -511,12 +517,6 @@ def execute(self): break data.append(step_data) self._response.data = data - else: - self._response.data = ( - self._service_provider.projects.list_keypoint_steps( - self._project - ).data["steps"] - ) return self._response From fe04a90059d56450eb328666c22a0d8de0998be8 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Mon, 19 May 2025 18:23:46 +0400 Subject: [PATCH 10/17] fix upload_annotations --- src/superannotate/lib/core/usecases/annotations.py | 2 +- tests/integration/annotations/test_upload_annotations.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index 950c510af..67daff6d5 100644 --- a/src/superannotate/lib/core/usecases/annotations.py +++ b/src/superannotate/lib/core/usecases/annotations.py @@ -287,7 +287,7 @@ def _validate_json(self, json_data: dict) -> list: use_case = ValidateAnnotationUseCase( reporter=self.reporter, team_id=self._project.team_id, - project_type=self._project.type, + project_type=self._project.type.value, annotation=json_data, service_provider=self._service_provider, ) diff --git a/tests/integration/annotations/test_upload_annotations.py b/tests/integration/annotations/test_upload_annotations.py index 051c3c3d9..dd442a8e0 100644 --- a/tests/integration/annotations/test_upload_annotations.py +++ b/tests/integration/annotations/test_upload_annotations.py @@ -267,6 +267,5 @@ def test_download_annotations(self): assert len(downloaded_data) == len( annotations ), "Mismatch in annotation count" - assert ( - downloaded_data == annotations - ), "Downloaded annotations do not match uploaded annotations" + for a in downloaded_data: + assert a in annotations, "Mismatch in annotation count" From 03544825be85a285c415767fa3913cf4d7267a0f Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Tue, 20 May 2025 12:48:06 +0400 Subject: [PATCH 11/17] fix _attach_categories in upload multimodal annotations --- src/superannotate/lib/core/usecases/annotations.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index 67daff6d5..854f1ebd0 100644 --- a/src/superannotate/lib/core/usecases/annotations.py +++ b/src/superannotate/lib/core/usecases/annotations.py @@ -2106,9 +2106,11 @@ def execute(self): ) if category: item_id_category_map[name_item_map[item_name].id] = category - self._attach_categories( - folder_id=folder.id, item_id_category_map=item_id_category_map - ) + if item_id_category_map: + self._attach_categories( + folder_id=folder.id, + item_id_category_map=item_id_category_map, + ) workflow = self._service_provider.work_management.get_workflow( self._project.workflow_id ) @@ -2147,7 +2149,7 @@ def _attach_categories(self, folder_id: int, item_id_category_map: Dict[int, str ) response.raise_for_status() categories = response.data - self._category_name_to_id_map = {c.name: c.id for c in categories} + self._category_name_to_id_map = {c.value: c.id for c in categories} for item_id in list(item_id_category_map.keys()): category_name = item_id_category_map[item_id] if category_name not in self._category_name_to_id_map: From 37b09fa026eee908b8466e2fc2ba47f04b514cb7 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Tue, 20 May 2025 12:56:29 +0400 Subject: [PATCH 12/17] version update --- src/superannotate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index ae8804fe8..90fc8bca6 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.35dev2" +__version__ = "4.4.36b1" os.environ.update({"sa_version": __version__}) From 0772d950a30ed306ce8e22a135af09dd270f6815 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Tue, 20 May 2025 16:03:21 +0400 Subject: [PATCH 13/17] fix in docs --- src/superannotate/lib/app/interface/sdk_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 131c48489..8bcc3d924 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -2555,7 +2555,7 @@ def set_project_steps( :param connections: Defines connections between keypoint annotation steps. Each inner list specifies a pair of step IDs indicating a connection. - :type connections: list of dicts + :type connections: list of list Request Example for General Annotation Project: :: From 4d040ecc48b1dedf82c291ebeb2453ad986c49db Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 27 May 2025 17:01:36 +0400 Subject: [PATCH 14/17] Add user permissions --- src/superannotate/lib/core/entities/work_managament.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/superannotate/lib/core/entities/work_managament.py b/src/superannotate/lib/core/entities/work_managament.py index 40aff7cb5..8e23f38b9 100644 --- a/src/superannotate/lib/core/entities/work_managament.py +++ b/src/superannotate/lib/core/entities/work_managament.py @@ -133,6 +133,7 @@ class WMProjectUserEntity(TimedBaseModel): email: Optional[str] state: Optional[WMUserStateEnum] custom_fields: Optional[dict] = Field(dict(), alias="customField") + permissions: Optional[dict] class Config: extra = Extra.ignore From 5afb4cc63a99ae6195df94a2fe4ea942fab545fc Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan <84702976+VaghinakDev@users.noreply.github.com> Date: Thu, 5 Jun 2025 10:12:35 +0400 Subject: [PATCH 15/17] Update __init__.py --- src/superannotate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 90fc8bca6..6beddbe4f 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.36b1" +__version__ = "4.4.36" os.environ.update({"sa_version": __version__}) From 40c579d0fb4055fa2d389c9a8845d657f6cf5c48 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan <84702976+VaghinakDev@users.noreply.github.com> Date: Thu, 5 Jun 2025 10:42:36 +0400 Subject: [PATCH 16/17] Update CHANGELOG.rst --- CHANGELOG.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f95946de4..a82ac5351 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,14 @@ History All release highlights of this project will be documented in this file. +4.4.35 - June 05, 2025 +______________________ + +**Updated** + + - ``SAClient.get_project_steps`` and ``SAClient.get_project_steps`` now support keypoint workflows, enabling structured step configuration with class IDs, attributes, and step connections. + - ``SAClient.list_users`` now returns user-specific permission states for paused, allow_orchestrate, allow_run_explore, and allow_view_sdk_token. + 4.4.35 - May 2, 2025 ____________________ From 40ba8737700030e6e04b7b63b9d22918f26fe02c Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan <84702976+VaghinakDev@users.noreply.github.com> Date: Thu, 5 Jun 2025 10:51:00 +0400 Subject: [PATCH 17/17] Update CHANGELOG.rst --- CHANGELOG.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a82ac5351..c1406cbdd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,7 +6,7 @@ History All release highlights of this project will be documented in this file. -4.4.35 - June 05, 2025 +4.4.36 - June 05, 2025 ______________________ **Updated**