diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 5dc58156a..a944560f9 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -158,6 +158,7 @@ from superannotate.lib.app.interface.sdk_interface import ( upload_videos_from_folder_to_project, ) +from superannotate.lib.app.interface.sdk_interface import validate_annotations from superannotate.version import __version__ @@ -166,6 +167,7 @@ "controller", # Utils "AppException", + "validate_annotations", # "init", "set_auth_token", diff --git a/src/superannotate/lib/app/helpers.py b/src/superannotate/lib/app/helpers.py index 483fa9335..32c8eec86 100644 --- a/src/superannotate/lib/app/helpers.py +++ b/src/superannotate/lib/app/helpers.py @@ -9,6 +9,7 @@ import boto3 import pandas as pd from superannotate.lib.app.exceptions import PathError +from superannotate.lib.core import ATTACHED_VIDEO_ANNOTATION_POSTFIX from superannotate.lib.core import PIXEL_ANNOTATION_POSTFIX from superannotate.lib.core import VECTOR_ANNOTATION_POSTFIX @@ -55,7 +56,13 @@ def get_local_annotation_paths( [ str(i) for i in all_not_folders - if i.name.endswith((VECTOR_ANNOTATION_POSTFIX, PIXEL_ANNOTATION_POSTFIX)) + if i.name.endswith( + ( + VECTOR_ANNOTATION_POSTFIX, + PIXEL_ANNOTATION_POSTFIX, + ATTACHED_VIDEO_ANNOTATION_POSTFIX, + ) + ) ] ) if recursive: @@ -82,8 +89,10 @@ def get_s3_annotation_paths(folder_path, s3_bucket, annotation_paths, recursive) for data in paginator.paginate(Bucket=s3_bucket, Prefix=folder_path): for annotation in data["Contents"]: key = annotation["Key"] - if key.endswith(VECTOR_ANNOTATION_POSTFIX) or key.endswith( - PIXEL_ANNOTATION_POSTFIX + if ( + key.endswith(VECTOR_ANNOTATION_POSTFIX) + or key.endswith(PIXEL_ANNOTATION_POSTFIX) + or key.endswith(ATTACHED_VIDEO_ANNOTATION_POSTFIX) ): if not recursive and "/" in key[len(folder_path) + 1 :]: continue diff --git a/src/superannotate/lib/app/interface/cli_interface.py b/src/superannotate/lib/app/interface/cli_interface.py index 2a0730e8d..ceeed0c0a 100644 --- a/src/superannotate/lib/app/interface/cli_interface.py +++ b/src/superannotate/lib/app/interface/cli_interface.py @@ -268,7 +268,7 @@ def upload_videos( self, project, folder, - target_fps=1, + target_fps=None, recursive=False, extensions=constances.DEFAULT_VIDEO_EXTENSIONS, set_annotation_status=constances.AnnotationStatus.NOT_STARTED.name, diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index cfc485f39..98713d5f6 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -30,6 +30,7 @@ from lib.app.interface.types import AnnotationType from lib.app.interface.types import ImageQualityChoices from lib.app.interface.types import NotEmptyStr +from lib.app.interface.types import ProjectTypes from lib.app.interface.types import validate_arguments from lib.app.mixp.decorators import Trackable from lib.app.serializers import BaseSerializers @@ -40,6 +41,7 @@ from lib.core import LIMITED_FUNCTIONS from lib.core.enums import ImageQuality from lib.core.exceptions import AppException +from lib.core.plugin import VideoPlugin from lib.core.types import AttributeGroup from lib.core.types import ClassesJson from lib.core.types import MLModel @@ -1631,13 +1633,13 @@ def upload_videos_from_folder_to_project( extensions: Optional[ Union[Tuple[NotEmptyStr], List[NotEmptyStr]] ] = constances.DEFAULT_VIDEO_EXTENSIONS, - exclude_file_patterns: Optional[Iterable[NotEmptyStr]] = (), + exclude_file_patterns: Optional[List[NotEmptyStr]] = [], recursive_subfolders: Optional[StrictBool] = False, target_fps: Optional[int] = None, start_time: Optional[float] = 0.0, end_time: Optional[float] = None, annotation_status: Optional[AnnotationStatuses] = "NotStarted", - image_quality_in_editor: Optional[str] = None, + image_quality_in_editor: Optional[ImageQualityChoices] = None, ): """Uploads image frames from all videos with given extensions from folder_path to the project. Sets status of all the uploaded images to set_status if it is not None. @@ -1693,19 +1695,33 @@ def upload_videos_from_folder_to_project( filtered_paths = [] video_paths = [str(path) for path in video_paths] for path in video_paths: + not_in_exclude_list = [x not in Path(path).name for x in exclude_file_patterns] if all(not_in_exclude_list): filtered_paths.append(path) project_folder_name = project_name + (f"/{folder_name}" if folder_name else "") - logger.info( - f"Uploading all videos with extensions {extensions} from {str(folder_path)} to project {project_name}. Excluded file patterns are: {[*exclude_file_patterns]}.", + f"Uploading all videos with extensions {extensions} from {str(folder_path)} to project {project_name}. Excluded file patterns are: {exclude_file_patterns}." ) uploaded_paths = [] - for path in video_paths: + for path in filtered_paths: + progress_bar = None with tempfile.TemporaryDirectory() as temp_path: - res = controller.extract_video_frames( + frame_names = VideoPlugin.get_extractable_frames( + path, start_time, end_time, target_fps + ) + duplicate_images = ( + controller.get_duplicate_images( + project_name=project_name, + folder_name=folder_name, + images=frame_names, + ) + .execute() + .data + ) + duplicate_images = [image.name for image in duplicate_images] + frames_generator = controller.extract_video_frames( project_name=project_name, folder_name=folder_name, video_path=path, @@ -1716,48 +1732,51 @@ def upload_videos_from_folder_to_project( annotation_status=annotation_status, image_quality_in_editor=image_quality_in_editor, ) - if res.errors: - raise AppException(res.errors) - use_case = controller.upload_images_from_folder_to_project( - project_name=project_name, - folder_name=folder_name, - folder_path=temp_path, - annotation_status=annotation_status, - image_quality_in_editor=image_quality_in_editor, - ) - images_to_upload, duplicates = use_case.images_to_upload + total_frames_count = len(frame_names) + logger.info(f"Video frame count is {total_frames_count}.") logger.info( - f"Extracted {len(res.data)} frames from video. Now uploading to platform.", + f"Extracted {total_frames_count} frames from video. Now uploading to platform.", ) logger.info( - f"Uploading {len(images_to_upload)} images to project {str(project_folder_name)}." + f"Uploading {total_frames_count} images to project {str(project_folder_name)}." ) - - if len(duplicates): + if len(duplicate_images): logger.warning( - f"{len(duplicates)} already existing images found that won't be uploaded." - ) - if not images_to_upload: - logger.warning( - f"{len(duplicates)} already existing images found that won't be uploaded." + f"{len(duplicate_images)} already existing images found that won't be uploaded." ) + if set(duplicate_images) == set(frame_names): continue - if use_case.is_valid(): - with tqdm( - total=len(images_to_upload), desc="Uploading images" - ) as progress_bar: + + for _ in frames_generator: + use_case = controller.upload_images_from_folder_to_project( + project_name=project_name, + folder_name=folder_name, + folder_path=temp_path, + annotation_status=annotation_status, + image_quality_in_editor=image_quality_in_editor, + ) + + images_to_upload, duplicates = use_case.images_to_upload + if not len(images_to_upload): + continue + if not progress_bar: + progress_bar = tqdm( + total=total_frames_count, desc="Uploading images" + ) + if use_case.is_valid(): for _ in use_case.execute(): progress_bar.update() - uploaded, failed_images, duplicated = use_case.response.data - uploaded_paths.extend(uploaded) - if failed_images: - logger.warning(f"Failed {len(uploaded)}.") - if duplicated: - logger.warning( - f"{len(duplicated)} already existing images found that won't be uploaded." - ) - else: - raise AppException(use_case.response.errors) + uploaded, failed_images, _ = use_case.response.data + uploaded_paths.extend(uploaded) + if failed_images: + logger.warning(f"Failed {len(failed_images)}.") + files = os.listdir(temp_path) + image_paths = [f"{temp_path}/{f}" for f in files] + for path in image_paths: + os.remove(path) + else: + raise AppException(use_case.response.errors) + return uploaded_paths @@ -1770,7 +1789,7 @@ def upload_video_to_project( start_time: Optional[float] = 0.0, end_time: Optional[float] = None, annotation_status: Optional[AnnotationStatuses] = "NotStarted", - image_quality_in_editor: Optional[NotEmptyStr] = None, + image_quality_in_editor: Optional[ImageQualityChoices] = None, ): """Uploads image frames from video to platform. Uploaded images will have names "_.jpg". @@ -1798,11 +1817,27 @@ def upload_video_to_project( """ project_name, folder_name = extract_project_folder(project) + project_folder_name = project_name + (f"/{folder_name}" if folder_name else "") + + uploaded_paths = [] + path = video_path + progress_bar = None with tempfile.TemporaryDirectory() as temp_path: - res = controller.extract_video_frames( + frame_names = VideoPlugin.get_extractable_frames( + video_path, start_time, end_time, target_fps + ) + duplicate_images = ( + controller.get_duplicate_images( + project_name=project_name, folder_name=folder_name, images=frame_names, + ) + .execute() + .data + ) + duplicate_images = [image.name for image in duplicate_images] + frames_generator = controller.extract_video_frames( project_name=project_name, folder_name=folder_name, - video_path=video_path, + video_path=path, extract_path=temp_path, target_fps=target_fps, start_time=start_time, @@ -1810,24 +1845,49 @@ def upload_video_to_project( annotation_status=annotation_status, image_quality_in_editor=image_quality_in_editor, ) - if res.errors: - raise AppException(res.errors) - use_case = controller.upload_images_from_folder_to_project( - project_name=project_name, - folder_name=folder_name, - folder_path=temp_path, - annotation_status=annotation_status, - image_quality_in_editor=image_quality_in_editor, + total_frames_count = len(frame_names) + logger.info( + f"Extracted {total_frames_count} frames from video. Now uploading to platform.", ) - images_to_upload, _ = use_case.images_to_upload - if use_case.is_valid(): - with tqdm( - total=len(images_to_upload), desc="Uploading frames." - ) as progress_bar: + logger.info( + f"Uploading {total_frames_count} images to project {str(project_folder_name)}." + ) + if len(duplicate_images): + logger.warning( + f"{len(duplicate_images)} already existing images found that won't be uploaded." + ) + if set(duplicate_images) == set(frame_names): + return [] + + for _ in frames_generator: + use_case = controller.upload_images_from_folder_to_project( + project_name=project_name, + folder_name=folder_name, + folder_path=temp_path, + annotation_status=annotation_status, + image_quality_in_editor=image_quality_in_editor, + ) + + images_to_upload, duplicates = use_case.images_to_upload + if not len(images_to_upload): + continue + if not progress_bar: + progress_bar = tqdm(total=total_frames_count, desc="Uploading images") + if use_case.is_valid(): for _ in use_case.execute(): - progress_bar.update(1) - return use_case.data[0] - raise AppException(use_case.response.errors) + progress_bar.update() + uploaded, failed_images, _ = use_case.response.data + uploaded_paths.extend(uploaded) + if failed_images: + logger.warning(f"Failed {len(failed_images)}.") + files = os.listdir(temp_path) + image_paths = [f"{temp_path}/{f}" for f in files] + for path in image_paths: + os.remove(path) + else: + raise AppException(use_case.response.errors) + + return uploaded_paths @Trackable @@ -2378,51 +2438,33 @@ def upload_annotations_from_folder_to_project( """ project_name, folder_name = extract_project_folder(project) - project = controller.get_project_metadata(project_name).data - if project["project"].project_type in [ - constances.ProjectType.VIDEO.value, - constances.ProjectType.DOCUMENT.value, - ]: - raise AppException(LIMITED_FUNCTIONS[project["project"].project_type]) if recursive_subfolders: logger.info( - "When using recursive subfolder parsing same name annotations in different subfolders will overwrite each other.", + "When using recursive subfolder parsing same name annotations in different " + "subfolders will overwrite each other.", ) - - logger.info( - "The JSON files should follow specific naming convention. For Vector projects they should be named '___objects.json', for Pixel projects JSON file should be names '___pixel.json' and also second mask image file should be present with the name '___save.png'. In both cases image with should be already present on the platform." - ) - logger.info("Existing annotations will be overwritten.",) - logger.info( - "Uploading all annotations from %s to project %s.", folder_path, project_name - ) + logger.info("The JSON files should follow a specific naming convention, matching file names already present " + "on the platform. Existing annotations will be overwritten") annotation_paths = get_annotation_paths( folder_path, from_s3_bucket, recursive_subfolders ) + if not annotation_paths: + raise AppException("Could not find annotations matching existing items on the platform.") + logger.info( "Uploading %s annotations to project %s.", len(annotation_paths), project_name ) - use_case = controller.upload_annotations_from_folder( + response = controller.upload_annotations_from_folder( project_name=project_name, folder_name=folder_name, - folder_path=folder_path, annotation_paths=annotation_paths, # noqa: E203 client_s3_bucket=from_s3_bucket, ) - if use_case.is_valid(): - with tqdm( - total=len(use_case.annotations_to_upload), desc="Uploading annotations" - ) as progress_bar: - for _ in use_case.execute(): - progress_bar.update(1) - else: - raise AppException(use_case.response.errors) - if use_case.response.report: - for i in use_case.response.report_messages: - logger.info(i) - return use_case.data + if response.errors: + raise AppException(response.errors) + return response.data @Trackable @@ -2483,20 +2525,16 @@ def upload_preannotations_from_folder_to_project( logger.info( "Uploading %s annotations to project %s.", len(annotation_paths), project_name ) - use_case = controller.upload_annotations_from_folder( + response = controller.upload_annotations_from_folder( project_name=project_name, folder_name=folder_name, - folder_path=folder_path, annotation_paths=annotation_paths, # noqa: E203 client_s3_bucket=from_s3_bucket, is_pre_annotations=True, ) - with tqdm( - total=len(annotation_paths), desc="Uploading annotations" - ) as progress_bar: - for _ in use_case.execute(): - progress_bar.update(1) - return use_case.data + if response.errors: + raise AppException(response.errors) + return response.data @Trackable @@ -2505,7 +2543,7 @@ def upload_image_annotations( project: Union[NotEmptyStr, dict], image_name: str, annotation_json: Union[str, Path, dict], - mask: Optional[Union[str, Path, dict]] = None, + mask: Optional[Union[str, Path, bytes]] = None, verbose: Optional[StrictBool] = True, ): """Upload annotations from JSON (also mask for pixel annotations) @@ -2520,15 +2558,17 @@ def upload_image_annotations( :param mask: BytesIO object or filepath to mask annotation for pixel projects in SuperAnnotate format :type mask: BytesIO or Path-like (str or Path) """ - annotation_path = f"{image_name}___save.png" - if isinstance(annotation_json, str) or isinstance(annotation_json, Path): - annotation_path = str(annotation_json).replace("___pixel.json", "___save.png") - if isinstance(annotation_json, list): - raise AppException( - "Annotation JSON should be a dict object. You are using list object." - " If this is an old annotation format you can convert it to new format with superannotate." - "update_json_format SDK function" - ) + if not mask: + if not isinstance(annotation_json, dict): + mask_path = str(annotation_json).replace("___pixel.json", "___save.png") + else: + mask_path = f"{image_name}___save.png" + if os.path.exists(mask_path): + mask = open(mask_path, "rb").read() + elif isinstance(mask, str) or isinstance(mask, Path): + if os.path.exists(mask): + mask = open(mask, "rb").read() + if not isinstance(annotation_json, dict): if verbose: logger.info("Uploading annotations from %s.", annotation_json) @@ -2541,7 +2581,6 @@ def upload_image_annotations( annotations=annotation_json, mask=mask, verbose=verbose, - annotation_path=annotation_path, ) if response.errors: raise AppException(response.errors) @@ -3568,3 +3607,18 @@ def attach_document_urls_to_project( ] return uploaded, failed_images, duplications raise AppException(use_case.response.errors) + + +@validate_arguments +def validate_annotations( + project_type: ProjectTypes, annotations_json: Union[NotEmptyStr, Path] +): + with open(annotations_json) as file: + annotation_data = json.loads(file.read()) + response = controller.validate_annotations(project_type, annotation_data) + if response.errors: + raise AppException(response.errors) + if response.data: + return True + print(response.report) + return False diff --git a/src/superannotate/lib/app/interface/types.py b/src/superannotate/lib/app/interface/types.py index 2ea6366df..992dcec40 100644 --- a/src/superannotate/lib/app/interface/types.py +++ b/src/superannotate/lib/app/interface/types.py @@ -1,8 +1,10 @@ -from collections import defaultdict from functools import wraps from typing import Union from lib.core.enums import AnnotationStatus +from lib.core.enums import ProjectType +from lib.core.exceptions import AppException +from lib.infrastructure.validators import wrap_error from pydantic import constr from pydantic import StrictStr from pydantic import validate_arguments as pydantic_validate_arguments @@ -17,7 +19,9 @@ def validate(cls, value: Union[str]) -> Union[str]: if cls.curtail_length and len(value) > cls.curtail_length: value = value[: cls.curtail_length] if value.lower() not in AnnotationStatus.values(): - raise TypeError(f"Available statuses is {', '.join(AnnotationStatus.titles())}. ") + raise TypeError( + f"Available statuses is {', '.join(AnnotationStatus.titles())}. " + ) return value @@ -46,6 +50,16 @@ def validate(cls, value: Union[str]) -> Union[str]: return value.lower() +class ProjectTypes(StrictStr): + @classmethod + def validate(cls, value: Union[str]) -> Union[str]: + if value.lower() not in ProjectType.values(): + raise TypeError( + f"Available annotation_statuses are {', '.join(ProjectType.titles())}. " + ) + return value + + class AnnotationStatuses(StrictStr): @classmethod def validate(cls, value: Union[str]) -> Union[str]: @@ -56,29 +70,12 @@ def validate(cls, value: Union[str]) -> Union[str]: return value -def to_chunks(t, size=2): - it = iter(t) - return zip(*[it] * size) - - def validate_arguments(func): @wraps(func) def wrapped(*args, **kwargs): try: return pydantic_validate_arguments(func)(*args, **kwargs) except ValidationError as e: - error_messages = defaultdict(list) - for error in e.errors(): - errors_list = list(error["loc"]) - errors_list[1::2] = [f"[{i}]" for i in errors_list[1::2]] - errors_list[2::2] = [f".{i}" for i in errors_list[2::2]] - error_messages["".join(errors_list)].append(error["msg"]) - texts = ["\n"] - for field, text in error_messages.items(): - texts.append( - "{} {}{}".format( - field, " " * (48 - len(field)), f"\n {' ' * 48}".join(text) - ) - ) - raise Exception("\n".join(texts)) from None + raise AppException(wrap_error(e)) + return wrapped diff --git a/src/superannotate/lib/core/__init__.py b/src/superannotate/lib/core/__init__.py index 064f1f05f..bd809a658 100644 --- a/src/superannotate/lib/core/__init__.py +++ b/src/superannotate/lib/core/__init__.py @@ -45,6 +45,7 @@ VECTOR_ANNOTATION_POSTFIX = "___objects.json" PIXEL_ANNOTATION_POSTFIX = "___pixel.json" ANNOTATION_MASK_POSTFIX = "___save.png" +ATTACHED_VIDEO_ANNOTATION_POSTFIX = ".json" NON_PLOTABLE_KEYS = ["eta_seconds", "iteration", "data_time", "time", "model"] diff --git a/src/superannotate/lib/core/helpers.py b/src/superannotate/lib/core/helpers.py index 53d3ba480..8c584f3bc 100644 --- a/src/superannotate/lib/core/helpers.py +++ b/src/superannotate/lib/core/helpers.py @@ -1,8 +1,11 @@ +import json from collections import defaultdict from typing import List +from lib.core.reporter import Reporter -def map_annotation_classes_name(annotation_classes, logger=None) -> dict: + +def map_annotation_classes_name(annotation_classes, reporter: Reporter) -> dict: classes_data = defaultdict(dict) for annotation_class in annotation_classes: class_info = {"id": annotation_class.uuid} @@ -10,16 +13,16 @@ def map_annotation_classes_name(annotation_classes, logger=None) -> dict: for attribute_group in annotation_class.attribute_groups: attribute_group_data = defaultdict(dict) for attribute in attribute_group["attributes"]: - if logger and attribute["name"] in attribute_group_data.keys(): - logger.warning( + if attribute["name"] in attribute_group_data.keys(): + reporter.log_warning( f"Duplicate annotation class attribute name {attribute['name']}" f" in attribute group {attribute_group['name']}. " "Only one of the annotation class attributes will be used. " "This will result in errors in annotation upload." ) attribute_group_data[attribute["name"]] = attribute["id"] - if logger and attribute_group["name"] in class_info.keys(): - logger.warning( + if attribute_group["name"] in class_info.keys(): + reporter.log_warning( f"Duplicate annotation class attribute group name {attribute_group['name']}." " Only one of the annotation class attribute groups will be used." " This will result in errors in annotation upload." @@ -30,8 +33,8 @@ def map_annotation_classes_name(annotation_classes, logger=None) -> dict: "attributes": attribute_group_data, } } - if logger and annotation_class.name in classes_data.keys(): - logger.warning( + if annotation_class.name in classes_data.keys(): + reporter.log_warning( f"Duplicate annotation class name {annotation_class.name}." f" Only one of the annotation classes will be used." " This will result in errors in annotation upload.", @@ -40,26 +43,25 @@ def map_annotation_classes_name(annotation_classes, logger=None) -> dict: return classes_data -def fill_annotation_ids(annotations: dict, annotation_classes_name_maps: dict, templates: List[dict], logger=None): +def fill_annotation_ids( + annotations: dict, + annotation_classes_name_maps: dict, + templates: List[dict], + reporter: Reporter, +): annotation_classes_name_maps = annotation_classes_name_maps if "instances" not in annotations: return - missing_classes = set() - missing_attribute_groups = set() - missing_attributes = set() unknown_classes = dict() - report = { - "missing_classes": missing_classes, - "missing_attribute_groups": missing_attribute_groups, - "missing_attributes": missing_attributes, - } + for annotation in [i for i in annotations["instances"] if "className" in i]: if "className" not in annotation: return annotation_class_name = annotation["className"] if annotation_class_name not in annotation_classes_name_maps.keys(): if annotation_class_name not in unknown_classes: - missing_classes.add(annotation_class_name) + reporter.log_warning(f"Couldn't find class {annotation_class_name}") + reporter.store_message("missing_classes", annotation_class_name) unknown_classes[annotation_class_name] = { "id": -(len(unknown_classes) + 1), "attribute_groups": {}, @@ -67,7 +69,7 @@ def fill_annotation_ids(annotations: dict, annotation_classes_name_maps: dict, t annotation_classes_name_maps.update(unknown_classes) template_name_id_map = {template["name"]: template["id"] for template in templates} for annotation in ( - i for i in annotations["instances"] if i.get("type", None) == "template" + i for i in annotations["instances"] if i.get("type", None) == "template" ): annotation["templateId"] = template_name_id_map.get( annotation.get("templateName", ""), -1 @@ -76,41 +78,152 @@ def fill_annotation_ids(annotations: dict, annotation_classes_name_maps: dict, t for annotation in [i for i in annotations["instances"] if "className" in i]: annotation_class_name = annotation["className"] if annotation_class_name not in annotation_classes_name_maps.keys(): - if logger: - logger.warning( - f"Couldn't find annotation class {annotation_class_name}" - ) + reporter.log_warning( + f"Couldn't find annotation class {annotation_class_name}" + ) continue - annotation["classId"] = annotation_classes_name_maps[annotation_class_name]["id"] + annotation["classId"] = annotation_classes_name_maps[annotation_class_name][ + "id" + ] for attribute in annotation["attributes"]: if ( - attribute["groupName"] - not in annotation_classes_name_maps[annotation_class_name]["attribute_groups"] + attribute["groupName"] + not in annotation_classes_name_maps[annotation_class_name][ + "attribute_groups" + ] ): - if logger: - logger.warning( - f"Couldn't find annotation group {attribute['groupName']}." - ) - missing_attribute_groups.add(attribute["groupName"]) + reporter.log_warning( + f"Couldn't find annotation group {attribute['groupName']}." + ) + reporter.store_message("missing_attribute_groups", f"{annotation['className']}.{attribute['groupName']}") continue attribute["groupId"] = annotation_classes_name_maps[annotation_class_name][ "attribute_groups" ][attribute["groupName"]]["id"] if ( - attribute["name"] - not in annotation_classes_name_maps[annotation_class_name][ - "attribute_groups" - ][attribute["groupName"]]["attributes"] + attribute["name"] + not in annotation_classes_name_maps[annotation_class_name][ + "attribute_groups" + ][attribute["groupName"]]["attributes"] ): del attribute["groupId"] - if logger: - logger.warning( - f"Couldn't find annotation name {attribute['name']} in" - f" annotation group {attribute['groupName']}", - ) - missing_attributes.add(attribute["name"]) + reporter.log_warning( + f"Couldn't find annotation name {attribute['name']} in" + f" annotation group {attribute['groupName']}", + ) + reporter.store_message("missing_attributes", attribute["name"]) continue attribute["id"] = annotation_classes_name_maps[annotation_class_name][ "attribute_groups" ][attribute["groupName"]]["attributes"][attribute["name"]] - return report + + +def convert_to_video_editor_json(data: dict, class_name_mapper: dict, reporter: Reporter): + def safe_time(timestamp): + return "0" if str(timestamp) == "0.0" else timestamp + + def convert_timestamp(timestamp): + return timestamp / 10 ** 6 + + editor_data = { + "instances": [], + "tags": data["tags"], + "name": data["metadata"]["name"], + "metadata": { + "duration": convert_timestamp(data["metadata"]["duration"]), + "name": data["metadata"]["name"], + "width": data["metadata"]["width"], + "height": data["metadata"]["height"], + }, + } + for instance in data["instances"]: + meta = instance["meta"] + class_name = meta["className"] + editor_instance = { + "attributes": [], + "timeline": {}, + "type": meta["type"], + "classId": class_name_mapper.get(class_name, {}).get("id", -1), + "locked": True, + } + if meta.get("pointLabels", None): + editor_instance["pointLabels"] = meta["pointLabels"] + active_attributes = set() + for parameter in instance["parameters"]: + + start_time = safe_time(convert_timestamp(parameter["start"])) + end_time = safe_time(convert_timestamp(parameter["end"])) + + for timestamp_data in parameter["timestamps"]: + timestamp = safe_time(convert_timestamp(timestamp_data["timestamp"])) + editor_instance["timeline"][timestamp] = {} + + if timestamp == start_time: + editor_instance["timeline"][timestamp]["active"] = True + + if timestamp == end_time: + editor_instance["timeline"][timestamp]["active"] = False + + if timestamp_data.get("points", None): + editor_instance["timeline"][timestamp]["points"] = timestamp_data[ + "points" + ] + + if not class_name_mapper.get(meta["className"], None): + reporter.store_message("missing_classes", meta["className"]) + continue + + existing_attributes_in_current_instance = set() + for attribute in timestamp_data["attributes"]: + group_name, attr_name = attribute.get("groupName"), attribute.get("name") + if not class_name_mapper[class_name].get("attribute_groups", {}).get(group_name): + reporter.store_message("missing_attribute_groups", f"{class_name}.{group_name}") + elif not class_name_mapper[class_name]["attribute_groups"][group_name].get("attributes", {}).get(attr_name): + reporter.store_message("missing_attributes", f"{class_name}.{group_name}.{attr_name}") + else: + existing_attributes_in_current_instance.add((group_name, attr_name)) + attributes_to_add = ( + existing_attributes_in_current_instance - active_attributes + ) + attributes_to_delete = ( + active_attributes - existing_attributes_in_current_instance + ) + if attributes_to_add or attributes_to_delete: + editor_instance["timeline"][timestamp]["attributes"] = defaultdict( + list + ) + for new_attribute in attributes_to_add: + attr = { + "id": class_name_mapper[class_name]["attribute_groups"][ + new_attribute[0] + ]["attributes"][new_attribute[1]], + "groupId": class_name_mapper[class_name]["attribute_groups"][ + new_attribute[0] + ]["id"], + } + active_attributes.add(new_attribute) + editor_instance["timeline"][timestamp]["attributes"]["+"].append( + attr + ) + for attribute_to_delete in attributes_to_delete: + attr = { + "id": class_name_mapper[class_name]["attribute_groups"][ + attribute_to_delete[0] + ]["attributes"][attribute_to_delete[1]], + "groupId": class_name_mapper[class_name]["attribute_groups"][ + attribute_to_delete[0] + ]["id"], + } + active_attributes.remove(attribute_to_delete) + editor_instance["timeline"][timestamp]["attributes"]["-"].append( + attr + ) + editor_data["instances"].append(editor_instance) + return editor_data + + +class SetEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, set): + return list(obj) + return json.JSONEncoder.default(self, obj) diff --git a/src/superannotate/lib/core/plugin.py b/src/superannotate/lib/core/plugin.py index 7baeb3a59..46284634e 100644 --- a/src/superannotate/lib/core/plugin.py +++ b/src/superannotate/lib/core/plugin.py @@ -1,9 +1,9 @@ import io import logging -import os from pathlib import Path from typing import List from typing import Tuple +from typing import Union import cv2 import ffmpeg @@ -171,7 +171,7 @@ def draw_line(self, x, y, fill_color, width=1): class VideoPlugin: @staticmethod - def get_frames_count(video_path): + def get_frames_count(video_path: str): video = cv2.VideoCapture(str(video_path), cv2.CAP_FFMPEG) count = 0 flag = True @@ -184,7 +184,12 @@ def get_frames_count(video_path): return count @staticmethod - def get_video_rotate_code(video_path): + def get_fps(video_path: str) -> Union[int, float]: + video = cv2.VideoCapture(str(video_path), cv2.CAP_FFMPEG) + return video.get(cv2.CAP_PROP_FPS) + + @staticmethod + def get_video_rotate_code(video_path, log): cv2_rotations = { 90: cv2.ROTATE_90_CLOCKWISE, 180: cv2.ROTATE_180, @@ -194,85 +199,111 @@ def get_video_rotate_code(video_path): meta_dict = ffmpeg.probe(str(video_path)) rot = int(meta_dict["streams"][0]["tags"]["rotate"]) if rot: - logger.info( - "Frame rotation of %s found. Output images will be rotated accordingly.", - rot, - ) + if log: + logger.info( + "Frame rotation of %s found. Output images will be rotated accordingly.", + rot, + ) return cv2_rotations[rot] except Exception as e: warning_str = "" if "ffprobe" in str(e): warning_str = "This could be because ffmpeg package is not installed. To install it, run: sudo apt install ffmpeg" - logger.warning( - "Couldn't read video metadata to determine rotation. %s", warning_str - ) + if log: + logger.warning( + "Couldn't read video metadata to determine rotation. %s", + warning_str, + ) return @staticmethod - def extract_frames( - video_path: str, - start_time, - end_time, - extract_path: str, - limit: int, - target_fps: float, - ) -> List[str]: - video = cv2.VideoCapture(video_path, cv2.CAP_FFMPEG) + def frames_generator( + video_path: str, start_time, end_time, target_fps: float, log=True + ): + video = cv2.VideoCapture(str(video_path), cv2.CAP_FFMPEG) if not video.isOpened(): - return [] - frames_count = VideoPlugin.get_frames_count(video_path) - logger.info("Video frame count is %s.", frames_count) - + raise ImageProcessingException( + f"Couldn't open video file {str(video_path)}." + ) fps = video.get(cv2.CAP_PROP_FPS) if not target_fps: target_fps = fps if target_fps > fps: - logger.warning( - "Video frame rate %s smaller than target frame rate %s. Cannot change frame rate.", - fps, - target_fps, - ) target_fps = fps - - else: - logger.info( - "Changing video frame rate from %s to target frame rate %s.", - fps, - target_fps, - ) - ratio = fps / target_fps - zero_fill_count = len(str(frames_count)) + rotate_code = VideoPlugin.get_video_rotate_code(video_path, log) + frame_no = 0 + frame_no_with_change = 1.0 + while True: + success, frame = video.read() + if not success: + break + frame_no += 1 + if round(frame_no_with_change) != frame_no: + continue + frame_no_with_change += ratio + frame_time = video.get(cv2.CAP_PROP_POS_MSEC) / 1000.0 + if end_time and frame_time > end_time: + break + if frame_time < start_time: + continue + if rotate_code: + frame = cv2.rotate(frame, rotate_code) + yield frame - rotate_code = VideoPlugin.get_video_rotate_code(video_path) + @staticmethod + def get_extractable_frames( + video_path: str, start_time, end_time, target_fps: float, + ): + total = VideoPlugin.get_frames_count(video_path) + total_with_fps = sum( + 1 + for _ in VideoPlugin.frames_generator( + video_path,start_time, end_time, target_fps, log=False + ) + ) + zero_fill_count = len(str(total)) + video_name = Path(video_path).stem + frame_names = [] + for i in range(1, total_with_fps + 1): + frame_name = f"{video_name}_{str(i).zfill(zero_fill_count)}.jpg" + frame_names.append(frame_name) + return frame_names - frame_number = 0 - extracted_frame_number = 0 - extracted_frame_ratio = 1.0 - logger.info("Extracting frames from video to %s.", extract_path) + @staticmethod + def extract_frames( + video_path: str, + start_time, + end_time, + extract_path: str, + limit: int, + target_fps: float, + chunk_size: int = 100, + ) -> List[str]: + total_num_of_frames = VideoPlugin.get_frames_count(video_path) + zero_fill_count = len(str(total_num_of_frames)) + video_name = Path(video_path).stem + extracted_frame_no = 1 extracted_frames_paths = [] - - while len(os.listdir(extract_path)) < limit: - success, frame = video.read() - if success: - frame_time = video.get(cv2.CAP_PROP_POS_MSEC) / 1000.0 - - if (end_time and frame_time > end_time) or ( - start_time and frame_time < start_time - ): - continue - - frame_number += 1 - if round(extracted_frame_ratio) != frame_number: - continue - extracted_frame_ratio += ratio - extracted_frame_number += 1 - if rotate_code: - frame = cv2.rotate(frame, rotate_code) - - path = f"{extract_path}/{Path(video_path).stem}_{str(extracted_frame_number).zfill(zero_fill_count)}.jpg" - extracted_frames_paths.append(path) - cv2.imwrite(path, frame) - else: + for frame in VideoPlugin.frames_generator( + video_path, start_time, end_time, target_fps + ): + if len(extracted_frames_paths) > limit: break - return extracted_frames_paths + path = str( + Path(extract_path) + / ( + video_name + + "_" + + str(extracted_frame_no).zfill(zero_fill_count) + + ".jpg" + ) + ) + extracted_frame_no += 1 + cv2.imwrite(path, frame) + extracted_frames_paths.append(path) + if len(extracted_frames_paths) % chunk_size == 0: + yield extracted_frames_paths + extracted_frames_paths.clear() + if extracted_frames_paths: + yield extracted_frames_paths diff --git a/src/superannotate/lib/core/reporter.py b/src/superannotate/lib/core/reporter.py new file mode 100644 index 000000000..fdc2c7b1f --- /dev/null +++ b/src/superannotate/lib/core/reporter.py @@ -0,0 +1,66 @@ +import logging +from collections import defaultdict +from typing import Union + +import tqdm + + +class Reporter: + def __init__( + self, + log_info: bool = True, + log_warning: bool = True, + disable_progress_bar: bool = False, + ): + self.logger = logging.getLogger("root") + self._log_info = log_info + self._log_warning = log_warning + self._disable_progress_bar = disable_progress_bar + self.info_messages = [] + self.warning_messages = [] + self.custom_messages = defaultdict(set) + self.progress_bar = None + + def log_info(self, value: str): + if self._log_info: + self.logger.info(value) + self.info_messages.append(value) + + def log_warning(self, value: str): + if self._log_warning: + self.logger.warning(value) + self.warning_messages.append(value) + + def start_progress( + self, iterations: Union[int, range], description: str = "Processing" + ): + if isinstance(iterations, range): + self.progress_bar = tqdm.tqdm( + iterations, desc=description, disable=self._disable_progress_bar + ) + else: + self.progress_bar = tqdm.tqdm( + total=iterations, desc=description, disable=self._disable_progress_bar + ) + + def finish_progress(self): + self.progress_bar.close() + + def update_progress(self, value: int = 1): + self.progress_bar.update(value) + + def generate_report(self) -> str: + report = "" + if self.info_messages: + report += "\n".join(self.info_messages) + if self.warning_messages: + report += "\n".join(self.warning_messages) + return report + + def store_message(self, key: str, value: str): + self.custom_messages[key].add(value) + + @property + def messages(self): + for key, values in self.custom_messages.items(): + yield f"{key} [{', '.join(values)}]" diff --git a/src/superannotate/lib/core/types.py b/src/superannotate/lib/core/types.py index ba6664230..c506a50a0 100644 --- a/src/superannotate/lib/core/types.py +++ b/src/superannotate/lib/core/types.py @@ -1,3 +1,4 @@ +from typing import Dict from typing import List from typing import Optional from typing import Union @@ -6,11 +7,24 @@ from pydantic import constr from pydantic import Extra from pydantic import StrictStr - +from pydantic import validate_model +from pydantic import validator +from pydantic.error_wrappers import ErrorWrapper +from pydantic.error_wrappers import ValidationError NotEmptyStr = constr(strict=True, min_length=1) +class AnnotationType(StrictStr): + @classmethod + def validate(cls, value: str) -> Union[str]: + if value not in ANNOTATION_TYPES.keys(): + raise ValidationError( + [ErrorWrapper(TypeError(f"invalid value {value}"), "type")], cls + ) + return value + + class Attribute(BaseModel): name: NotEmptyStr @@ -33,11 +47,21 @@ class Metadata(BaseModel): height: Optional[int] +class PointLabels(BaseModel): + __root__: Dict[constr(regex=r'^[0-9]*$'), str] + + class BaseInstance(BaseModel): - type: NotEmptyStr - classId: int + type: AnnotationType + classId: Optional[int] groupId: Optional[int] attributes: List[Attribute] + # point_labels: Optional[PointLabels] + + class Config: + error_msg_templates = { + "value_error.missing": "field required for annotation", + } class Point(BaseInstance): @@ -72,20 +96,20 @@ class Ellipse(BaseInstance): class TemplatePoint(BaseModel): - id: int + id: Optional[int] x: float y: float class TemplateConnection(BaseModel): - id: int + id: Optional[int] to: int class Template(BaseInstance): points: List[TemplatePoint] connections: List[Optional[TemplateConnection]] - templateId: int + templateId: Optional[int] class EmptyPoint(BaseModel): @@ -109,15 +133,46 @@ class PixelAnnotationPart(BaseModel): class PixelAnnotationInstance(BaseModel): - classId: int + classId: Optional[int] groupId: Optional[int] parts: List[PixelAnnotationPart] attributes: List[Attribute] +class VectorInstance(BaseModel): + __root__: Union[Template, Cuboid, Point, PolyLine, Polygon, Bbox, Ellipse] + + +ANNOTATION_TYPES = { + "bbox": Bbox, + "ellipse": Ellipse, + "template": Template, + "cuboid": Cuboid, + "polyline": PolyLine, + "polygon": Polygon, + "point": Point, +} + + class VectorAnnotation(BaseModel): metadata: Metadata - instances: List[Union[Template, Cuboid, Point, PolyLine, Polygon, Bbox, Ellipse]] + instances: Optional[ + List[Union[Template, Cuboid, Point, PolyLine, Polygon, Bbox, Ellipse]] + ] + + @validator("instances", pre=True, each_item=True) + def check_instances(cls, instance): + annotation_type = AnnotationType.validate(instance.get("type")) + if not annotation_type: + raise ValidationError( + [ErrorWrapper(TypeError("value not specified"), "type")], cls + ) + result = validate_model(ANNOTATION_TYPES[annotation_type], instance) + if result[2]: + raise ValidationError( + result[2].raw_errors, model=ANNOTATION_TYPES[annotation_type] + ) + return instance class PixelAnnotation(BaseModel): @@ -134,10 +189,48 @@ class Config: class MLModel(BaseModel): name: NotEmptyStr - id: int + id: Optional[int] path: NotEmptyStr config_path: NotEmptyStr team_id: Optional[int] class Config: extra = Extra.allow + + +class VideoMetaData(BaseModel): + name: Optional[str] + width: Optional[int] + height: Optional[int] + + +class VideoInstanceMeta(BaseModel): + type: NotEmptyStr + classId: Optional[int] + + +class VideoTimeStamp(BaseModel): + timestamp: int + attributes: List[Attribute] + + +class VideoInstanceParameter(BaseModel): + start: int + end: int + timestamps: List[VideoTimeStamp] + + +class VideoInstance(BaseModel): + meta: VideoInstanceMeta + parameters: List[VideoInstanceParameter] + + +class VideoAnnotation(BaseModel): + metadata: VideoMetaData + instances: List[VideoInstance] + tags: List[str] + + +class DocumentAnnotation(BaseModel): + instances: list + tags: List[str] diff --git a/src/superannotate/lib/core/usecases/__init__.py b/src/superannotate/lib/core/usecases/__init__.py index 7d80eb389..1fd77c63a 100644 --- a/src/superannotate/lib/core/usecases/__init__.py +++ b/src/superannotate/lib/core/usecases/__init__.py @@ -1,3 +1,4 @@ +from lib.core.usecases.annotations import * from lib.core.usecases.folders import * from lib.core.usecases.images import * from lib.core.usecases.models import * diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py new file mode 100644 index 000000000..2baf9efeb --- /dev/null +++ b/src/superannotate/lib/core/usecases/annotations.py @@ -0,0 +1,424 @@ +import concurrent.futures +import io +import json +import logging +import os +from collections import namedtuple +from typing import List + +import boto3 +import lib.core as constances +from lib.core.entities import AnnotationClassEntity +from lib.core.entities import FolderEntity +from lib.core.entities import ImageEntity +from lib.core.entities import ProjectEntity +from lib.core.exceptions import AppException +from lib.core.helpers import convert_to_video_editor_json +from lib.core.helpers import fill_annotation_ids +from lib.core.helpers import map_annotation_classes_name +from lib.core.reporter import Reporter +from lib.core.service_types import UploadAnnotationAuthData +from lib.core.serviceproviders import SuerannotateServiceProvider +from lib.core.usecases.base import BaseReportableUseCae +from lib.core.usecases.images import GetBulkImages +from lib.core.usecases.images import ValidateAnnotationUseCase +from lib.infrastructure.validators import BaseAnnotationValidator + +logger = logging.getLogger("root") + + +class UploadAnnotationsUseCase(BaseReportableUseCae): + MAX_WORKERS = 10 + CHUNK_SIZE = 100 + AUTH_DATA_CHUNK_SIZE = 500 + ImageInfo = namedtuple("ImageInfo", ["path", "name", "id"]) + + def __init__( + self, + reporter: Reporter, + project: ProjectEntity, + folder: FolderEntity, + annotation_classes: List[AnnotationClassEntity], + annotation_paths: List[str], + backend_service_provider: SuerannotateServiceProvider, + templates: List[dict], + validators: BaseAnnotationValidator, + pre_annotation: bool = False, + client_s3_bucket=None, + ): + super().__init__(reporter) + self._project = project + self._folder = folder + self._backend_service = backend_service_provider + self._annotation_classes = annotation_classes + self._annotation_paths = annotation_paths + self._client_s3_bucket = client_s3_bucket + self._pre_annotation = pre_annotation + self._templates = templates + self._annotations_to_upload = None + self._missing_annotations = None + self._validators = validators + self.missing_attribute_groups = set() + self.missing_classes = set() + self.missing_attributes = set() + + @property + def annotation_postfix(self): + if self._project.project_type in ( + constances.ProjectType.VIDEO.value, + constances.ProjectType.DOCUMENT.value, + ): + return constances.ATTACHED_VIDEO_ANNOTATION_POSTFIX + elif self._project.project_type == constances.ProjectType.VECTOR.value: + return constances.VECTOR_ANNOTATION_POSTFIX + elif self._project.project_type == constances.ProjectType.PIXEL.value: + return constances.PIXEL_ANNOTATION_POSTFIX + + @staticmethod + def extract_name(value: str): + return os.path.basename( + value.replace(constances.PIXEL_ANNOTATION_POSTFIX, "") + .replace(constances.VECTOR_ANNOTATION_POSTFIX, "") + .replace(constances.ATTACHED_VIDEO_ANNOTATION_POSTFIX, ""), + ) + + @property + def annotations_to_upload(self): + if not self._annotations_to_upload: + annotation_paths = self._annotation_paths + images_detail = [] + for annotation_path in annotation_paths: + images_detail.append( + self.ImageInfo( + id=None, + path=annotation_path, + name=self.extract_name(annotation_path), + ) + ) + images_data = ( + GetBulkImages( + service=self._backend_service, + project_id=self._project.uuid, + team_id=self._project.team_id, + folder_id=self._folder.uuid, + images=[image.name for image in images_detail], + ) + .execute() + .data + ) + for image_data in images_data: + for idx, detail in enumerate(images_detail): + if detail.name == image_data.name: + images_detail[idx] = detail._replace(id=image_data.uuid) + + missing_annotations = list( + filter(lambda image_detail: image_detail.id is None, images_detail) + ) + annotations_to_upload = list( + filter(lambda image_detail: image_detail.id is not None, images_detail) + ) + if missing_annotations: + for missing in missing_annotations: + logger.warning( + f"Couldn't find image {missing.path} for annotation upload." + ) + if not annotations_to_upload: + raise AppException("No image to attach annotations.") + self._missing_annotations = missing_annotations + self._annotations_to_upload = annotations_to_upload + return self._annotations_to_upload + + def get_annotation_upload_data( + self, image_ids: List[int] + ) -> UploadAnnotationAuthData: + if self._pre_annotation: + function = self._backend_service.get_pre_annotation_upload_data + else: + function = self._backend_service.get_annotation_upload_data + response = function( + project_id=self._project.uuid, + team_id=self._project.team_id, + folder_id=self._folder.uuid, + image_ids=image_ids, + ) + if response.ok: + return response.data + + def _upload_annotation( + self, + image_id: int, + image_name: str, + upload_data: UploadAnnotationAuthData, + path: str, + bucket, + ): + try: + response = UploadAnnotationUseCase( + project=self._project, + folder=self._folder, + image=ImageEntity(uuid=image_id, name=image_name), + annotation_classes=self._annotation_classes, + backend_service_provider=self._backend_service, + reporter=self.reporter, + templates=self._templates, + annotation_upload_data=upload_data, + client_s3_bucket=self._client_s3_bucket, + annotation_path=path, + verbose=False, + s3_bucket=bucket, + validators=self._validators, + ).execute() + if response.errors: + self.reporter.store_message("Invalid jsons", path) + return path, False + return path, True + except Exception as e: + # raise e + return path, False + + def get_bucket_to_upload(self, ids: List[int]): + upload_data = self.get_annotation_upload_data(ids) + if upload_data: + session = boto3.Session( + aws_access_key_id=upload_data.access_key, + aws_secret_access_key=upload_data.secret_key, + aws_session_token=upload_data.session_token, + region_name=upload_data.region, + ) + resource = session.resource("s3") + return resource.Bucket(upload_data.bucket) + + def _log_report(self): + for key, values in self.reporter.custom_messages.items(): + template = key + ": {}" + if key == "missing_classes": + template = "Could not find annotation classes matching existing classes on the platform: [{}]" + elif key == "missing_attribute_groups": + template = "Could not find attribute groups matching existing attribute groups on the platform: [{}]" + elif key == "missing_attributes": + template = "Could not find attributes matching existing attributes on the platform: [{}]" + logger.warning( + template.format("', '".join(values)) + ) + + def execute(self): + uploaded_annotations = [] + failed_annotations = [] + if self.annotations_to_upload: + iterations_range = range( + 0, len(self.annotations_to_upload), self.AUTH_DATA_CHUNK_SIZE + ) + self.reporter.start_progress(iterations_range, description="Uploading Annotations") + for _ in iterations_range: + annotations_to_upload = self.annotations_to_upload[ + _ : _ + self.AUTH_DATA_CHUNK_SIZE # noqa: E203 + ] + upload_data = self.get_annotation_upload_data( + [int(image.id) for image in annotations_to_upload] + ) + bucket = self.get_bucket_to_upload([int(image.id) for image in annotations_to_upload]) + if bucket: + image_id_name_map = { + image.id: image for image in self.annotations_to_upload + } + # dummy progress + for _ in range(len(annotations_to_upload) - len(upload_data.images)): + self.reporter.update_progress() + with concurrent.futures.ThreadPoolExecutor( + max_workers=self.MAX_WORKERS + ) as executor: + results = [ + executor.submit( + self._upload_annotation, + image_id, + image_id_name_map[image_id].name, + upload_data, + image_id_name_map[image_id].path, + bucket, + ) + for image_id, image_data in upload_data.images.items() + ] + for future in concurrent.futures.as_completed(results): + annotation, uploaded = future.result() + if uploaded: + uploaded_annotations.append(annotation) + else: + failed_annotations.append(annotation) + self.reporter.update_progress() + + self._response.data = ( + uploaded_annotations, + failed_annotations, + [annotation.path for annotation in self._missing_annotations], + ) + self._log_report() + else: + self._response.errors = "Could not find annotations matching existing items on the platform." + return self._response + + +class UploadAnnotationUseCase(BaseReportableUseCae): + def __init__( + self, + project: ProjectEntity, + folder: FolderEntity, + image: ImageEntity, + annotation_classes: List[AnnotationClassEntity], + backend_service_provider: SuerannotateServiceProvider, + reporter: Reporter, + templates: List[dict], + validators: BaseAnnotationValidator, + annotation_upload_data: UploadAnnotationAuthData = None, + annotations: dict = None, + s3_bucket=None, + client_s3_bucket=None, + mask=None, + verbose: bool = True, + annotation_path: str = None, + pass_validation: bool = False, + ): + super().__init__(reporter) + self._project = project + self._folder = folder + self._image = image + self._backend_service = backend_service_provider + self._annotation_classes = annotation_classes + self._annotation_json = annotations + self._mask = mask + self._verbose = verbose + self._templates = templates + self._annotation_path = annotation_path + self._annotation_upload_data = annotation_upload_data + self._s3_bucket = s3_bucket + self._client_s3_bucket = client_s3_bucket + self._pass_validation = pass_validation + self._validators = validators + + @property + def annotation_upload_data(self) -> UploadAnnotationAuthData: + if not self._annotation_upload_data: + response = self._backend_service.get_annotation_upload_data( + project_id=self._project.uuid, + team_id=self._project.team_id, + folder_id=self._folder.uuid, + image_ids=[self._image.uuid], + ) + if response.ok: + self._annotation_upload_data = response.data + return self._annotation_upload_data + + @property + def s3_bucket(self): + if not self._s3_bucket: + upload_data = self.annotation_upload_data + if upload_data: + session = boto3.Session( + aws_access_key_id=upload_data.access_key, + aws_secret_access_key=upload_data.secret_key, + aws_session_token=upload_data.session_token, + region_name=upload_data.region, + ) + resource = session.resource("s3") + self._s3_bucket = resource.Bucket(upload_data.bucket) + return self._s3_bucket + + def get_s3_annotation(self, s3, path: str): + file = io.BytesIO() + s3_object = s3.Object(self._client_s3_bucket, path) + s3_object.download_fileobj(file) + file.seek(0) + return json.load(file) + + @property + def from_s3(self): + if self._client_s3_bucket: + from_session = boto3.Session() + return from_session.resource("s3") + + def get_annotation_json(self): + if not self._annotation_json: + if self._client_s3_bucket: + return self.get_s3_annotation(self.from_s3, self._annotation_path) + return json.load(open(self._annotation_path)) + return self._annotation_json + + def _is_valid_json(self, json_data: dict): + use_case = ValidateAnnotationUseCase( + project_type=constances.ProjectType.get_name(self._project.project_type), + annotation=json_data, + validators=self._validators, + ) + return not use_case.execute().errors + + @staticmethod + def prepare_annotations( + project_type: int, + annotations: dict, + annotation_classes: List[AnnotationClassEntity], + templates: List[dict], + reporter: Reporter, + ) -> dict: + if project_type in ( + constances.ProjectType.VECTOR.value, + constances.ProjectType.PIXEL.value, + constances.ProjectType.DOCUMENT.value, + ): + fill_annotation_ids( + annotations=annotations, + annotation_classes_name_maps=map_annotation_classes_name( + annotation_classes, reporter + ), + templates=templates, + reporter=reporter, + ) + elif project_type == constances.ProjectType.VIDEO.value: + annotations = convert_to_video_editor_json( + annotations, map_annotation_classes_name(annotation_classes, reporter), reporter + ) + return annotations + + def is_valid_json( + self, json_data: dict, + ): + use_case = ValidateAnnotationUseCase( + constances.ProjectType.get_name(self._project.project_type), + annotation=json_data, + validators=self._validators, + ) + return use_case.execute().data + + def execute(self): + if self.is_valid(): + annotation_json = self.get_annotation_json() + if self.is_valid_json(annotation_json): + bucket = self.s3_bucket + annotation_json = self.prepare_annotations( + project_type=self._project.project_type, + annotations=annotation_json, + annotation_classes=self._annotation_classes, + templates=self._templates, + reporter=self.reporter, + ) + bucket.put_object( + Key=self.annotation_upload_data.images[self._image.uuid][ + "annotation_json_path" + ], + Body=json.dumps(annotation_json), + ) + if self._project.project_type == constances.ProjectType.PIXEL.value: + if self._mask: + bucket.put_object( + Key=self.annotation_upload_data.images[self._image.uuid][ + "annotation_bluemap_path" + ], + Body=self._mask, + ) + if self._verbose: + logger.info( + "Uploading annotations for image %s in project %s.", + str(self._image.name), + self._project.name, + ) + else: + self._response.errors = "Invalid json" + return self._response diff --git a/src/superannotate/lib/core/usecases/base.py b/src/superannotate/lib/core/usecases/base.py index 70f422517..bbd878983 100644 --- a/src/superannotate/lib/core/usecases/base.py +++ b/src/superannotate/lib/core/usecases/base.py @@ -1,13 +1,16 @@ from abc import ABC from abc import abstractmethod +from typing import Iterable from lib.core.exceptions import AppValidationException +from lib.core.reporter import Reporter from lib.core.response import Response class BaseUseCase(ABC): def __init__(self): self._response = Response() + self._pass_validation = False @abstractmethod def execute(self) -> Response: @@ -23,11 +26,23 @@ def _validate(self): self._response.errors = e def is_valid(self): + if self._pass_validation: + return True self._validate() return not self._response.errors class BaseInteractiveUseCase(BaseUseCase): + def __init__(self): + super().__init__() + self._validated = False + + def is_valid(self): + if not self._validated: + self._validate() + self._validated = True + return not self._response.errors + @property def response(self): return self._response @@ -37,5 +52,11 @@ def data(self): return self.response.data @abstractmethod - def execute(self): + def execute(self) -> Iterable: raise NotImplementedError + + +class BaseReportableUseCae(BaseUseCase): + def __init__(self, reporter: Reporter): + super().__init__() + self.reporter = reporter diff --git a/src/superannotate/lib/core/usecases/images.py b/src/superannotate/lib/core/usecases/images.py index c993de804..cf89b51fa 100644 --- a/src/superannotate/lib/core/usecases/images.py +++ b/src/superannotate/lib/core/usecases/images.py @@ -33,21 +33,17 @@ from lib.core.exceptions import AppException from lib.core.exceptions import AppValidationException from lib.core.exceptions import ImageProcessingException -from lib.core.helpers import fill_annotation_ids -from lib.core.helpers import map_annotation_classes_name from lib.core.plugin import ImagePlugin from lib.core.plugin import VideoPlugin from lib.core.repositories import BaseManageableRepository from lib.core.repositories import BaseReadOnlyRepository from lib.core.response import Response from lib.core.serviceproviders import SuerannotateServiceProvider -from lib.core.types import PixelAnnotation -from lib.core.types import VectorAnnotation from lib.core.usecases.base import BaseInteractiveUseCase from lib.core.usecases.base import BaseUseCase from lib.core.usecases.projects import GetAnnotationClassesUseCase +from lib.core.validators import BaseAnnotationValidator from PIL import UnidentifiedImageError -from pydantic import ValidationError logger = logging.getLogger("root") @@ -1600,7 +1596,6 @@ def __init__( self._settings = settings self._auth_data = None - @property def auth_data(self): if not self._auth_data: @@ -1746,7 +1741,7 @@ def execute(self): image.entity for image in images_to_upload[i : i + 100] ], annotation_status=self._annotation_status, - upload_state_code=constances.UploadState.BASIC.value + upload_state_code=constances.UploadState.BASIC.value, ).execute() if response.errors: continue @@ -2190,99 +2185,6 @@ def execute(self) -> Response: return self._response -class UploadImageAnnotationsUseCase(BaseUseCase): - def __init__( - self, - project: ProjectEntity, - folder: FolderEntity, - annotation_classes: BaseReadOnlyRepository, - image_name: str, - annotations: dict, - backend_service_provider: SuerannotateServiceProvider, - mask=None, - verbose: bool = True, - annotation_path: str = True, - ): - super().__init__() - self._project = project - self._folder = folder - self._backend_service = backend_service_provider - self._annotation_classes = annotation_classes - self._image_name = image_name - self._annotations = annotations - self._mask = mask - self._verbose = verbose - self._annotation_path = annotation_path - - def validate_project_type(self): - if self._project.project_type in constances.LIMITED_FUNCTIONS: - raise AppValidationException( - constances.LIMITED_FUNCTIONS[self._project.project_type] - ) - - def execute(self): - if self.is_valid(): - image_data = self._backend_service.get_bulk_images( - images=[self._image_name], - folder_id=self._folder.uuid, - team_id=self._project.team_id, - project_id=self._project.uuid, - ) - if not image_data: - raise AppException("There is no images to attach annotation.") - image_data = image_data[0] - response = self._backend_service.get_annotation_upload_data( - project_id=self._project.uuid, - team_id=self._project.team_id, - folder_id=self._folder.uuid, - image_ids=[image_data["id"]], - ) - if response.ok: - session = boto3.Session( - aws_access_key_id=response.data.access_key, - aws_secret_access_key=response.data.secret_key, - aws_session_token=response.data.session_token, - region_name=response.data.region, - ) - resource = session.resource("s3") - bucket = resource.Bucket(response.data.bucket) - fill_annotation_ids( - annotations=self._annotations, - annotation_classes_name_maps=map_annotation_classes_name(self._annotation_classes.get_all()), - templates=self._backend_service.get_templates(self._project.team_id).get("data", []), - logger=logger - ) - bucket.put_object( - Key=response.data.images[image_data["id"]]["annotation_json_path"], - Body=json.dumps(self._annotations), - ) - if self._project.project_type == constances.ProjectType.PIXEL.value: - mask_path = None - png_path = self._annotation_path.replace( - "___pixel.json", "___save.png" - ) - if os.path.exists(png_path) and not self._mask: - mask_path = png_path - elif self._mask: - mask_path = self._mask - - if mask_path: - with open(mask_path, "rb") as descriptor: - bucket.put_object( - Key=response.data.images[image_data["id"]][ - "annotation_bluemap_path" - ], - Body=descriptor.read(), - ) - if self._verbose: - logger.info( - "Uploading annotations for image %s in project %s.", - str(image_data["name"]), - self._project.name, - ) - return self._response - - class DeleteImagesUseCase(BaseUseCase): CHUNK_SIZE = 1000 @@ -2339,251 +2241,6 @@ def execute(self): return self._response -class UploadAnnotationsUseCase(BaseInteractiveUseCase): - MAX_WORKERS = 10 - CHUNK_SIZE = 100 - AUTH_DATA_CHUNK_SIZE = 500 - - def __init__( - self, - project: ProjectEntity, - folder: FolderEntity, - annotation_classes: List[AnnotationClassEntity], - folder_path: str, - annotation_paths: List[str], - backend_service_provider: SuerannotateServiceProvider, - templates: List[dict], - pre_annotation: bool = False, - client_s3_bucket=None, - ): - super().__init__() - self._project = project - self._folder = folder - self._backend_service = backend_service_provider - self._annotation_classes = annotation_classes - self._folder_path = folder_path - self._annotation_paths = annotation_paths - self._client_s3_bucket = client_s3_bucket - self._pre_annotation = pre_annotation - self._templates = templates - self._annotations_to_upload = None - self._missing_annotations = None - self.missing_attribute_groups = set() - self.missing_classes = set() - self.missing_attributes = set() - - @property - def s3_client(self): - return boto3.client("s3") - - @property - def annotation_postfix(self): - return ( - constances.VECTOR_ANNOTATION_POSTFIX - if self._project.project_type == constances.ProjectType.VECTOR.value - else constances.PIXEL_ANNOTATION_POSTFIX - ) - - @property - def annotations_to_upload(self): - if not self._annotations_to_upload: - annotation_paths = self._annotation_paths - ImageInfo = namedtuple("ImageInfo", ["path", "name", "id"]) - images_detail = [] - for annotation_path in annotation_paths: - images_detail.append( - ImageInfo( - id=None, - path=annotation_path, - name=os.path.basename( - annotation_path.replace( - constances.PIXEL_ANNOTATION_POSTFIX, "" - ).replace(constances.VECTOR_ANNOTATION_POSTFIX, ""), - ), - ) - ) - images_data = ( - GetBulkImages( - service=self._backend_service, - project_id=self._project.uuid, - team_id=self._project.team_id, - folder_id=self._folder.uuid, - images=[image.name for image in images_detail], - ) - .execute() - .data - ) - - for image_data in images_data: - for idx, detail in enumerate(images_detail): - if detail.name == image_data.name: - images_detail[idx] = detail._replace(id=image_data.uuid) - - missing_annotations = list( - filter(lambda detail: detail.id is None, images_detail) - ) - annotations_to_upload = list( - filter(lambda detail: detail.id is not None, images_detail) - ) - if missing_annotations: - for missing in missing_annotations: - logger.warning( - f"Couldn't find image {missing.path} for annotation upload." - ) - if not annotations_to_upload: - raise AppException("No image to attach annotations.") - self._missing_annotations = missing_annotations - self._annotations_to_upload = annotations_to_upload - return self._annotations_to_upload - - def _is_valid_json(self, json_data: dict): - try: - if self._project.project_type == constances.ProjectType.PIXEL.value: - PixelAnnotation(**json_data) - else: - VectorAnnotation(**json_data) - return True - except ValidationError as _: - return False - - def execute(self): - uploaded_annotations = [] - missing_annotations = [] - failed_annotations = [] - for _ in range(0, len(self.annotations_to_upload), self.AUTH_DATA_CHUNK_SIZE): - annotations_to_upload = self.annotations_to_upload[ - _ : _ + self.AUTH_DATA_CHUNK_SIZE # noqa: E203 - ] - - if self._pre_annotation: - response = self._backend_service.get_pre_annotation_upload_data( - project_id=self._project.uuid, - team_id=self._project.team_id, - folder_id=self._folder.uuid, - image_ids=[int(image.id) for image in annotations_to_upload], - ) - else: - response = self._backend_service.get_annotation_upload_data( - project_id=self._project.uuid, - team_id=self._project.team_id, - folder_id=self._folder.uuid, - image_ids=[int(image.id) for image in annotations_to_upload], - ) - if response.ok: - session = boto3.Session( - aws_access_key_id=response.data.access_key, - aws_secret_access_key=response.data.secret_key, - aws_session_token=response.data.session_token, - region_name=response.data.region, - ) - resource = session.resource("s3") - bucket = resource.Bucket(response.data.bucket) - image_id_name_map = { - image.id: image for image in self.annotations_to_upload - } - if self._client_s3_bucket: - from_session = boto3.Session() - from_s3 = from_session.resource("s3") - else: - from_s3 = None - - for _ in range(len(annotations_to_upload) - len(response.data.images)): - yield - with concurrent.futures.ThreadPoolExecutor( - max_workers=self.MAX_WORKERS - ) as executor: - results = [ - executor.submit( - self.upload_to_s3, - image_id, - image_info, - bucket, - from_s3, - image_id_name_map, - ) - for image_id, image_info in response.data.images.items() - ] - for future in concurrent.futures.as_completed(results): - annotation, uploaded = future.result() - if uploaded: - uploaded_annotations.append(annotation) - else: - failed_annotations.append(annotation) - yield - - uploaded_annotations = [annotation.path for annotation in uploaded_annotations] - missing_annotations.extend( - [annotation.path for annotation in self._missing_annotations] - ) - failed_annotations = [annotation.path for annotation in failed_annotations] - self._response.data = ( - uploaded_annotations, - failed_annotations, - missing_annotations, - ) - self.report_missing_data() - return self._response - - def upload_to_s3( - self, image_id: int, image_info, bucket, from_s3, image_id_name_map - ): - try: - if from_s3: - file = io.BytesIO() - s3_object = from_s3.Object( - self._client_s3_bucket, image_id_name_map[image_id].path - ) - s3_object.download_fileobj(file) - file.seek(0) - annotation_json = json.load(file) - else: - annotation_json = json.load(open(image_id_name_map[image_id].path)) - report = fill_annotation_ids( - annotations=annotation_json, - annotation_classes_name_maps=map_annotation_classes_name(self._annotation_classes), - templates=self._templates - ) - self.missing_classes.update(report["missing_classes"]) - self.missing_attribute_groups.update(report["missing_attribute_groups"]) - self.missing_attributes.update(report["missing_attributes"]) - if not self._is_valid_json(annotation_json): - logger.warning(f"Invalid json {image_id_name_map[image_id].path}") - return image_id_name_map[image_id], False - bucket.put_object( - Key=image_info["annotation_json_path"], - Body=json.dumps(annotation_json), - ) - if self._project.project_type == constances.ProjectType.PIXEL.value: - mask_path = image_id_name_map[image_id].path.replace( - "___pixel.json", constances.ANNOTATION_MASK_POSTFIX - ) - if from_s3: - file = io.BytesIO() - s3_object = from_s3.Object(self._client_s3_bucket, mask_path) - s3_object.download_fileobj(file) - file.seek(0) - else: - with open(mask_path, "rb") as mask_file: - file = io.BytesIO(mask_file.read()) - bucket.put_object(Key=image_info["annotation_bluemap_path"], Body=file) - return image_id_name_map[image_id], True - except Exception as e: - self._response.report = f"Couldn't upload annotation {image_id_name_map[image_id].name} - {str(e)}" - return image_id_name_map[image_id], False - - def report_missing_data(self): - if self.missing_classes: - logger.warning(f"Couldn't find classes [{', '.join(self.missing_classes)}]") - if self.missing_attribute_groups: - logger.warning( - f"Couldn't find annotation groups [{', '.join(self.missing_attribute_groups)}]" - ) - if self.missing_attributes: - logger.warning( - f"Couldn't find attributes [{', '.join(self.missing_attributes)}]" - ) - - class DownloadImageAnnotationsUseCase(BaseUseCase): def __init__( self, @@ -3305,7 +2962,7 @@ def execute(self): self._to_s3_bucket.upload_file(str(self._path), self._s3_key) -class ExtractFramesUseCase(BaseUseCase): +class ExtractFramesUseCase(BaseInteractiveUseCase): def __init__( self, backend_service_provider: SuerannotateServiceProvider, @@ -3334,6 +2991,20 @@ def __init__( self._limit = limit self._limitation_response = None + def validate_fps(self): + fps = VideoPlugin.get_fps(self._video_path) + if not self._target_fps: + self._target_fps = fps + return + if self._target_fps and self._target_fps > fps: + logger.info( + f"Video frame rate {fps} smaller than target frame rate {self._target_fps}. Cannot change frame rate." + ) + else: + logger.info( + f"Changing video frame rate from {fps} to target frame rate {self._target_fps}." + ) + def validate_upload_state(self): if self._project.upload_state == constances.UploadState.EXTERNAL.value: raise AppValidationException(constances.UPLOADING_UPLOAD_STATE_ERROR) @@ -3367,12 +3038,12 @@ def validate_limitations(self): @property def limit(self): limits = [ - self._limitation_response.data.folder_limit.remaining_image_count, - self._limitation_response.data.project_limit.remaining_image_count, + self.limitation_response.data.folder_limit.remaining_image_count, + self.limitation_response.data.project_limit.remaining_image_count, ] - if self._limitation_response.data.user_limit: + if self.limitation_response.data.user_limit: limits.append( - self._limitation_response.data.user_limit.remaining_image_count + self.limitation_response.data.user_limit.remaining_image_count ) return min(limits) @@ -3384,7 +3055,7 @@ def validate_project_type(self): def execute(self): if self.is_valid(): - extracted_paths = VideoPlugin.extract_frames( + frames_generator = VideoPlugin.extract_frames( video_path=self._video_path, start_time=self._start_time, end_time=self._end_time, @@ -3392,8 +3063,7 @@ def execute(self): limit=self.limit, target_fps=self._target_fps, ) - self._response.data = extracted_paths - return self._response + yield from frames_generator class UploadS3ImagesBackendUseCase(BaseUseCase): @@ -3480,3 +3150,36 @@ def execute(self): data=[old_setting.to_dict()], ) return self._response + + +class ValidateAnnotationUseCase(BaseUseCase): + def __init__( + self, project_type: str, annotation: dict, validators: BaseAnnotationValidator + ): + super().__init__() + self._project_type = project_type + self._annotation = annotation + self._validators = validators + + def execute(self) -> Response: + validator = None + if self._project_type.lower() == constances.ProjectType.VECTOR.name.lower(): + validator = self._validators.get_vector_validator() + elif self._project_type.lower() == constances.ProjectType.PIXEL.name.lower(): + validator = self._validators.get_pixel_validator() + elif self._project_type.lower() == constances.ProjectType.VIDEO.name.lower(): + validator = self._validators.get_video_validator() + elif self._project_type.lower() == constances.ProjectType.DOCUMENT.name.lower(): + validator = self._validators.get_document_validator() + if validator: + validator = validator(self._annotation) + if validator.is_valid(): + self._response.data = True + else: + self._response.report = validator.generate_report() + self._response.data = False + else: + self._response.errors = AppException( + f"There is not validator for type {self._project_type}." + ) + return self._response diff --git a/src/superannotate/lib/core/validators.py b/src/superannotate/lib/core/validators.py index e69de29bb..367aa7fe1 100644 --- a/src/superannotate/lib/core/validators.py +++ b/src/superannotate/lib/core/validators.py @@ -0,0 +1,45 @@ +from abc import ABCMeta +from abc import abstractmethod +from typing import Any +from typing import Type + + +class BaseValidator(metaclass=ABCMeta): + def __init__(self, data: Any): + self._data = data + self._validation_output = None + + @classmethod + @abstractmethod + def validate(cls, data: Any): + raise NotImplementedError + + @abstractmethod + def is_valid(self) -> bool: + raise NotImplementedError + + @abstractmethod + def generate_report(self) -> str: + raise NotImplementedError + + +class BaseAnnotationValidator(metaclass=ABCMeta): + @staticmethod + @abstractmethod + def get_pixel_validator() -> Type[BaseValidator]: + raise NotImplementedError + + @staticmethod + @abstractmethod + def get_vector_validator() -> Type[BaseValidator]: + raise NotImplementedError + + @staticmethod + @abstractmethod + def get_video_validator() -> Type[BaseValidator]: + raise NotImplementedError + + @staticmethod + @abstractmethod + def get_document_validator() -> Type[BaseValidator]: + raise NotImplementedError diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index 9fe010287..3c5db49e6 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -19,6 +19,7 @@ from lib.core.entities import MLModelEntity from lib.core.entities import ProjectEntity from lib.core.exceptions import AppException +from lib.core.reporter import Reporter from lib.core.response import Response from lib.infrastructure.helpers import timed_lru_cache from lib.infrastructure.repositories import AnnotationClassRepository @@ -32,6 +33,7 @@ from lib.infrastructure.repositories import TeamRepository from lib.infrastructure.repositories import WorkflowRepository from lib.infrastructure.services import SuperannotateBackendService +from lib.infrastructure.validators import AnnotationValidator class SingleInstanceMetaClass(type): @@ -74,7 +76,9 @@ def init(self, config_path=None): config_path = Path(expanduser(config_path)) if str(config_path) == constances.CONFIG_FILE_LOCATION: if not Path(self._config_path).is_file(): - self.configs.insert(ConfigEntity("main_endpoint", constances.BACKEND_URL)) + self.configs.insert( + ConfigEntity("main_endpoint", constances.BACKEND_URL) + ) self.configs.insert(ConfigEntity("token", "")) return if not config_path.is_file(): @@ -93,7 +97,9 @@ def init(self, config_path=None): if not main_endpoint: main_endpoint = constances.BACKEND_URL if not token: - raise AppException(f"Incorrect config file: token is not present in the config file {config_path}") + raise AppException( + f"Incorrect config file: token is not present in the config file {config_path}" + ) verify_ssl_entity = self.configs.get_one("ssl_verify") if not verify_ssl_entity: verify_ssl = True @@ -168,9 +174,7 @@ def folders(self): return self._folders def get_team(self): - return usecases.GetTeamUseCase( - teams=self.teams, team_id=self.team_id - ).execute() + return usecases.GetTeamUseCase(teams=self.teams, team_id=self.team_id).execute() @property def ml_models(self): @@ -197,7 +201,9 @@ def configs(self): @property def team_id(self) -> int: if not self._team_id: - raise AppException(f"Invalid credentials provided in the {self._config_path}.") + raise AppException( + f"Invalid credentials provided in the {self._config_path}." + ) return self._team_id @timed_lru_cache(seconds=3600) @@ -222,6 +228,10 @@ def get_s3_repository(self, team_id: int, project_id: int, folder_id: int): def s3_repo(self): return S3Repository + @property + def annotation_validators(self): + return AnnotationValidator() + class Controller(BaseController): def __init__(self, config_path=constances.CONFIG_FILE_LOCATION): @@ -621,14 +631,16 @@ def search_images( def _get_image( self, project: ProjectEntity, image_name: str, folder: FolderEntity = None, ) -> ImageEntity: - use_case = usecases.GetImageUseCase( + response = usecases.GetImageUseCase( service=self._backend_client, project=project, folder=folder, image_name=image_name, images=self.images, - ) - return use_case.execute().data + ).execute() + if response.errors: + raise AppException(response.errors) + return response.data def get_image( self, project_name: str, image_name: str, folder_path: str = None @@ -1153,7 +1165,21 @@ def extract_video_frames( image_quality_in_editor=image_quality_in_editor, limit=limit, ) - return use_case.execute() + if use_case.is_valid(): + yield from use_case.execute() + else: + raise AppException(use_case.response.errors) + + def get_duplicate_images(self, project_name: str, folder_name: str, images: list): + project = self._get_project(project_name) + folder = self._get_folder(project, folder_name) + return usecases.GetBulkImages( + service=self._backend_client, + project_id=project.uuid, + team_id=project.team_id, + folder_id=folder.uuid, + images=images, + ) def create_annotation_class( self, project_name: str, name: str, color: str, attribute_groups: List[dict] @@ -1287,7 +1313,6 @@ def upload_annotations_from_folder( self, project_name: str, folder_name: str, - folder_path: str, annotation_paths: List[str], client_s3_bucket=None, is_pre_annotations: bool = False, @@ -1297,7 +1322,6 @@ def upload_annotations_from_folder( use_case = usecases.UploadAnnotationsUseCase( project=project, folder=folder, - folder_path=folder_path, annotation_paths=annotation_paths, backend_service_provider=self._backend_client, annotation_classes=AnnotationClassRepository( @@ -1308,8 +1332,10 @@ def upload_annotations_from_folder( templates=self._backend_client.get_templates(team_id=self.team_id).get( "data", [] ), + validators=self.annotation_validators, + reporter=Reporter(log_info=False, log_warning=False), ) - return use_case + return use_case.execute() def upload_image_annotations( self, @@ -1319,22 +1345,29 @@ def upload_image_annotations( annotations: dict, mask: io.BytesIO = None, verbose: bool = True, - annotation_path: str = None, ): project = self._get_project(project_name) folder = self._get_folder(project, folder_name) - use_case = usecases.UploadImageAnnotationsUseCase( + try: + image = self._get_image(project, image_name, folder) + except AppException: + raise AppException("There is no images to attach annotation.") + use_case = usecases.UploadAnnotationUseCase( project=project, folder=folder, annotation_classes=AnnotationClassRepository( service=self._backend_client, project=project - ), - image_name=image_name, + ).get_all(), + image=image, annotations=annotations, + templates=self._backend_client.get_templates(team_id=self.team_id).get( + "data", [] + ), backend_service_provider=self._backend_client, mask=mask, verbose=verbose, - annotation_path=annotation_path, + reporter=Reporter(), + validators=self.annotation_validators, ) return use_case.execute() @@ -1597,3 +1630,9 @@ def delete_annotations( image_names=image_names, ) return use_case.execute() + + def validate_annotations(self, project_type: str, annotation: dict): + use_case = usecases.ValidateAnnotationUseCase( + project_type, annotation, validators=self.annotation_validators + ) + return use_case.execute() diff --git a/src/superannotate/lib/infrastructure/validators.py b/src/superannotate/lib/infrastructure/validators.py new file mode 100644 index 000000000..3ae0e9ed7 --- /dev/null +++ b/src/superannotate/lib/infrastructure/validators.py @@ -0,0 +1,89 @@ +import os +from collections import defaultdict + +from lib.core.types import DocumentAnnotation +from lib.core.types import PixelAnnotation +from lib.core.types import VectorAnnotation +from lib.core.types import VideoAnnotation +from lib.core.validators import BaseAnnotationValidator +from lib.core.validators import BaseValidator +from pydantic import ValidationError + + +def get_tabulation() -> int: + try: + return int(os.get_terminal_size().columns / 2) + except OSError: + return 48 + + +def wrap_error(e: ValidationError) -> str: + tabulation = get_tabulation() + error_messages = defaultdict(list) + for error in e.errors(): + errors_list = list(error["loc"]) + errors_list[1::2] = [f"[{i}]" for i in errors_list[1::2]] + errors_list[2::2] = [f".{i}" for i in errors_list[2::2]] + error_messages["".join(errors_list)].append(error["msg"]) + texts = ["\n"] + for field, text in error_messages.items(): + texts.append( + "{} {}{}".format( + field, + " " * (tabulation - len(field)), + f"\n {' ' * tabulation}".join(text), + ) + ) + return "\n".join(texts) + + +class BaseSchemaValidator(BaseValidator): + MODEL = PixelAnnotation + + @classmethod + def validate(cls, data: dict): + cls.MODEL(**data) + + def is_valid(self) -> bool: + try: + self.validate(self._data) + except ValidationError as e: + self._validation_output = e + return not bool(self._validation_output) + + def generate_report(self) -> str: + return wrap_error(self._validation_output) + + +class PixelValidator(BaseSchemaValidator): + MODEL = PixelAnnotation + + +class VectorValidator(BaseSchemaValidator): + MODEL = VectorAnnotation + + +class VideoValidator(BaseSchemaValidator): + MODEL = VideoAnnotation + + +class DocumentValidator(BaseSchemaValidator): + MODEL = DocumentAnnotation + + +class AnnotationValidator(BaseAnnotationValidator): + @classmethod + def get_pixel_validator(cls): + return PixelValidator + + @classmethod + def get_vector_validator(cls): + return VectorValidator + + @classmethod + def get_video_validator(cls): + return VideoValidator + + @classmethod + def get_document_validator(cls): + return DocumentValidator diff --git a/tests/__init__.py b/tests/__init__.py index e69de29bb..a2edcf2fb 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,6 @@ +import sys +from pathlib import Path + + +LIB_PATH = Path(__file__).parent.parent / "src" +sys.path.insert(0, str(LIB_PATH)) diff --git a/tests/data_set/attach_video_for_annotation.csv b/tests/data_set/attach_video_for_annotation.csv new file mode 100644 index 000000000..c1a22d5f1 --- /dev/null +++ b/tests/data_set/attach_video_for_annotation.csv @@ -0,0 +1,2 @@ +url,name +https://file-examples-com.github.io/uploads/2017/04/file_example_MP4_480_1_5MG.mp4,video.mp4 diff --git a/tests/data_set/csv_files/text_urls_template.csv b/tests/data_set/csv_files/text_urls_template.csv new file mode 100644 index 000000000..8f24f2486 --- /dev/null +++ b/tests/data_set/csv_files/text_urls_template.csv @@ -0,0 +1,4 @@ +url,name +https://sa-public-files.s3.us-west-2.amazonaws.com/Text+project/text_file_example_1.txt,text_file_example_1 +https://sa-public-files.s3.us-west-2.amazonaws.com/Text+project/text_file_example_2.txt,text_file_example_2 +https://sa-public-files.s3.us-west-2.amazonaws.com/Text+project/text_file_example_3.txt,text_file_example_3 diff --git a/tests/data_set/document_annotation/classes/classes.json b/tests/data_set/document_annotation/classes/classes.json new file mode 100644 index 000000000..7a911ea5a --- /dev/null +++ b/tests/data_set/document_annotation/classes/classes.json @@ -0,0 +1 @@ +[{"id":873208,"project_id":160158,"name":"vid","color":"#0fc1c9","count":0,"createdAt":"2021-10-22T10:40:03.000Z","updatedAt":"2021-10-22T10:40:03.000Z","attribute_groups":[{"id":347588,"class_id":873208,"name":"attr g","is_multiselect":0,"createdAt":"2021-10-22T10:40:03.000Z","updatedAt":"2021-10-22T10:40:03.000Z","attributes":[{"id":1203338,"group_id":347588,"project_id":160158,"name":"attr","count":0,"createdAt":"2021-10-22T10:40:03.000Z","updatedAt":"2021-10-22T10:40:03.000Z"}]}]}] \ No newline at end of file diff --git a/tests/data_set/document_annotation/text_file_example_1.json b/tests/data_set/document_annotation/text_file_example_1.json new file mode 100644 index 000000000..144f5513d --- /dev/null +++ b/tests/data_set/document_annotation/text_file_example_1.json @@ -0,0 +1 @@ +{"metadata":{"name":"text_file_example_1","status":"Completed","url":"https://sa-public-files.s3.us-west-2.amazonaws.com/Text+project/text_file_example_1.txt","projectId":160158,"annotatorEmail":null,"qaEmail":null,"lastAction":{"email":"shab.prog@gmail.com","timestamp":1634899229953}},"instances":[{"start":253,"end":593,"classId":873208,"createdAt":"2021-10-22T10:40:26.151Z","createdBy":{"email":"shab.prog@gmail.com","role":"Admin"},"updatedAt":"2021-10-22T10:40:29.953Z","updatedBy":{"email":"shab.prog@gmail.com","role":"Admin"},"attributes":[],"creationType":"Manual","className":"vid"}],"tags":[],"freeText":""} \ No newline at end of file diff --git a/tests/data_set/document_annotation_invalid_json/text_file_example_1.json b/tests/data_set/document_annotation_invalid_json/text_file_example_1.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/tests/data_set/document_annotation_invalid_json/text_file_example_1.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/data_set/sample_videos/earth_video/earth.mov b/tests/data_set/sample_videos/earth_video/earth.mov new file mode 100644 index 000000000..088ec5129 Binary files /dev/null and b/tests/data_set/sample_videos/earth_video/earth.mov differ diff --git a/tests/data_set/video_annotation/classes/classes.json b/tests/data_set/video_annotation/classes/classes.json new file mode 100644 index 000000000..7ee912787 --- /dev/null +++ b/tests/data_set/video_annotation/classes/classes.json @@ -0,0 +1,32 @@ +[ + { + "id": 857627, + "project_id": 150845, + "name": "vid", + "color": "#0fc1c9", + "count": 0, + "createdAt": "2021-10-01T13:03:51.000Z", + "updatedAt": "2021-10-01T13:03:51.000Z", + "attribute_groups": [ + { + "id": 337487, + "class_id": 857627, + "name": "attr g", + "is_multiselect": 0, + "createdAt": "2021-10-04T07:01:29.000Z", + "updatedAt": "2021-10-04T07:01:29.000Z", + "attributes": [ + { + "id": 1174520, + "group_id": 337487, + "project_id": 150845, + "name": "attr", + "count": 0, + "createdAt": "2021-10-04T07:01:31.000Z", + "updatedAt": "2021-10-04T07:01:31.000Z" + } + ] + } + ] + } +] \ No newline at end of file diff --git a/tests/data_set/video_annotation/video.mp4.json b/tests/data_set/video_annotation/video.mp4.json new file mode 100644 index 000000000..4687b418f --- /dev/null +++ b/tests/data_set/video_annotation/video.mp4.json @@ -0,0 +1,299 @@ +{ + "metadata": { + "name": "video.mp4", + "width": 480, + "height": 270, + "status": "NotStarted", + "url": "https://file-examples-com.github.io/uploads/2017/04/file_example_MP4_480_1_5MG.mp4", + "duration": 30526667, + "projectId": 152038, + "error": null, + "annotatorEmail": null, + "qaEmail": null + }, + "instances": [ + { + "meta": { + "type": "bbox", + "classId": 859496, + "className": "vid", + "pointLabels": { + "3": "point label bro" + }, + "start": 0, + "end": 30526667 + }, + "parameters": [ + { + "start": 0, + "end": 30526667, + "timestamps": [ + { + "points": { + "x1": 223.32, + "y1": 78.45, + "x2": 312.31, + "y2": 176.66 + }, + "timestamp": 0, + "attributes": [] + }, + { + "points": { + "x1": 182.08, + "y1": 33.18, + "x2": 283.45, + "y2": 131.39 + }, + "timestamp": 17271058, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "points": { + "x1": 182.32, + "y1": 36.33, + "x2": 284.01, + "y2": 134.54 + }, + "timestamp": 18271058, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "points": { + "x1": 181.49, + "y1": 45.09, + "x2": 283.18, + "y2": 143.3 + }, + "timestamp": 19271058, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "points": { + "x1": 181.9, + "y1": 48.35, + "x2": 283.59, + "y2": 146.56 + }, + "timestamp": 19725864, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "points": { + "x1": 181.49, + "y1": 52.46, + "x2": 283.18, + "y2": 150.67 + }, + "timestamp": 20271058, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "points": { + "x1": 181.49, + "y1": 63.7, + "x2": 283.18, + "y2": 161.91 + }, + "timestamp": 21271058, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "points": { + "x1": 182.07, + "y1": 72.76, + "x2": 283.76, + "y2": 170.97 + }, + "timestamp": 22271058, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "points": { + "x1": 182.07, + "y1": 81.51, + "x2": 283.76, + "y2": 179.72 + }, + "timestamp": 23271058, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "points": { + "x1": 182.42, + "y1": 97.19, + "x2": 284.11, + "y2": 195.4 + }, + "timestamp": 24271058, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "points": { + "x1": 182.42, + "y1": 97.19, + "x2": 284.11, + "y2": 195.4 + }, + "timestamp": 30526667, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + } + ] + } + ] + }, + { + "meta": { + "type": "bbox", + "classId": 859496, + "className": "vid", + "start": 29713736, + "end": 30526667 + }, + "parameters": [ + { + "start": 29713736, + "end": 30526667, + "timestamps": [ + { + "points": { + "x1": 132.82, + "y1": 129.12, + "x2": 175.16, + "y2": 188 + }, + "timestamp": 29713736, + "attributes": [] + }, + { + "points": { + "x1": 132.82, + "y1": 129.12, + "x2": 175.16, + "y2": 188 + }, + "timestamp": 30526667, + "attributes": [] + } + ] + } + ] + }, + { + "meta": { + "type": "event", + "classId": 859496, + "className": "vid", + "start": 5528212, + "end": 7083022 + }, + "parameters": [ + { + "start": 5528212, + "end": 7083022, + "timestamps": [ + { + "timestamp": 5528212, + "attributes": [] + }, + { + "timestamp": 6702957, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "timestamp": 7083022, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + } + ] + } + ] + } + ], + "tags": [ + "some tag" + ] +} \ No newline at end of file diff --git a/tests/data_set/video_annotation_invalid_json/classes/classes.json b/tests/data_set/video_annotation_invalid_json/classes/classes.json new file mode 100644 index 000000000..7ee912787 --- /dev/null +++ b/tests/data_set/video_annotation_invalid_json/classes/classes.json @@ -0,0 +1,32 @@ +[ + { + "id": 857627, + "project_id": 150845, + "name": "vid", + "color": "#0fc1c9", + "count": 0, + "createdAt": "2021-10-01T13:03:51.000Z", + "updatedAt": "2021-10-01T13:03:51.000Z", + "attribute_groups": [ + { + "id": 337487, + "class_id": 857627, + "name": "attr g", + "is_multiselect": 0, + "createdAt": "2021-10-04T07:01:29.000Z", + "updatedAt": "2021-10-04T07:01:29.000Z", + "attributes": [ + { + "id": 1174520, + "group_id": 337487, + "project_id": 150845, + "name": "attr", + "count": 0, + "createdAt": "2021-10-04T07:01:31.000Z", + "updatedAt": "2021-10-04T07:01:31.000Z" + } + ] + } + ] + } +] \ No newline at end of file diff --git a/tests/data_set/video_annotation_invalid_json/video.mp4.json b/tests/data_set/video_annotation_invalid_json/video.mp4.json new file mode 100644 index 000000000..ce5658d4e --- /dev/null +++ b/tests/data_set/video_annotation_invalid_json/video.mp4.json @@ -0,0 +1,299 @@ +{ + "metadata": { + "name": "video.mp4", + "width": 480, + "height": 270, + "status": "NotStarted", + "url": "https://file-examples-com.github.io/uploads/2017/04/file_example_MP4_480_1_5MG.mp4", + "duration": 30526667, + "projectId": 152038, + "error": null, + "annotatorEmail": null, + "qaEmail": null + }, + "instances": [ + { + "meta": { + "type": "bbox", + "classId": 859496, + "className": "vid", + "pointLabels": { + "3": "point label bro" + }, + "start": 0, + "end": 30526667 + }, + "parameters1": [ + { + "start": 0, + "end": 30526667, + "timestamps": [ + { + "points": { + "x1": 223.32, + "y1": 78.45, + "x2": 312.31, + "y2": 176.66 + }, + "timestamp": 0, + "attributes": [] + }, + { + "points": { + "x1": 182.08, + "y1": 33.18, + "x2": 283.45, + "y2": 131.39 + }, + "timestamp": 17271058, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "points": { + "x1": 182.32, + "y1": 36.33, + "x2": 284.01, + "y2": 134.54 + }, + "timestamp": 18271058, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "points": { + "x1": 181.49, + "y1": 45.09, + "x2": 283.18, + "y2": 143.3 + }, + "timestamp": 19271058, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "points": { + "x1": 181.9, + "y1": 48.35, + "x2": 283.59, + "y2": 146.56 + }, + "timestamp": 19725864, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "points": { + "x1": 181.49, + "y1": 52.46, + "x2": 283.18, + "y2": 150.67 + }, + "timestamp": 20271058, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "points": { + "x1": 181.49, + "y1": 63.7, + "x2": 283.18, + "y2": 161.91 + }, + "timestamp": 21271058, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "points": { + "x1": 182.07, + "y1": 72.76, + "x2": 283.76, + "y2": 170.97 + }, + "timestamp": 22271058, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "points": { + "x1": 182.07, + "y1": 81.51, + "x2": 283.76, + "y2": 179.72 + }, + "timestamp": 23271058, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "points": { + "x1": 182.42, + "y1": 97.19, + "x2": 284.11, + "y2": 195.4 + }, + "timestamp": 24271058, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "points": { + "x1": 182.42, + "y1": 97.19, + "x2": 284.11, + "y2": 195.4 + }, + "timestamp": 30526667, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + } + ] + } + ] + }, + { + "meta": { + "type": "bbox", + "classId": 859496, + "className": "vid", + "start": 29713736, + "end": 30526667 + }, + "parameters": [ + { + "start": 29713736, + "end": 30526667, + "timestamps": [ + { + "points": { + "x1": 132.82, + "y1": 129.12, + "x2": 175.16, + "y2": 188 + }, + "timestamp": 29713736, + "attributes": [] + }, + { + "points": { + "x1": 132.82, + "y1": 129.12, + "x2": 175.16, + "y2": 188 + }, + "timestamp": 30526667, + "attributes": [] + } + ] + } + ] + }, + { + "meta": { + "type": "event", + "classId": 859496, + "className": "vid", + "start": 5528212, + "end": 7083022 + }, + "parameters": [ + { + "start": 5528212, + "end": 7083022, + "timestamps": [ + { + "timestamp": 5528212, + "attributes": [] + }, + { + "timestamp": 6702957, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + }, + { + "timestamp": 7083022, + "attributes": [ + { + "id": 1175876, + "groupId": 338357, + "name": "attr", + "groupName": "attr g" + } + ] + } + ] + } + ] + } + ], + "tags": [ + "some tag" + ] +} \ No newline at end of file diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index eff023582..e69de29bb 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -1,4 +0,0 @@ -import os -import sys - -sys.path.insert(0, os.path.join(sys.path[0], "src/superannotate")) \ No newline at end of file diff --git a/tests/integration/annotations/__init__.py b/tests/integration/annotations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/test_annotation_adding.py b/tests/integration/annotations/test_annotation_adding.py similarity index 97% rename from tests/integration/test_annotation_adding.py rename to tests/integration/annotations/test_annotation_adding.py index d01537fd0..93251f368 100644 --- a/tests/integration/test_annotation_adding.py +++ b/tests/integration/annotations/test_annotation_adding.py @@ -1,7 +1,6 @@ import json import os import tempfile -from os.path import dirname from pathlib import Path import src.superannotate as sa @@ -19,11 +18,11 @@ class TestAnnotationAdding(BaseTestCase): @property def folder_path(self): - return os.path.join(dirname(dirname(__file__)), self.TEST_FOLDER_PATH) + return os.path.join(Path(__file__).parent.parent.parent, self.TEST_FOLDER_PATH) @property def invalid_json_path(self): - return os.path.join(dirname(dirname(__file__)), self.TEST_INVALID_ANNOTATION_FOLDER_PATH) + return os.path.join(Path(__file__).parent.parent.parent, self.TEST_INVALID_ANNOTATION_FOLDER_PATH) @property def classes_json_path(self): @@ -36,7 +35,6 @@ def test_upload_invalid_annotations(self): uploaded_annotations, failed_annotations, missing_annotations = sa.upload_annotations_from_folder_to_project( self.PROJECT_NAME, self.invalid_json_path ) - self.assertEqual(len(uploaded_annotations), 3) self.assertEqual(len(failed_annotations), 1) self.assertEqual(len(missing_annotations), 0) diff --git a/tests/integration/test_annotation_class_new.py b/tests/integration/annotations/test_annotation_class_new.py similarity index 95% rename from tests/integration/test_annotation_class_new.py rename to tests/integration/annotations/test_annotation_class_new.py index b68b04d43..17efa4bc7 100644 --- a/tests/integration/test_annotation_class_new.py +++ b/tests/integration/annotations/test_annotation_class_new.py @@ -1,5 +1,5 @@ import os -from os.path import dirname +from pathlib import Path from unittest import TestCase import src.superannotate as sa @@ -33,8 +33,7 @@ def tearDownClass(cls) -> None: @property def classes_json(self): - return os.path.join( - dirname(dirname(__file__)), + return os.path.join(Path(__file__).parent.parent.parent, "data_set/sample_project_vector/classes/classes.json", ) diff --git a/tests/integration/test_annotation_classes.py b/tests/integration/annotations/test_annotation_classes.py similarity index 94% rename from tests/integration/test_annotation_classes.py rename to tests/integration/annotations/test_annotation_classes.py index bba492ee7..41a70f1c5 100644 --- a/tests/integration/test_annotation_classes.py +++ b/tests/integration/annotations/test_annotation_classes.py @@ -1,6 +1,6 @@ from urllib.parse import urlparse import os -from os.path import dirname +from pathlib import Path import src.superannotate as sa from tests.integration.base import BaseTestCase @@ -13,7 +13,7 @@ class TestAnnotationClasses(BaseTestCase): @property def classes_path(self): - return os.path.join(dirname(dirname(__file__)), self.CLASSES_JON_PATH) + return os.path.join(Path(__file__).parent.parent.parent, self.CLASSES_JON_PATH) def test_invalid_json(self): try: diff --git a/tests/integration/test_annotation_delete.py b/tests/integration/annotations/test_annotation_delete.py similarity index 96% rename from tests/integration/test_annotation_delete.py rename to tests/integration/annotations/test_annotation_delete.py index 1e45a0966..277177f1b 100644 --- a/tests/integration/test_annotation_delete.py +++ b/tests/integration/annotations/test_annotation_delete.py @@ -1,6 +1,7 @@ import os from os.path import dirname import pytest +from pathlib import Path import src.superannotate as sa from tests.integration.base import BaseTestCase @@ -17,12 +18,11 @@ class TestAnnotationDelete(BaseTestCase): @property def folder_path(self): - return os.path.join(dirname(dirname(__file__)), self.TEST_FOLDER_PATH) + return os.path.join(Path(__file__).parent.parent.parent, self.TEST_FOLDER_PATH) @property def classes_json(self): - return os.path.join( - dirname(dirname(__file__)), + return os.path.join(Path(__file__).parent.parent.parent, "data_set/sample_project_vector/classes/classes.json", ) diff --git a/tests/integration/test_annotation_upload_pixel.py b/tests/integration/annotations/test_annotation_upload_pixel.py similarity index 94% rename from tests/integration/test_annotation_upload_pixel.py rename to tests/integration/annotations/test_annotation_upload_pixel.py index 1dfa21858..899ac51c2 100644 --- a/tests/integration/test_annotation_upload_pixel.py +++ b/tests/integration/annotations/test_annotation_upload_pixel.py @@ -1,5 +1,5 @@ import os -from os.path import dirname +from pathlib import Path import pytest import src.superannotate as sa from tests.integration.base import BaseTestCase @@ -7,6 +7,7 @@ import json from os.path import join + class TestRecursiveFolderPixel(BaseTestCase): PROJECT_NAME = "test_recursive_pixel" PROJECT_DESCRIPTION = "Desc" @@ -17,7 +18,7 @@ class TestRecursiveFolderPixel(BaseTestCase): @property def folder_path(self): - return os.path.join(dirname(dirname(__file__)), self.TEST_FOLDER_PATH) + return os.path.join(Path(__file__).parent.parent.parent, self.TEST_FOLDER_PATH) @pytest.mark.flaky(reruns=2) def test_recursive_annotation_upload_pixel(self): diff --git a/tests/integration/test_annotation_upload_vector.py b/tests/integration/annotations/test_annotation_upload_vector.py similarity index 56% rename from tests/integration/test_annotation_upload_vector.py rename to tests/integration/annotations/test_annotation_upload_vector.py index 538aa5139..6f7d9ebca 100644 --- a/tests/integration/test_annotation_upload_vector.py +++ b/tests/integration/annotations/test_annotation_upload_vector.py @@ -1,8 +1,11 @@ import tempfile -from os.path import dirname +from pathlib import Path +import os from os.path import join import json import pytest +from unittest.mock import patch +from unittest.mock import MagicMock import src.superannotate as sa from tests.integration.base import BaseTestCase @@ -18,13 +21,24 @@ class TestAnnotationUploadVector(BaseTestCase): @property def folder_path(self): - return join(dirname(dirname(__file__)), self.TEST_FOLDER_PATH) + return os.path.join(Path(__file__).parent.parent.parent, self.TEST_FOLDER_PATH) @pytest.mark.flaky(reruns=2) - def test_annotation_upload(self): + @patch("lib.infrastructure.controller.Reporter") + def test_annotation_upload(self, reporter): + reporter_mock = MagicMock() + reporter.return_value = reporter_mock + annotation_path = join(self.folder_path, f"{self.IMAGE_NAME}___objects.json") sa.upload_image_to_project(self.PROJECT_NAME, join(self.folder_path, self.IMAGE_NAME)) sa.upload_image_annotations(self.PROJECT_NAME, self.IMAGE_NAME, annotation_path) + + from collections import defaultdict + call_groups = defaultdict(list) + reporter_calls = reporter_mock.method_calls + for call in reporter_calls: + call_groups[call[0]].append(call[1]) + self.assertEqual(len(call_groups["log_warning"]), len(call_groups["store_message"])) with tempfile.TemporaryDirectory() as tmp_dir: sa.download_image_annotations(self.PROJECT_NAME, self.IMAGE_NAME, tmp_dir) origin_annotation = json.load(open(annotation_path)) @@ -34,6 +48,28 @@ def test_annotation_upload(self): [i["attributes"]for i in origin_annotation["instances"]] ) + def test_annotation_folder_upload_download(self, ): + sa.upload_images_from_folder_to_project( + self.PROJECT_NAME, self.folder_path, annotation_status="InProgress" + ) + sa.create_annotation_classes_from_classes_json( + self.PROJECT_NAME, f"{self.folder_path}/classes/classes.json" + ) + _, _, _ = sa.upload_annotations_from_folder_to_project( + self.PROJECT_NAME, self.folder_path + ) + images = sa.search_images(self.PROJECT_NAME) + with tempfile.TemporaryDirectory() as tmp_dir: + for image_name in images: + annotation_path = join(self.folder_path, f"{image_name}___objects.json") + sa.download_image_annotations(self.PROJECT_NAME, image_name, tmp_dir) + origin_annotation = json.load(open(annotation_path)) + annotation = json.load(open(join(tmp_dir, f"{image_name}___objects.json"))) + self.assertEqual( + len([i["attributes"] for i in annotation["instances"]]), + len([i["attributes"] for i in origin_annotation["instances"]]) + ) + def test_pre_annotation_folder_upload_download(self): sa.upload_images_from_folder_to_project( self.PROJECT_NAME, self.folder_path, annotation_status="InProgress" diff --git a/tests/integration/test_missing_annotation_upload.py b/tests/integration/annotations/test_missing_annotation_upload.py similarity index 97% rename from tests/integration/test_missing_annotation_upload.py rename to tests/integration/annotations/test_missing_annotation_upload.py index b5392f49e..e509ddc3c 100644 --- a/tests/integration/test_missing_annotation_upload.py +++ b/tests/integration/annotations/test_missing_annotation_upload.py @@ -1,5 +1,5 @@ import os -from os.path import dirname +from pathlib import Path import src.superannotate as sa from tests.integration.base import BaseTestCase @@ -14,7 +14,7 @@ class TestAnnotationUpload(BaseTestCase): @property def folder_path(self): - return os.path.join(dirname(dirname(__file__)), self.TEST_FOLDER_PATH) + return os.path.join(Path(__file__).parent.parent.parent, self.TEST_FOLDER_PATH) def test_missing_annotation_upload(self): sa.upload_images_from_folder_to_project( diff --git a/tests/integration/test_preannotation_upload.py b/tests/integration/annotations/test_preannotation_upload.py similarity index 93% rename from tests/integration/test_preannotation_upload.py rename to tests/integration/annotations/test_preannotation_upload.py index 09624ec0b..6dcd77647 100644 --- a/tests/integration/test_preannotation_upload.py +++ b/tests/integration/annotations/test_preannotation_upload.py @@ -1,6 +1,5 @@ import os import tempfile -from os.path import dirname from pathlib import Path import src.superannotate as sa @@ -15,7 +14,7 @@ class TestVectorPreAnnotationImage(BaseTestCase): @property def folder_path(self): - return os.path.join(dirname(dirname(__file__)), self.TEST_FOLDER_PATH) + return os.path.join(Path(__file__).parent.parent.parent, self.TEST_FOLDER_PATH) def test_pre_annotation_folder_upload_download(self): sa.upload_images_from_folder_to_project( @@ -46,7 +45,7 @@ class TestVectorAnnotationImage(BaseTestCase): @property def folder_path(self): - return os.path.join(dirname(dirname(__file__)), self.TEST_FOLDER_PATH) + return os.path.join(Path(__file__).parent.parent.parent, self.TEST_FOLDER_PATH) def test_pre_annotation_folder_upload_download(self): sa.upload_images_from_folder_to_project( diff --git a/tests/integration/annotations/test_text_annotation_upload.py b/tests/integration/annotations/test_text_annotation_upload.py new file mode 100644 index 000000000..0e35273b8 --- /dev/null +++ b/tests/integration/annotations/test_text_annotation_upload.py @@ -0,0 +1,74 @@ +import os +import tempfile +import json +from pathlib import Path +import pytest +import src.superannotate as sa +from tests.integration.base import BaseTestCase + + +class TestUploadTextAnnotation(BaseTestCase): + PROJECT_NAME = "text annotation upload" + PATH_TO_URLS = "data_set/csv_files/text_urls_template.csv" + PROJECT_DESCRIPTION = "desc" + PROJECT_TYPE = "Document" + ANNOTATIONS_PATH = "data_set/document_annotation" + CLASSES_PATH = "data_set/document_annotation/classes/classes.json" + ANNOTATIONS_PATH_INVALID_JSON = "data_set/document_annotation_invalid_json" + + @property + def folder_path(self): + return Path(__file__).parent.parent.parent + + @property + def csv_path(self): + return os.path.join(self.folder_path, self.PATH_TO_URLS) + + @property + def annotations_path(self): + return os.path.join(self.folder_path, self.ANNOTATIONS_PATH) + + @property + def invalid_annotations_path(self): + return os.path.join(self.folder_path, self.ANNOTATIONS_PATH_INVALID_JSON) + + @property + def classes_path(self): + return os.path.join(self.folder_path, self.CLASSES_PATH) + + @pytest.fixture(autouse=True) + def inject_fixtures(self, caplog): + self._caplog = caplog + + def test_document_annotation_upload_invalid_json(self): + sa.create_annotation_classes_from_classes_json(self.PROJECT_NAME, self.classes_path) + + _, _, _ = sa.attach_document_urls_to_project( + self.PROJECT_NAME, + self.csv_path, + ) + + (uploaded_annotations, failed_annotations, missing_annotations) = sa.upload_annotations_from_folder_to_project( + self.PROJECT_NAME, self.invalid_annotations_path) + self.assertEqual(len(uploaded_annotations), 0) + self.assertEqual(len(failed_annotations), 1) + self.assertEqual(len(missing_annotations), 0) + self.assertIn("Invalid json", self._caplog.text) + + def test_text_annotation_upload(self): + sa.create_annotation_classes_from_classes_json(self.PROJECT_NAME, self.classes_path) + + _, _, _ = sa.attach_document_urls_to_project( + self.PROJECT_NAME, + self.csv_path, + ) + sa.upload_annotations_from_folder_to_project(self.PROJECT_NAME, self.annotations_path) + export = sa.prepare_export(self.PROJECT_NAME) + + with tempfile.TemporaryDirectory() as temp_dir: + output_path = temp_dir + sa.download_export(self.PROJECT_NAME, export, output_path, True) + classes = sa.search_annotation_classes(self.PROJECT_NAME) + downloaded_annotation = json.loads(open(f"{output_path}/text_file_example_1.json").read()) + instance = downloaded_annotation['instances'][0] + self.assertEqual(instance['classId'], classes[0]['id']) diff --git a/tests/integration/annotations/test_video_annotation_upload.py b/tests/integration/annotations/test_video_annotation_upload.py new file mode 100644 index 000000000..9416f11e8 --- /dev/null +++ b/tests/integration/annotations/test_video_annotation_upload.py @@ -0,0 +1,88 @@ +import os +import tempfile +import json +from pathlib import Path + +import pytest +import src.superannotate as sa +from tests.integration.base import BaseTestCase + + +class TestUploadVideoAnnotation(BaseTestCase): + PROJECT_NAME = "video annotation upload" + PATH_TO_URLS = "data_set/attach_video_for_annotation.csv" + PROJECT_DESCRIPTION = "desc" + PROJECT_TYPE = "Video" + ANNOTATIONS_PATH = "data_set/video_annotation" + CLASSES_PATH = "data_set/video_annotation/classes/classes.json" + ANNOTATIONS_PATH_INVALID_JSON = "data_set/video_annotation_invalid_json" + + @property + def folder_path(self): + return Path(__file__).parent.parent.parent + + @property + def csv_path(self): + return os.path.join(self.folder_path, self.PATH_TO_URLS) + + @property + def annotations_path(self): + return os.path.join(self.folder_path, self.ANNOTATIONS_PATH) + + @property + def invalid_annotations_path(self): + return os.path.join(self.folder_path, self.ANNOTATIONS_PATH_INVALID_JSON) + + @property + def classes_path(self): + return os.path.join(self.folder_path, self.CLASSES_PATH) + + @pytest.fixture(autouse=True) + def inject_fixtures(self, caplog): + self._caplog = caplog + + def test_video_annotation_upload_invalid_json(self): + sa.create_annotation_classes_from_classes_json(self.PROJECT_NAME, self.classes_path) + + _, _, _ = sa.attach_video_urls_to_project( + self.PROJECT_NAME, + self.csv_path, + ) + (uploaded_annotations, failed_annotations, missing_annotations) = sa.upload_annotations_from_folder_to_project( + self.PROJECT_NAME, self.invalid_annotations_path) + self.assertEqual(len(uploaded_annotations), 0) + self.assertEqual(len(failed_annotations), 1) + self.assertEqual(len(missing_annotations), 0) + self.assertIn("Invalid json", self._caplog.text) + + def test_video_annotation_upload(self): + sa.create_annotation_classes_from_classes_json(self.PROJECT_NAME, self.classes_path) + + _, _, _ = sa.attach_video_urls_to_project( + self.PROJECT_NAME, + self.csv_path, + ) + sa.upload_annotations_from_folder_to_project(self.PROJECT_NAME, self.annotations_path) + export = sa.prepare_export(self.PROJECT_NAME) + + with tempfile.TemporaryDirectory() as temp_dir: + output_path = temp_dir + sa.download_export(self.PROJECT_NAME, export, output_path, True) + classes = sa.search_annotation_classes(self.PROJECT_NAME) + ids_to_replace = [sa.get_project_metadata(self.PROJECT_NAME)['id']] + for class_ in classes: + for attribute_group in class_['attribute_groups']: + for attribute in attribute_group['attributes']: + ids_to_replace.append(attribute['id']) + ids_to_replace.append(attribute_group['id']) + ids_to_replace.append(class_['id']) + downloaded_annotation = open(f"{output_path}/video.mp4.json").read() + for id_ in ids_to_replace: + downloaded_annotation = downloaded_annotation.replace(str(id_), "0") + downloaded_annotation = json.loads(downloaded_annotation) + class_ids = ["152038", "859496", "338357", "1175876"] + annotation = open(f"{self.annotations_path}/video.mp4.json").read() + for class_id in class_ids: + annotation = annotation.replace(class_id, "0") + uploaded_annotation = json.loads(annotation) + self.assertEqual(downloaded_annotation, uploaded_annotation) diff --git a/tests/integration/test_depricated_functions_document.py b/tests/integration/test_depricated_functions_document.py index 7ede399ca..4d884eef9 100644 --- a/tests/integration/test_depricated_functions_document.py +++ b/tests/integration/test_depricated_functions_document.py @@ -80,10 +80,6 @@ def test_deprecated_functions(self): sa.upload_images_to_project(self.PROJECT_NAME, ["some"]) except AppException as e: self.assertIn(self.EXCEPTION_MESSAGE, str(e)) - try: - sa.upload_annotations_from_folder_to_project(self.PROJECT_NAME, "some", self.annotation_path) - except AppException as e: - self.assertIn(self.EXCEPTION_MESSAGE, str(e)) try: sa.upload_image_annotations(self.PROJECT_NAME, "some", self.annotation_path) except AppException as e: diff --git a/tests/integration/test_depricated_functions_video.py b/tests/integration/test_depricated_functions_video.py index e7ef33942..65ca71cda 100644 --- a/tests/integration/test_depricated_functions_video.py +++ b/tests/integration/test_depricated_functions_video.py @@ -77,10 +77,6 @@ def test_deprecated_functions(self): sa.upload_images_to_project(self.PROJECT_NAME,["some"]) except AppException as e: self.assertIn(self.EXCEPTION_MESSAGE, str(e)) - try: - sa.upload_annotations_from_folder_to_project(self.PROJECT_NAME,"some",self.annotation_path) - except AppException as e: - self.assertIn(self.EXCEPTION_MESSAGE, str(e)) try: sa.upload_image_annotations(self.PROJECT_NAME,"some",self.annotation_path) except AppException as e: diff --git a/tests/integration/test_fuse_gen.py b/tests/integration/test_fuse_gen.py index b32e28555..a4bfde77b 100644 --- a/tests/integration/test_fuse_gen.py +++ b/tests/integration/test_fuse_gen.py @@ -194,7 +194,7 @@ def test_fuse_image_create_pixel(self): self.assertEqual(im1_array.dtype, im2_array.dtype) self.assertTrue(np.array_equal(im1_array, im2_array)) - @pytest.mark.flaky(reruns=4) + @pytest.mark.flaky(reruns=3) def test_fuse_image_create_pixel_with_no_classes(self): with tempfile.TemporaryDirectory() as temp_dir: temp_dir = pathlib.Path(temp_dir) diff --git a/tests/integration/test_interface.py b/tests/integration/test_interface.py index f199f4a5c..de739844a 100644 --- a/tests/integration/test_interface.py +++ b/tests/integration/test_interface.py @@ -251,3 +251,6 @@ def test_create_folder_with_special_character(self): 'Folder __abc (1) created in project Interface Pixel test', logs.output[4] ) + + def test_(self): + sa.upload_annotations_from_folder_to_project("PROJECT_2", "/Users/vaghinak.basentsyan/www/for_fun/data") \ No newline at end of file diff --git a/tests/integration/test_limitations.py b/tests/integration/test_limitations.py index ee872a622..54730708a 100644 --- a/tests/integration/test_limitations.py +++ b/tests/integration/test_limitations.py @@ -51,7 +51,7 @@ def test_user_limitations(self, *_): class TestLimitsMoveImage(BaseTestCase): - PROJECT_NAME = "TestLimitsUploadImagesFromFolderToProject" + PROJECT_NAME = "TestLimitsMoveImage" PROJECT_DESCRIPTION = "Desc" PROJECT_TYPE = "Vector" TEST_FOLDER_PTH = "data_set" @@ -91,7 +91,7 @@ def test_user_limitations(self, ): class TestLimitsCopyImage(BaseTestCase): - PROJECT_NAME = "TestLimitsUploadImagesFromFolderToProject" + PROJECT_NAME = "TestLimitsCopyImage" PROJECT_DESCRIPTION = "Desc" PROJECT_TYPE = "Vector" TEST_FOLDER_PTH = "data_set" diff --git a/tests/integration/test_video.py b/tests/integration/test_video.py index 81bc10e38..a4032ed12 100644 --- a/tests/integration/test_video.py +++ b/tests/integration/test_video.py @@ -2,7 +2,9 @@ from os.path import dirname import src.superannotate as sa +from src.superannotate.lib.core.plugin import VideoPlugin from tests.integration.base import BaseTestCase +import pytest class TestVideo(BaseTestCase): @@ -12,12 +14,18 @@ class TestVideo(BaseTestCase): PROJECT_TYPE = "Vector" TEST_FOLDER_NAME = "new_folder" TEST_VIDEO_FOLDER_PATH = "data_set/sample_videos/single" + TEST_VIDEO_FOLDER_PATH_BIG = "data_set/sample_videos/earth_video" TEST_VIDEO_NAME = "video.mp4" + TEST_FOLDER_NAME_BIG_VIDEO = "big" @property def folder_path(self): return os.path.join(dirname(dirname(__file__)), self.TEST_VIDEO_FOLDER_PATH) + @property + def folder_path_big(self): + return os.path.join(dirname(dirname(__file__)), self.TEST_VIDEO_FOLDER_PATH_BIG) + def setUp(self, *args, **kwargs): self.tearDown() self._project = sa.create_project( @@ -58,36 +66,31 @@ def test_single_video_upload(self): ) self.assertEqual(len(sa.search_images(self.PROJECT_NAME)), 5) - # todo check - # def test_video_deep(self): - # with tempfile.TemporaryDirectory() as temp_dir: - # logger = logging.getLogger() - # - # controller = Controller( - # backend_client=SuperannotateBackendService( - # api_url=constances.BACKEND_URL, - # auth_token=ConfigRepository().get_one("token"), - # logger=logger, - # ), - # response=Response(), - # ) - # controller.extract_video_frames( - # project_name=self.PROJECT_NAME, - # folder_name="", - # video_path=self.folder_path + "/single/video.mp4", - # extract_path=temp_dir, - # target_fps=1, - # start_time=0.0, - # end_time=None, - # annotation_status=None, - # image_quality_in_editor=None, - # limit=10, - # ) - # ground_truth_dir_name = self.folder_path + "/single/ground_truth_frames" - # for file_name in os.listdir(temp_dir): - # temp_file_path = temp_dir + "/" + file_name - # truth_file_path = ground_truth_dir_name + "/" + file_name - # img1 = cv2.imread(temp_file_path) - # img2 = cv2.imread(truth_file_path) - # diff = np.sum(img2 - img1) + np.sum(img2 - img1) - # assert diff == 0 + @pytest.fixture(autouse=True) + def inject_fixtures(self, caplog): + self._caplog = caplog + + def test_video_big(self): + sa.create_folder(self.PROJECT_NAME, self.TEST_FOLDER_NAME_BIG_VIDEO) + sa.upload_video_to_project( + f"{self.PROJECT_NAME}/{self.TEST_FOLDER_NAME_BIG_VIDEO}", + f"{self.folder_path_big}/earth.mov", + target_fps=1, + ) + self.assertEqual(len(sa.search_images(f"{self.PROJECT_NAME}/{self.TEST_FOLDER_NAME_BIG_VIDEO}")), 31) + sa.upload_video_to_project( + f"{self.PROJECT_NAME}/{self.TEST_FOLDER_NAME_BIG_VIDEO}", + f"{self.folder_path_big}/earth.mov", + target_fps=1, + ) + self.assertIn("31 already existing images found that won't be uploaded.", self._caplog.text) + + def test_frame_extraction(self): + frames_gen = VideoPlugin.frames_generator( + f"{self.folder_path_big}/earth.mov", target_fps=None, start_time=0.0, end_time=None + ) + self.assertEqual(len([*frames_gen]), 901) + frames_gen = VideoPlugin.frames_generator( + f"{self.folder_path_big}/earth.mov", target_fps=None, start_time=10.0, end_time=None + ) + self.assertEqual(len([*frames_gen]), 589) diff --git a/tests/integration/vector.json b/tests/integration/vector.json new file mode 100644 index 000000000..9e9363aee --- /dev/null +++ b/tests/integration/vector.json @@ -0,0 +1,47 @@ + +{ + "metadata": { + "name": "example_image_1.jpg", + "width": 1024, + "height": 683, + "status": "Completed", + "pinned": false, + "isPredicted": null, + "projectId": null, + "annotatorEmail": null, + "qaEmail": null + }, + "instances": [ + { + "type": "bbox", + "classId": 72274, + "probability": 100, + "points": { + "x1": 437.16, + "x2": 465.23, + "y1": 341.5, + "y2": 357.09 + }, + "groupId": 0, + "pointLabels": {}, + "locked": false, + "visible": false, + "attributes": [ + { + "id": 117845, + "groupId": 28230, + "name": "2", + "groupName": "Num doors" + } + ], + "trackingId": "aaa97f80c9e54a5f2dc2e920fc92e5033d9af45b", + "error": null, + "createdAt": null, + "createdBy": null, + "creationType": null, + "updatedAt": null, + "updatedBy": null, + "className": "Personal vehicle" + } + ] +} diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/test_controller_init.py b/tests/unit/test_controller_init.py index caf9ec2b3..2ba4c41de 100644 --- a/tests/unit/test_controller_init.py +++ b/tests/unit/test_controller_init.py @@ -1,8 +1,5 @@ -import sys -from io import StringIO from os.path import join import json -from contextlib import contextmanager import pkg_resources import tempfile from unittest import TestCase @@ -12,6 +9,7 @@ from src.superannotate.lib.app.interface.cli_interface import CLIFacade from src.superannotate.lib.core import CONFIG_FILE_LOCATION +from tests.utils.helpers import catch_prints try: @@ -20,13 +18,6 @@ CLI_VERSION = None -@contextmanager -def catch_prints(): - out = StringIO() - sys.stdout = out - yield out - - class CLITest(TestCase): @patch('builtins.input') diff --git a/tests/unit/test_validators.py b/tests/unit/test_validators.py new file mode 100644 index 000000000..d54832781 --- /dev/null +++ b/tests/unit/test_validators.py @@ -0,0 +1,88 @@ +import json +import os +from os.path import dirname +import tempfile + +import src.superannotate as sa +from tests.utils.helpers import catch_prints + +from unittest import TestCase + +VECTOR_ANNOTATION_JSON_WITH_BBOX = """ +{ + "metadata": { + "name": "example_image_1.jpg", + "width": 1024, + "height": 683, + "status": "Completed", + "pinned": false, + "isPredicted": null, + "projectId": null, + "annotatorEmail": null, + "qaEmail": null + }, + "instances": [ + { + "type": "bbox", + "classId": 72274, + "probability": 100, + "points": { + + "x2": 465.23, + "y1": 341.5, + "y2": 357.09 + }, + "groupId": 0, + "pointLabels": {}, + "locked": false, + "visible": false, + "attributes": [ + { + "id": 117845, + "groupId": 28230, + "name": "2", + "groupName": "Num doors" + } + ], + "trackingId": "aaa97f80c9e54a5f2dc2e920fc92e5033d9af45b", + "error": null, + "createdAt": null, + "createdBy": null, + "creationType": null, + "updatedAt": null, + "updatedBy": null, + "className": "Personal vehicle" + } + ] +} +""" + + +class TestValidators(TestCase): + TEST_VECTOR_FOLDER_PATH = "data_set/sample_project_vector" + VECTOR_JSON = "example_image_1.jpg___objects.json" + + @property + def vector_folder_path(self): + return os.path.join(dirname(dirname(__file__)), self.TEST_VECTOR_FOLDER_PATH) + + def test_validate_annotations_should_note_raise_errors(self): + sa.validate_annotations("Vector", os.path.join(self.vector_folder_path, self.VECTOR_JSON)) + + def test_validate_annotation_with_wrong_bbox(self): + with tempfile.TemporaryDirectory() as tmpdir_name: + with open(f"{tmpdir_name}/vector.json", "w") as vector_json: + vector_json.write(VECTOR_ANNOTATION_JSON_WITH_BBOX) + with catch_prints() as out: + sa.validate_annotations("Vector", os.path.join(self.vector_folder_path, f"{tmpdir_name}/vector.json")) + self.assertEqual("instances[0].points[x1]fieldrequired", out.getvalue().strip().replace(" ", "")) + + def test_validate_annotation_without_metadata(self): + with tempfile.TemporaryDirectory() as tmpdir_name: + with open(f"{tmpdir_name}/vector.json", "w") as vector_json: + vector_json.write( + json.dumps({"instances": []}) + ) + with catch_prints() as out: + sa.validate_annotations("Vector", os.path.join(self.vector_folder_path, f"{tmpdir_name}/vector.json")) + self.assertIn("metadatafieldrequired", out.getvalue().strip().replace(" ", "")) diff --git a/tests/unit/tset_vector_annotation_validation.py b/tests/unit/tset_vector_annotation_validation.py new file mode 100644 index 000000000..a29b9e68f --- /dev/null +++ b/tests/unit/tset_vector_annotation_validation.py @@ -0,0 +1,30 @@ +from src.superannotate.lib.infrastructure.validators import AnnotationValidator +from tests.utils.helpers import catch_prints + +from unittest import TestCase + + +class TestVectorValidators(TestCase): + + def test_validate_annotation_without_metadata(self): + validator = AnnotationValidator.get_vector_validator()({"instances": []}) + self.assertFalse(validator.is_valid()) + self.assertEqual("metadatafieldrequired", validator.generate_report().strip().replace(" ", "")) + + def test_validate_annotation_with_invalid_metadata(self): + validator = AnnotationValidator.get_vector_validator()({"metadata": {"name": 12}}) + self.assertFalse(validator.is_valid()) + self.assertEqual("metadata[name]strtypeexpected", validator.generate_report().strip().replace(" ", "")) + + def test_validate_instances(self): + validator = AnnotationValidator.get_vector_validator()( + { + "metadata": {"name": "12"}, + "instances": [{"type": "invalid_type"}, {"type": "bbox"}] + } + ) + + self.assertFalse(validator.is_valid()) + print(validator.generate_report()) + self.assertEqual("metadata[name]strtypeexpected", validator.generate_report().strip().replace(" ", "")) + diff --git a/tests/utils/helpers.py b/tests/utils/helpers.py new file mode 100644 index 000000000..1ed538710 --- /dev/null +++ b/tests/utils/helpers.py @@ -0,0 +1,11 @@ +import sys + +from io import StringIO +from contextlib import contextmanager + + +@contextmanager +def catch_prints(): + out = StringIO() + sys.stdout = out + yield out \ No newline at end of file diff --git a/tox.ini b/tox.ini index 1c734166e..6b94f500e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,15 +1,14 @@ [tox] isolated_build = True - -envlist = - py{36} +tox_pyenv_fallback = False +envlist = py36,py38 [testenv] usedevelop = true deps = -rrequirements.txt -[testenv:py38] +[testenv:py36] deps = {[testenv]deps} commands =