From f963e36ef60101d46e4a2a166294361891573017 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Mon, 16 Oct 2023 17:49:43 +0200 Subject: [PATCH 01/71] fixing test --- tests/darwin/exporter/formats/export_darwin_1_0_test.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/darwin/exporter/formats/export_darwin_1_0_test.py b/tests/darwin/exporter/formats/export_darwin_1_0_test.py index a997c62b3..0d5a7773d 100644 --- a/tests/darwin/exporter/formats/export_darwin_1_0_test.py +++ b/tests/darwin/exporter/formats/export_darwin_1_0_test.py @@ -59,8 +59,15 @@ def test_complete_annotation_file(self): {"x": 531.6440000000002, "y": 428.4196}, {"x": 529.8140000000002, "y": 426.5896}, ] + bounding_box = {"x": 557.66, + "y": 428.98, + "w": 160.76, + "h": 315.3 + } annotation_class = dt.AnnotationClass(name="test", annotation_type="polygon") - annotation = dt.Annotation(annotation_class=annotation_class, data={"path": polygon_path}, subs=[]) + annotation = dt.Annotation(annotation_class=annotation_class, data={"path": polygon_path, "bounding_box":bounding_box}, subs=[]) + + annotation_file = dt.AnnotationFile( path=Path("test.json"), From 08fc262b5a91c9c121a692419b7463ac6364686e Mon Sep 17 00:00:00 2001 From: Christoffer Date: Mon, 16 Oct 2023 17:55:29 +0200 Subject: [PATCH 02/71] added polygon and complex polygon tests with bounding boxes --- .../formats/export_darwin_1_0_test.py | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/darwin/exporter/formats/export_darwin_1_0_test.py b/tests/darwin/exporter/formats/export_darwin_1_0_test.py index 0d5a7773d..fef10e3b4 100644 --- a/tests/darwin/exporter/formats/export_darwin_1_0_test.py +++ b/tests/darwin/exporter/formats/export_darwin_1_0_test.py @@ -135,3 +135,86 @@ def test_complex_polygon(self): "annotations": [{"complex_polygon": {"path": polygon_path}, "name": "test", "slot_names": []}], "dataset": "None", } + + def test_polygon_annotation_file_with_bbox(self): + polygon_path = [ + {"x": 534.1440000000002, "y": 429.0896}, + {"x": 531.6440000000002, "y": 428.4196}, + {"x": 529.8140000000002, "y": 426.5896}, + ] + + annotation_class = dt.AnnotationClass(name="test", annotation_type="polygon") + annotation = dt.Annotation(annotation_class=annotation_class, data={"path": polygon_path}, subs=[]) + + annotation_file = dt.AnnotationFile( + path=Path("test.json"), + filename="test.json", + annotation_classes=[annotation_class], + annotations=[annotation], + image_height=1080, + image_width=1920, + image_url="https://darwin.v7labs.com/image.jpg", + ) + + assert _build_json(annotation_file) == { + "image": { + "seq": None, + "width": 1920, + "height": 1080, + "filename": "test.json", + "original_filename": "test.json", + "url": "https://darwin.v7labs.com/image.jpg", + "thumbnail_url": None, + "path": None, + "workview_url": None, + }, + "annotations": [{"polygon": {"path": polygon_path}, "name": "test", "slot_names": [], "bounding_box": bounding_box}], + "dataset": "None", + } + + def test_complex_polygon_with_bbox(self): + polygon_path = [ + [{"x": 230.06, "y": 174.04}, {"x": 226.39, "y": 170.36}, {"x": 224.61, "y": 166.81}], + [{"x": 238.98, "y": 171.69}, {"x": 236.97, "y": 174.04}, {"x": 238.67, "y": 174.04}], + [ + {"x": 251.75, "y": 169.77}, + {"x": 251.75, "y": 154.34}, + {"x": 251.08, "y": 151.84}, + {"x": 249.25, "y": 150.01}, + ], + ] + + bounding_box = {"x": 557.66, + "y": 428.98, + "w": 160.76, + "h": 315.3 + } + + annotation_class = dt.AnnotationClass(name="test", annotation_type="complex_polygon") + annotation = dt.Annotation(annotation_class=annotation_class, data={"paths": polygon_path, "bounding_box": bounding_box}, subs=[]) + + annotation_file = dt.AnnotationFile( + path=Path("test.json"), + filename="test.json", + annotation_classes=[annotation_class], + annotations=[annotation], + image_height=1080, + image_width=1920, + image_url="https://darwin.v7labs.com/image.jpg", + ) + + assert _build_json(annotation_file) == { + "image": { + "seq": None, + "width": 1920, + "height": 1080, + "filename": "test.json", + "original_filename": "test.json", + "url": "https://darwin.v7labs.com/image.jpg", + "thumbnail_url": None, + "path": None, + "workview_url": None, + }, + "annotations": [{"complex_polygon": {"path": polygon_path}, "name": "test", "slot_names": [], "bounding_box": bounding_box}], + "dataset": "None", + } \ No newline at end of file From db3fbde4abb2553c24d7bd2ea1536fd236101a35 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Mon, 16 Oct 2023 18:01:11 +0200 Subject: [PATCH 03/71] extended convertion tests --- .../formats/export_darwin_1_0_test.py | 87 +++++++++++++++---- 1 file changed, 69 insertions(+), 18 deletions(-) diff --git a/tests/darwin/exporter/formats/export_darwin_1_0_test.py b/tests/darwin/exporter/formats/export_darwin_1_0_test.py index fef10e3b4..f9ec01711 100644 --- a/tests/darwin/exporter/formats/export_darwin_1_0_test.py +++ b/tests/darwin/exporter/formats/export_darwin_1_0_test.py @@ -8,9 +8,7 @@ class TestBuildJson: def test_empty_annotation_file(self): - annotation_file = dt.AnnotationFile( - path=Path("test.json"), filename="test.json", annotation_classes=[], annotations=[] - ) + annotation_file = dt.AnnotationFile(path=Path("test.json"), filename="test.json", annotation_classes=[], annotations=[]) assert _build_json(annotation_file) == { "image": { @@ -59,15 +57,9 @@ def test_complete_annotation_file(self): {"x": 531.6440000000002, "y": 428.4196}, {"x": 529.8140000000002, "y": 426.5896}, ] - bounding_box = {"x": 557.66, - "y": 428.98, - "w": 160.76, - "h": 315.3 - } - annotation_class = dt.AnnotationClass(name="test", annotation_type="polygon") - annotation = dt.Annotation(annotation_class=annotation_class, data={"path": polygon_path, "bounding_box":bounding_box}, subs=[]) - + annotation_class = dt.AnnotationClass(name="test", annotation_type="polygon") + annotation = dt.Annotation(annotation_class=annotation_class, data={"path": polygon_path}, subs=[]) annotation_file = dt.AnnotationFile( path=Path("test.json"), @@ -143,8 +135,10 @@ def test_polygon_annotation_file_with_bbox(self): {"x": 529.8140000000002, "y": 426.5896}, ] + bounding_box = {"x": 557.66, "y": 428.98, "w": 160.76, "h": 315.3} + annotation_class = dt.AnnotationClass(name="test", annotation_type="polygon") - annotation = dt.Annotation(annotation_class=annotation_class, data={"path": polygon_path}, subs=[]) + annotation = dt.Annotation(annotation_class=annotation_class, data={"path": polygon_path, "bounding_box": bounding_box}, subs=[]) annotation_file = dt.AnnotationFile( path=Path("test.json"), @@ -184,11 +178,7 @@ def test_complex_polygon_with_bbox(self): ], ] - bounding_box = {"x": 557.66, - "y": 428.98, - "w": 160.76, - "h": 315.3 - } + bounding_box = {"x": 557.66, "y": 428.98, "w": 160.76, "h": 315.3} annotation_class = dt.AnnotationClass(name="test", annotation_type="complex_polygon") annotation = dt.Annotation(annotation_class=annotation_class, data={"paths": polygon_path, "bounding_box": bounding_box}, subs=[]) @@ -217,4 +207,65 @@ def test_complex_polygon_with_bbox(self): }, "annotations": [{"complex_polygon": {"path": polygon_path}, "name": "test", "slot_names": [], "bounding_box": bounding_box}], "dataset": "None", - } \ No newline at end of file + } + + def test_bounding_box(self): + bounding_box_data = {"x": 100, "y": 150, "w": 50, "h": 30} + annotation_class = dt.AnnotationClass(name="bbox_test", annotation_type="bounding_box") + annotation = dt.Annotation(annotation_class=annotation_class, data=bounding_box_data, subs=[]) + + annotation_file = dt.AnnotationFile( + path=Path("test.json"), + filename="test.json", + annotation_classes=[annotation_class], + annotations=[annotation], + image_height=1080, + image_width=1920, + image_url="https://darwin.v7labs.com/image.jpg", + ) + + assert _build_json(annotation_file) == { + "image": { + "seq": None, + "width": 1920, + "height": 1080, + "filename": "test.json", + "original_filename": "test.json", + "url": "https://darwin.v7labs.com/image.jpg", + "thumbnail_url": None, + "path": None, + "workview_url": None, + }, + "annotations": [{"bounding_box": bounding_box_data, "name": "bbox_test", "slot_names": []}], + "dataset": "None", + } + + def test_tags(self): + tag_data = "sample_tag" + annotation_class = dt.AnnotationClass(name="tag_test", annotation_type="tag") + annotation = dt.Annotation(annotation_class=annotation_class, data=tag_data, subs=[]) + + annotation_file = dt.AnnotationFile( + path=Path("test.json"), + filename="test.json", + annotation_classes=[annotation_class], + annotations=[annotation], + image_height=1080, + image_width=1920, + image_url="https://darwin.v7labs.com/image.jpg", + ) + assert _build_json(annotation_file) == { + "image": { + "seq": None, + "width": 1920, + "height": 1080, + "filename": "test.json", + "original_filename": "test.json", + "url": "https://darwin.v7labs.com/image.jpg", + "thumbnail_url": None, + "path": None, + "workview_url": None, + }, + "annotations": [{"tag": {}, "name": "tag_test", "slot_names": []}], + "dataset": "None", + } From 5d984621b1bc3c1b66f5040a3a86508320e71dea Mon Sep 17 00:00:00 2001 From: Christoffer Date: Tue, 17 Oct 2023 10:19:32 +0200 Subject: [PATCH 04/71] formatter --- tests/darwin/exporter/formats/export_darwin_1_0_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/darwin/exporter/formats/export_darwin_1_0_test.py b/tests/darwin/exporter/formats/export_darwin_1_0_test.py index f9ec01711..4bca09c28 100644 --- a/tests/darwin/exporter/formats/export_darwin_1_0_test.py +++ b/tests/darwin/exporter/formats/export_darwin_1_0_test.py @@ -1,6 +1,5 @@ from pathlib import Path -import pytest import darwin.datatypes as dt from darwin.exporter.formats.darwin_1_0 import _build_json From 69f33f0750c5f66faeb905338d4cd91ebc566444 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Tue, 14 Nov 2023 16:07:45 +0100 Subject: [PATCH 05/71] updated test to reflect adding of bounding box --- darwin/dataset/local_dataset.py | 5 +- darwin/dataset/utils.py | 3 + darwin/exporter/formats/darwin.py | 171 +++++++++++++----- darwin/utils/utils.py | 5 + .../formats/export_darwin_1_0_test.py | 13 +- 5 files changed, 147 insertions(+), 50 deletions(-) diff --git a/darwin/dataset/local_dataset.py b/darwin/dataset/local_dataset.py index d79ecae76..5b5efbd4d 100644 --- a/darwin/dataset/local_dataset.py +++ b/darwin/dataset/local_dataset.py @@ -1,3 +1,4 @@ +import json import multiprocessing as mp from pathlib import Path from typing import Any, Dict, Iterator, List, Optional, Tuple @@ -102,7 +103,7 @@ def __init__( partition, split_type, ) - + print(self.images_path) if len(self.images_path) == 0: raise ValueError( f"Could not find any {SUPPORTED_IMAGE_EXTENSIONS} file", @@ -141,7 +142,7 @@ def _setup_annotations_and_images( continue else: raise ValueError( - f"Annotation ({annotation_path}) does not have a corresponding image" + f"Annotation ({annotation_path}) does not have a corresponding image, image path : {image_path}" ) def _initial_setup(self, dataset_path, release_name): diff --git a/darwin/dataset/utils.py b/darwin/dataset/utils.py index c11e3576a..1227ded72 100644 --- a/darwin/dataset/utils.py +++ b/darwin/dataset/utils.py @@ -477,7 +477,10 @@ def get_annotations( for p in invalid_annotation_paths: print(p) + print(stems) + print(f"Image path : {images_paths}") if len(images_paths) == 0: + print(f"Image path : {images_paths}") raise ValueError( f"Could not find any {SUPPORTED_EXTENSIONS} file" f" in {dataset_path / 'images'}" diff --git a/darwin/exporter/formats/darwin.py b/darwin/exporter/formats/darwin.py index eadab47ad..048615255 100644 --- a/darwin/exporter/formats/darwin.py +++ b/darwin/exporter/formats/darwin.py @@ -14,67 +14,38 @@ """ + def build_image_annotation(annotation_file: dt.AnnotationFile) -> Dict[str, Any]: """ - Builds and returns a dictionary with the annotations present in the given file. + Builds and returns a dictionary with the annotations present in the given file in Darwin v2 format. Parameters ---------- - annotation_file: dt.AnnotationFile + annotation_file: AnnotationFile File with the image annotations to extract. Returns ------- Dict[str, Any] - A dictionary with the annotation from the given file. Has the following structure: - - .. code-block:: python - - { - "annotations": [ - { - "annotation_type": { ... }, # annotation_data - "name": "annotation class name", - "bounding_box": { ... } # Optional parameter, only present if the file has a bounding box as well - } - ], - "image": { - "filename": "a_file_name.json", - "height": 1000, - "width": 2000, - "url": "https://www.darwin.v7labs.com/..." - } - } + A dictionary with the annotations in Darwin v2 format. """ - annotations: List[Dict[str, Any]] = [] - print(annotations) - for annotation in annotation_file.annotations: - payload = { - annotation.annotation_class.annotation_type: _build_annotation_data( - annotation - ), - "name": annotation.annotation_class.name, - } + annotations_list: List[Dict[str, Any]] = [] - if ( - annotation.annotation_class.annotation_type == "complex_polygon" - or annotation.annotation_class.annotation_type == "polygon" - ) and "bounding_box" in annotation.data: - payload["bounding_box"] = annotation.data["bounding_box"] + for annotation in annotation_file.annotations: + annotation_data = _build_v2_annotation_data(annotation) + annotations_list.append(annotation_data) - annotations.append(payload) + slots_data = _build_slots_data(annotation_file.slots) return { - "annotations": annotations, - "image": { - "filename": annotation_file.filename, - "height": annotation_file.image_height, - "width": annotation_file.image_width, - "url": annotation_file.image_url, - }, + "version": "2.0", + "schema_ref": "https://darwin-public.s3.eu-west-1.amazonaws.com/darwin_json/2.0/schema.json", + "item": _build_item_data(annotation_file), + "annotations": annotations_list } + @deprecation.deprecated( deprecated_in="0.7.8", removed_in="0.8.0", @@ -103,3 +74,117 @@ def _build_annotation_data(annotation: dt.Annotation) -> Dict[str, Any]: ) return dict(annotation.data) + + +def _build_v2_annotation_data(annotation: dt.Annotation) -> Dict[str, Any]: + annotation_data = { + "id": annotation.id, + "name": annotation.annotation_class.name + } + + if annotation.annotation_class.annotation_type == "bounding_box": + annotation_data["bounding_box"] = _build_bounding_box_data(annotation.data) + elif annotation.annotation_class.annotation_type == "tag": + annotation_data["tag"] = {} + elif annotation.annotation_class.annotation_type == "polygon": + annotation_data["polygon"] = _build_polygon_data(annotation.data) + + return annotation_data + +def _build_bounding_box_data(data: Dict[str, Any]) -> Dict[str, Any]: + return { + "h": data.get("h"), + "w": data.get("w"), + "x": data.get("x"), + "y": data.get("y") + } + + +def _build_polygon_data(data: Dict[str, Any]) -> Dict[str, List[List[Dict[str, float]]]]: + """ + Builds the polygon data for Darwin v2 format. + + Parameters + ---------- + data : Dict[str, Any] + The original data for the polygon annotation. + + Returns + ------- + Dict[str, List[List[Dict[str, float]]]] + The polygon data in the format required for Darwin v2 annotations. + """ + # Assuming the data contains a 'paths' key that is a list of lists of points, + # where each point is a dictionary with 'x' and 'y' keys. + paths = data.get("paths", []) + v2_paths = [] + + for path in paths: + v2_path = [] + for point in path: + v2_point = {"x": point.get("x"), "y": point.get("y")} + v2_path.append(v2_point) + v2_paths.append(v2_path) + + return {"paths": v2_paths} + + +def _build_item_data(annotation_file: dt.AnnotationFile) -> Dict[str, Any]: + """ + Constructs the 'item' section of the Darwin v2 format annotation. + + Parameters + ---------- + annotation_file: AnnotationFile + The AnnotationFile object containing annotation data. + + Returns + ------- + Dict[str, Any] + The 'item' section of the Darwin v2 format annotation. + """ + return { + "name": annotation_file.filename, + "path": annotation_file.remote_path or "/", + "source_info": { + "dataset": { + "name": annotation_file.dataset_name or "Unknown", + "slug": annotation_file.dataset_name.lower().replace(" ", "-") if annotation_file.dataset_name else "unknown", + }, + "item_id": annotation_file.item_id or "unknown-item-id", + "team": { + "name": "Unknown Team", # Replace with actual team name + "slug": annotation_file.dataset_name # Replace with actual team slug + }, + "workview_url": annotation_file.workview_url + } + } + + +def _build_slots_data(slots: List[dt.Slot]) -> List[Dict[str, Any]]: + """ + Constructs the 'slots' data for the Darwin v2 format annotation. + + Parameters + ---------- + slots: List[Slot] + A list of Slot objects from the AnnotationFile. + + Returns + ------- + List[Dict[str, Any]] + The 'slots' data for the Darwin v2 format annotation. + """ + slots_data = [] + for slot in slots: + slot_data = { + "type": slot.type, + "slot_name": slot.name, + "width": slot.width, + "height": slot.height, + "thumbnail_url": slot.thumbnail_url, + "source_files": slot.source_files + } + slots_data.append(slot_data) + + return slots_data diff --git a/darwin/utils/utils.py b/darwin/utils/utils.py index 68f81a53f..7f9433428 100644 --- a/darwin/utils/utils.py +++ b/darwin/utils/utils.py @@ -872,6 +872,10 @@ def split_video_annotation(annotation: dt.AnnotationFile) -> List[dt.AnnotationF annotations = [ a.frames[i] for a in annotation.annotations if isinstance(a, dt.VideoAnnotation) and i in a.frames ] + + if len(annotations) < 1: + continue + annotation_classes: Set[dt.AnnotationClass] = set([annotation.annotation_class for annotation in annotations]) filename: str = f"{Path(annotation.filename).stem}/{i:07d}.png" frame_annotations.append( @@ -890,6 +894,7 @@ def split_video_annotation(annotation: dt.AnnotationFile) -> List[dt.AnnotationF slots=annotation.slots, ) ) + print(frame_annotations[0].version) return frame_annotations diff --git a/tests/darwin/exporter/formats/export_darwin_1_0_test.py b/tests/darwin/exporter/formats/export_darwin_1_0_test.py index 5df8cb300..1a0fe759a 100644 --- a/tests/darwin/exporter/formats/export_darwin_1_0_test.py +++ b/tests/darwin/exporter/formats/export_darwin_1_0_test.py @@ -60,11 +60,14 @@ def test_complete_annotation_file(self): {"x": 531.6440000000002, "y": 428.4196}, {"x": 529.8140000000002, "y": 426.5896}, ] - + bounding_box = {"x": 557.66, + "y": 428.98, + "w": 160.76, + "h": 315.3 + } + annotation_class = dt.AnnotationClass(name="test", annotation_type="polygon") - annotation = dt.Annotation( - annotation_class=annotation_class, data={"path": polygon_path}, subs=[] - ) + annotation = dt.Annotation(annotation_class=annotation_class, data={"path": polygon_path, "bounding_box":bounding_box}, subs=[]) annotation_file = dt.AnnotationFile( path=Path("test.json"), @@ -89,7 +92,7 @@ def test_complete_annotation_file(self): "workview_url": None, }, "annotations": [ - {"polygon": {"path": polygon_path}, "name": "test", "slot_names": []} + {"polygon": {"path": polygon_path}, "name": "test", "slot_names": [], "bounding_box": bounding_box} ], "dataset": "None", } From b3c82e06a721b496a2e48551c5859dd353cbeee1 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Tue, 14 Nov 2023 17:49:47 +0100 Subject: [PATCH 06/71] updated tests to check for new format --- darwin/dataset/local_dataset.py | 1 - darwin/exporter/formats/darwin.py | 13 ++-- darwin/utils/utils.py | 1 - .../formats/export_darwin_1_0_test.py | 21 +++--- .../exporter/formats/export_darwin_test.py | 69 +++++++++++++++---- 5 files changed, 75 insertions(+), 30 deletions(-) diff --git a/darwin/dataset/local_dataset.py b/darwin/dataset/local_dataset.py index 5b5efbd4d..13b9f6df0 100644 --- a/darwin/dataset/local_dataset.py +++ b/darwin/dataset/local_dataset.py @@ -103,7 +103,6 @@ def __init__( partition, split_type, ) - print(self.images_path) if len(self.images_path) == 0: raise ValueError( f"Could not find any {SUPPORTED_IMAGE_EXTENSIONS} file", diff --git a/darwin/exporter/formats/darwin.py b/darwin/exporter/formats/darwin.py index 048615255..fbc1f31a4 100644 --- a/darwin/exporter/formats/darwin.py +++ b/darwin/exporter/formats/darwin.py @@ -36,16 +36,17 @@ def build_image_annotation(annotation_file: dt.AnnotationFile) -> Dict[str, Any] annotations_list.append(annotation_data) slots_data = _build_slots_data(annotation_file.slots) + item =_build_item_data(annotation_file) + item['slots'] = slots_data return { "version": "2.0", "schema_ref": "https://darwin-public.s3.eu-west-1.amazonaws.com/darwin_json/2.0/schema.json", - "item": _build_item_data(annotation_file), + "item": item, "annotations": annotations_list } - @deprecation.deprecated( deprecated_in="0.7.8", removed_in="0.8.0", @@ -148,13 +149,13 @@ def _build_item_data(annotation_file: dt.AnnotationFile) -> Dict[str, Any]: "path": annotation_file.remote_path or "/", "source_info": { "dataset": { - "name": annotation_file.dataset_name or "Unknown", - "slug": annotation_file.dataset_name.lower().replace(" ", "-") if annotation_file.dataset_name else "unknown", + "name": annotation_file.dataset_name, + "slug": annotation_file.dataset_name.lower().replace(" ", "-") if annotation_file.dataset_name else None }, "item_id": annotation_file.item_id or "unknown-item-id", "team": { - "name": "Unknown Team", # Replace with actual team name - "slug": annotation_file.dataset_name # Replace with actual team slug + "name": None, # TODO Replace with actual team name + "slug": None # TODO Replace with actual team slug }, "workview_url": annotation_file.workview_url } diff --git a/darwin/utils/utils.py b/darwin/utils/utils.py index 7f9433428..8eee4e1ad 100644 --- a/darwin/utils/utils.py +++ b/darwin/utils/utils.py @@ -894,7 +894,6 @@ def split_video_annotation(annotation: dt.AnnotationFile) -> List[dt.AnnotationF slots=annotation.slots, ) ) - print(frame_annotations[0].version) return frame_annotations diff --git a/tests/darwin/exporter/formats/export_darwin_1_0_test.py b/tests/darwin/exporter/formats/export_darwin_1_0_test.py index 1a0fe759a..5136929b3 100644 --- a/tests/darwin/exporter/formats/export_darwin_1_0_test.py +++ b/tests/darwin/exporter/formats/export_darwin_1_0_test.py @@ -60,14 +60,14 @@ def test_complete_annotation_file(self): {"x": 531.6440000000002, "y": 428.4196}, {"x": 529.8140000000002, "y": 426.5896}, ] - bounding_box = {"x": 557.66, - "y": 428.98, - "w": 160.76, - "h": 315.3 - } - + bounding_box = {"x": 557.66, "y": 428.98, "w": 160.76, "h": 315.3} + annotation_class = dt.AnnotationClass(name="test", annotation_type="polygon") - annotation = dt.Annotation(annotation_class=annotation_class, data={"path": polygon_path, "bounding_box":bounding_box}, subs=[]) + annotation = dt.Annotation( + annotation_class=annotation_class, + data={"path": polygon_path, "bounding_box": bounding_box}, + subs=[], + ) annotation_file = dt.AnnotationFile( path=Path("test.json"), @@ -92,7 +92,12 @@ def test_complete_annotation_file(self): "workview_url": None, }, "annotations": [ - {"polygon": {"path": polygon_path}, "name": "test", "slot_names": [], "bounding_box": bounding_box} + { + "polygon": {"path": polygon_path}, + "name": "test", + "slot_names": [], + "bounding_box": bounding_box, + } ], "dataset": "None", } diff --git a/tests/darwin/exporter/formats/export_darwin_test.py b/tests/darwin/exporter/formats/export_darwin_test.py index af4eb34c8..c92aec8d8 100644 --- a/tests/darwin/exporter/formats/export_darwin_test.py +++ b/tests/darwin/exporter/formats/export_darwin_test.py @@ -1,35 +1,76 @@ from pathlib import Path from darwin.datatypes import Annotation, AnnotationClass, AnnotationFile -from darwin.exporter.formats.darwin import build_image_annotation +from darwin.exporter.formats.darwin import ( + _build_item_data, + _build_v2_annotation_data, + build_image_annotation, +) -def test_empty_annotation_file(): +def test_empty_annotation_file_v2(): annotation_file = AnnotationFile( - path=Path("test.json"), filename="test.json", annotation_classes=[], annotations=[] + path=Path("test.json"), + filename="test.json", + annotation_classes=[], + annotations=[], + dataset_name="Test Dataset" ) - assert build_image_annotation(annotation_file) == { - "annotations": [], - "image": {"filename": "test.json", "height": None, "url": None, "width": None}, + expected_output = { + "version": "2.0", + "schema_ref": "https://darwin-public.s3.eu-west-1.amazonaws.com/darwin_json/2.0/schema.json", + "item": { + "name": "test.json", + "path": "/", + "source_info": { + "dataset": {"name": "Test Dataset", "slug": "test-dataset"}, + "item_id": "unknown-item-id", + "team": {"name": None, "slug": None}, + "workview_url": None + }, + "slots": [] # Include an empty slots list as per Darwin v2 format + }, + "annotations": [] } + assert build_image_annotation(annotation_file) == expected_output -def test_complete_annotation_file(): + + +def test_complete_annotation_file_v2(): annotation_class = AnnotationClass(name="test", annotation_type="polygon") - annotation = Annotation(annotation_class=annotation_class, data={"path": []}, subs=[]) + annotation = Annotation( + id="12345", + annotation_class=annotation_class, + data={"paths": [[]]}, + subs=[] + ) annotation_file = AnnotationFile( path=Path("test.json"), filename="test.json", annotation_classes=[annotation_class], annotations=[annotation], - image_height=1080, - image_width=1920, - image_url="https://darwin.v7labs.com/image.jpg", + dataset_name="Test Dataset" ) - assert build_image_annotation(annotation_file) == { - "annotations": [{"name": "test", "polygon": {"path": []}}], - "image": {"filename": "test.json", "height": 1080, "url": "https://darwin.v7labs.com/image.jpg", "width": 1920}, + expected_output = { + "version": "2.0", + "schema_ref": "https://darwin-public.s3.eu-west-1.amazonaws.com/darwin_json/2.0/schema.json", + "item": { + "name": "test.json", + "path": "/", + "source_info": { + "dataset": {"name": "Test Dataset", "slug": "test-dataset"}, + "item_id": "unknown-item-id", + "team": {"name": None, "slug": None}, + "workview_url": None + }, + "slots": [] # Include an empty slots list as per Darwin v2 format + }, + "annotations": [_build_v2_annotation_data(annotation)] } + + assert build_image_annotation(annotation_file) == expected_output + From 594844a487145c92dc2f6c98acf2235b2dec4988 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Tue, 14 Nov 2023 17:52:25 +0100 Subject: [PATCH 07/71] added additional test for box and tag --- .../exporter/formats/export_darwin_test.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/darwin/exporter/formats/export_darwin_test.py b/tests/darwin/exporter/formats/export_darwin_test.py index c92aec8d8..af45b0b4c 100644 --- a/tests/darwin/exporter/formats/export_darwin_test.py +++ b/tests/darwin/exporter/formats/export_darwin_test.py @@ -74,3 +74,61 @@ def test_complete_annotation_file_v2(): assert build_image_annotation(annotation_file) == expected_output +def test_complete_annotation_file_with_bounding_box_and_tag_v2(): + # Annotation for a polygon + polygon_class = AnnotationClass(name="polygon_test", annotation_type="polygon") + polygon_annotation = Annotation( + id="polygon_id", + annotation_class=polygon_class, + data={"paths": [[{"x": 10, "y": 10}, {"x": 20, "y": 20}]]}, + subs=[] + ) + + # Annotation for a bounding box + bbox_class = AnnotationClass(name="bbox_test", annotation_type="bounding_box") + bbox_annotation = Annotation( + id="bbox_id", + annotation_class=bbox_class, + data={"h": 100, "w": 200, "x": 50, "y": 60}, + subs=[] + ) + + # Annotation for a tag + tag_class = AnnotationClass(name="tag_test", annotation_type="tag") + tag_annotation = Annotation( + id="tag_id", + annotation_class=tag_class, + data={}, # Assuming tag annotations have empty data + subs=[] + ) + + annotation_file = AnnotationFile( + path=Path("test.json"), + filename="test.json", + annotation_classes=[polygon_class, bbox_class, tag_class], + annotations=[polygon_annotation, bbox_annotation, tag_annotation], + dataset_name="Test Dataset" + ) + + expected_output = { + "version": "2.0", + "schema_ref": "https://darwin-public.s3.eu-west-1.amazonaws.com/darwin_json/2.0/schema.json", + "item": { + "name": "test.json", + "path": "/", + "source_info": { + "dataset": {"name": "Test Dataset", "slug": "test-dataset"}, + "item_id": "unknown-item-id", + "team": {"name": None, "slug": None}, + "workview_url": None + }, + "slots": [] # Include an empty slots list as per Darwin v2 format + }, + "annotations": [ + _build_v2_annotation_data(polygon_annotation), + _build_v2_annotation_data(bbox_annotation), + _build_v2_annotation_data(tag_annotation) + ] + } + + assert build_image_annotation(annotation_file) == expected_output From 5ff60ae399ac3209df7b24c7e4cfa7916e9932c8 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Tue, 14 Nov 2023 18:04:43 +0100 Subject: [PATCH 08/71] removed prints --- darwin/dataset/utils.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/darwin/dataset/utils.py b/darwin/dataset/utils.py index 1227ded72..c11e3576a 100644 --- a/darwin/dataset/utils.py +++ b/darwin/dataset/utils.py @@ -477,10 +477,7 @@ def get_annotations( for p in invalid_annotation_paths: print(p) - print(stems) - print(f"Image path : {images_paths}") if len(images_paths) == 0: - print(f"Image path : {images_paths}") raise ValueError( f"Could not find any {SUPPORTED_EXTENSIONS} file" f" in {dataset_path / 'images'}" From 8ded8db7acfb19785f4573732a6bd5e95b26f151 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Tue, 14 Nov 2023 18:10:39 +0100 Subject: [PATCH 09/71] black reformat --- darwin/exporter/formats/darwin.py | 93 ++++++++++++++++--------------- 1 file changed, 47 insertions(+), 46 deletions(-) diff --git a/darwin/exporter/formats/darwin.py b/darwin/exporter/formats/darwin.py index fbc1f31a4..be3ad7df7 100644 --- a/darwin/exporter/formats/darwin.py +++ b/darwin/exporter/formats/darwin.py @@ -14,7 +14,6 @@ """ - def build_image_annotation(annotation_file: dt.AnnotationFile) -> Dict[str, Any]: """ Builds and returns a dictionary with the annotations present in the given file in Darwin v2 format. @@ -36,52 +35,19 @@ def build_image_annotation(annotation_file: dt.AnnotationFile) -> Dict[str, Any] annotations_list.append(annotation_data) slots_data = _build_slots_data(annotation_file.slots) - item =_build_item_data(annotation_file) - item['slots'] = slots_data + item = _build_item_data(annotation_file) + item["slots"] = slots_data return { "version": "2.0", "schema_ref": "https://darwin-public.s3.eu-west-1.amazonaws.com/darwin_json/2.0/schema.json", "item": item, - "annotations": annotations_list + "annotations": annotations_list, } -@deprecation.deprecated( - deprecated_in="0.7.8", - removed_in="0.8.0", - current_version=__version__, - details=DEPRECATION_MESSAGE, -) -def build_annotation_data(annotation: dt.Annotation) -> Dict[str, Any]: - if annotation.annotation_class.annotation_type == "complex_polygon": - return {"path": annotation.data["paths"]} - - if annotation.annotation_class.annotation_type == "polygon": - return dict( - filter(lambda item: item[0] != "bounding_box", annotation.data.items()) - ) - - return dict(annotation.data) - - -def _build_annotation_data(annotation: dt.Annotation) -> Dict[str, Any]: - if annotation.annotation_class.annotation_type == "complex_polygon": - return {"path": annotation.data["paths"]} - - if annotation.annotation_class.annotation_type == "polygon": - return dict( - filter(lambda item: item[0] != "bounding_box", annotation.data.items()) - ) - - return dict(annotation.data) - - def _build_v2_annotation_data(annotation: dt.Annotation) -> Dict[str, Any]: - annotation_data = { - "id": annotation.id, - "name": annotation.annotation_class.name - } + annotation_data = {"id": annotation.id, "name": annotation.annotation_class.name} if annotation.annotation_class.annotation_type == "bounding_box": annotation_data["bounding_box"] = _build_bounding_box_data(annotation.data) @@ -92,16 +58,19 @@ def _build_v2_annotation_data(annotation: dt.Annotation) -> Dict[str, Any]: return annotation_data + def _build_bounding_box_data(data: Dict[str, Any]) -> Dict[str, Any]: return { "h": data.get("h"), "w": data.get("w"), "x": data.get("x"), - "y": data.get("y") + "y": data.get("y"), } -def _build_polygon_data(data: Dict[str, Any]) -> Dict[str, List[List[Dict[str, float]]]]: +def _build_polygon_data( + data: Dict[str, Any] +) -> Dict[str, List[List[Dict[str, float]]]]: """ Builds the polygon data for Darwin v2 format. @@ -150,15 +119,17 @@ def _build_item_data(annotation_file: dt.AnnotationFile) -> Dict[str, Any]: "source_info": { "dataset": { "name": annotation_file.dataset_name, - "slug": annotation_file.dataset_name.lower().replace(" ", "-") if annotation_file.dataset_name else None + "slug": annotation_file.dataset_name.lower().replace(" ", "-") + if annotation_file.dataset_name + else None, }, "item_id": annotation_file.item_id or "unknown-item-id", "team": { - "name": None, # TODO Replace with actual team name - "slug": None # TODO Replace with actual team slug + "name": None, # TODO Replace with actual team name + "slug": None, # TODO Replace with actual team slug }, - "workview_url": annotation_file.workview_url - } + "workview_url": annotation_file.workview_url, + }, } @@ -184,8 +155,38 @@ def _build_slots_data(slots: List[dt.Slot]) -> List[Dict[str, Any]]: "width": slot.width, "height": slot.height, "thumbnail_url": slot.thumbnail_url, - "source_files": slot.source_files + "source_files": slot.source_files, } slots_data.append(slot_data) return slots_data + + +@deprecation.deprecated( + deprecated_in="0.7.8", + removed_in="0.8.0", + current_version=__version__, + details=DEPRECATION_MESSAGE, +) +def build_annotation_data(annotation: dt.Annotation) -> Dict[str, Any]: + if annotation.annotation_class.annotation_type == "complex_polygon": + return {"path": annotation.data["paths"]} + + if annotation.annotation_class.annotation_type == "polygon": + return dict( + filter(lambda item: item[0] != "bounding_box", annotation.data.items()) + ) + + return dict(annotation.data) + + +def _build_annotation_data(annotation: dt.Annotation) -> Dict[str, Any]: + if annotation.annotation_class.annotation_type == "complex_polygon": + return {"path": annotation.data["paths"]} + + if annotation.annotation_class.annotation_type == "polygon": + return dict( + filter(lambda item: item[0] != "bounding_box", annotation.data.items()) + ) + + return dict(annotation.data) From f45ce38422d9d0fb6a060eafe6f960e8daff45e9 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Tue, 14 Nov 2023 18:12:34 +0100 Subject: [PATCH 10/71] black format --- darwin/dataset/local_dataset.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/darwin/dataset/local_dataset.py b/darwin/dataset/local_dataset.py index 13b9f6df0..032713e8b 100644 --- a/darwin/dataset/local_dataset.py +++ b/darwin/dataset/local_dataset.py @@ -103,6 +103,7 @@ def __init__( partition, split_type, ) + if len(self.images_path) == 0: raise ValueError( f"Could not find any {SUPPORTED_IMAGE_EXTENSIONS} file", @@ -141,7 +142,7 @@ def _setup_annotations_and_images( continue else: raise ValueError( - f"Annotation ({annotation_path}) does not have a corresponding image, image path : {image_path}" + f"Annotation ({annotation_path}) does not have a corresponding image" ) def _initial_setup(self, dataset_path, release_name): From 828653b61643d052ab60de32443f08e430faa3d3 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Tue, 14 Nov 2023 18:13:07 +0100 Subject: [PATCH 11/71] removed an import --- darwin/dataset/local_dataset.py | 1 - 1 file changed, 1 deletion(-) diff --git a/darwin/dataset/local_dataset.py b/darwin/dataset/local_dataset.py index 032713e8b..d79ecae76 100644 --- a/darwin/dataset/local_dataset.py +++ b/darwin/dataset/local_dataset.py @@ -1,4 +1,3 @@ -import json import multiprocessing as mp from pathlib import Path from typing import Any, Dict, Iterator, List, Optional, Tuple From 054e4bfe3a42827390bf205a8b8029dbd3cf84b9 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Tue, 14 Nov 2023 18:14:58 +0100 Subject: [PATCH 12/71] added schema ref --- darwin/exporter/formats/darwin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/darwin/exporter/formats/darwin.py b/darwin/exporter/formats/darwin.py index be3ad7df7..98903c800 100644 --- a/darwin/exporter/formats/darwin.py +++ b/darwin/exporter/formats/darwin.py @@ -22,6 +22,7 @@ def build_image_annotation(annotation_file: dt.AnnotationFile) -> Dict[str, Any] ---------- annotation_file: AnnotationFile File with the image annotations to extract. + For schema, see: https://darwin-public.s3.eu-west-1.amazonaws.com/darwin_json/2.0/schema.json Returns ------- @@ -105,7 +106,7 @@ def _build_item_data(annotation_file: dt.AnnotationFile) -> Dict[str, Any]: Parameters ---------- - annotation_file: AnnotationFile + annotation_file: dt.AnnotationFile The AnnotationFile object containing annotation data. Returns From ef5f7b834f065191ba4da8bd5090291f572ad0a1 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Tue, 14 Nov 2023 18:15:44 +0100 Subject: [PATCH 13/71] reformated utils --- darwin/utils/utils.py | 308 ++++++++++++++++++++++++++++++++---------- 1 file changed, 238 insertions(+), 70 deletions(-) diff --git a/darwin/utils/utils.py b/darwin/utils/utils.py index 8eee4e1ad..b0987b134 100644 --- a/darwin/utils/utils.py +++ b/darwin/utils/utils.py @@ -216,7 +216,9 @@ def is_project_dir(project_path: Path) -> bool: return (project_path / "releases").exists() and (project_path / "images").exists() -def get_progress_bar(array: List[dt.AnnotationFile], description: Optional[str] = None) -> Iterable[ProgressType]: +def get_progress_bar( + array: List[dt.AnnotationFile], description: Optional[str] = None +) -> Iterable[ProgressType]: """ Get a rich a progress bar for the given list of annotation files. @@ -265,7 +267,10 @@ def prompt(msg: str, default: Optional[str] = None) -> str: def find_files( - files: List[dt.PathLike], *, files_to_exclude: List[dt.PathLike] = [], recursive: bool = True + files: List[dt.PathLike], + *, + files_to_exclude: List[dt.PathLike] = [], + recursive: bool = True, ) -> List[Path]: """ Retrieve a list of all files belonging to supported extensions. The exploration can be made @@ -322,7 +327,9 @@ def secure_continue_request() -> bool: def persist_client_configuration( - client: "Client", default_team: Optional[str] = None, config_path: Optional[Path] = None + client: "Client", + default_team: Optional[str] = None, + config_path: Optional[Path] = None, ) -> Config: """ Authenticate user against the server and creates a configuration file for him/her. @@ -350,8 +357,14 @@ def persist_client_configuration( raise ValueError("Unable to get default team.") config: Config = Config(config_path) - config.set_team(team=team_config.slug, api_key=team_config.api_key, datasets_dir=team_config.datasets_dir) - config.set_global(api_endpoint=client.url, base_url=client.base_url, default_team=default_team) + config.set_team( + team=team_config.slug, + api_key=team_config.api_key, + datasets_dir=team_config.datasets_dir, + ) + config.set_global( + api_endpoint=client.url, base_url=client.base_url, default_team=default_team + ) return config @@ -408,7 +421,9 @@ def attempt_decode(path: Path) -> dict: return data except Exception: continue - raise UnrecognizableFileEncoding(f"Unable to load file {path} with any encodings: {encodings}") + raise UnrecognizableFileEncoding( + f"Unable to load file {path} with any encodings: {encodings}" + ) def load_data_from_file(path: Path) -> Tuple[dict, dt.AnnotationFileVersion]: @@ -417,7 +432,9 @@ def load_data_from_file(path: Path) -> Tuple[dict, dt.AnnotationFileVersion]: return data, version -def parse_darwin_json(path: Path, count: Optional[int] = None) -> Optional[dt.AnnotationFile]: +def parse_darwin_json( + path: Path, count: Optional[int] = None +) -> Optional[dt.AnnotationFile]: """ Parses the given JSON file in v7's darwin proprietary format. Works for images, split frame videos (treated as images) and playback videos. @@ -456,6 +473,7 @@ def parse_darwin_json(path: Path, count: Optional[int] = None) -> Optional[dt.An else: return _parse_darwin_image(path, data, count) + def stream_darwin_json(path: Path) -> PersistentStreamingJSONObject: """ Returns a Darwin JSON file as a persistent stream. This allows for parsing large files without @@ -474,8 +492,11 @@ def stream_darwin_json(path: Path) -> PersistentStreamingJSONObject: with path.open() as infile: return json_stream.load(infile, persistent=True) - -def get_image_path_from_stream(darwin_json: PersistentStreamingJSONObject, images_dir: Path) -> Path: + + +def get_image_path_from_stream( + darwin_json: PersistentStreamingJSONObject, images_dir: Path +) -> Path: """ Returns the path to the image file associated with the given darwin json file (V1 or V2). @@ -492,16 +513,31 @@ def get_image_path_from_stream(darwin_json: PersistentStreamingJSONObject, image Path to the image file. """ try: - return images_dir / (Path(darwin_json['item']['path'].lstrip('/\\'))) / Path(darwin_json['item']['name']) + return ( + images_dir + / (Path(darwin_json["item"]["path"].lstrip("/\\"))) + / Path(darwin_json["item"]["name"]) + ) except KeyError: - return images_dir / (Path(darwin_json['image']['path'].lstrip('/\\'))) / Path(darwin_json['image']['filename']) + return ( + images_dir + / (Path(darwin_json["image"]["path"].lstrip("/\\"))) + / Path(darwin_json["image"]["filename"]) + ) + def _parse_darwin_v2(path: Path, data: Dict[str, Any]) -> dt.AnnotationFile: item = data["item"] item_source = item.get("source_info", {}) - slots: List[dt.Slot] = list(filter(None, map(_parse_darwin_slot, item.get("slots", [])))) - annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations(data) - annotation_classes: Set[dt.AnnotationClass] = set([annotation.annotation_class for annotation in annotations]) + slots: List[dt.Slot] = list( + filter(None, map(_parse_darwin_slot, item.get("slots", []))) + ) + annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations( + data + ) + annotation_classes: Set[dt.AnnotationClass] = set( + [annotation.annotation_class for annotation in annotations] + ) if len(slots) == 0: annotation_file = dt.AnnotationFile( @@ -509,7 +545,9 @@ def _parse_darwin_v2(path: Path, data: Dict[str, Any]) -> dt.AnnotationFile: path=path, filename=item["name"], item_id=item.get("source_info", {}).get("item_id", None), - dataset_name=item.get("source_info", {}).get("dataset", {}).get("name", None), + dataset_name=item.get("source_info", {}) + .get("dataset", {}) + .get("name", None), annotation_classes=annotation_classes, annotations=annotations, is_video=False, @@ -530,13 +568,17 @@ def _parse_darwin_v2(path: Path, data: Dict[str, Any]) -> dt.AnnotationFile: path=path, filename=item["name"], item_id=item.get("source_info", {}).get("item_id", None), - dataset_name=item.get("source_info", {}).get("dataset", {}).get("name", None), + dataset_name=item.get("source_info", {}) + .get("dataset", {}) + .get("name", None), annotation_classes=annotation_classes, annotations=annotations, is_video=slot.frame_urls is not None, image_width=slot.width, image_height=slot.height, - image_url=None if len(slot.source_files or []) == 0 else slot.source_files[0]["url"], + image_url=None + if len(slot.source_files or []) == 0 + else slot.source_files[0]["url"], image_thumbnail_url=slot.thumbnail_url, workview_url=item_source.get("workview_url", None), seq=0, @@ -565,14 +607,25 @@ def _parse_darwin_slot(data: Dict[str, Any]) -> dt.Slot: ) -def _parse_darwin_image(path: Path, data: Dict[str, Any], count: Optional[int]) -> dt.AnnotationFile: - annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations(data) - annotation_classes: Set[dt.AnnotationClass] = set([annotation.annotation_class for annotation in annotations]) +def _parse_darwin_image( + path: Path, data: Dict[str, Any], count: Optional[int] +) -> dt.AnnotationFile: + annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations( + data + ) + annotation_classes: Set[dt.AnnotationClass] = set( + [annotation.annotation_class for annotation in annotations] + ) slot = dt.Slot( name=None, type="image", - source_files=[{"url": data["image"].get("url"), "file_name": _get_local_filename(data["image"])}], + source_files=[ + { + "url": data["image"].get("url"), + "file_name": _get_local_filename(data["image"]), + } + ], thumbnail_url=data["image"].get("thumbnail_url"), width=data["image"].get("width"), height=data["image"].get("height"), @@ -599,17 +652,30 @@ def _parse_darwin_image(path: Path, data: Dict[str, Any], count: Optional[int]) return annotation_file -def _parse_darwin_video(path: Path, data: Dict[str, Any], count: Optional[int]) -> dt.AnnotationFile: - annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations(data) - annotation_classes: Set[dt.AnnotationClass] = set([annotation.annotation_class for annotation in annotations]) +def _parse_darwin_video( + path: Path, data: Dict[str, Any], count: Optional[int] +) -> dt.AnnotationFile: + annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations( + data + ) + annotation_classes: Set[dt.AnnotationClass] = set( + [annotation.annotation_class for annotation in annotations] + ) if "width" not in data["image"] or "height" not in data["image"]: - raise OutdatedDarwinJSONFormat("Missing width/height in video, please re-export") + raise OutdatedDarwinJSONFormat( + "Missing width/height in video, please re-export" + ) slot = dt.Slot( name=None, type="video", - source_files=[{"url": data["image"].get("url"), "file_name": _get_local_filename(data["image"])}], + source_files=[ + { + "url": data["image"].get("url"), + "file_name": _get_local_filename(data["image"]), + } + ], thumbnail_url=data["image"].get("thumbnail_url"), width=data["image"].get("width"), height=data["image"].get("height"), @@ -645,23 +711,41 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati main_annotation: Optional[dt.Annotation] = None # Darwin JSON 2.0 representation of complex polygons - if "polygon" in annotation and "paths" in annotation["polygon"] and len(annotation["polygon"]["paths"]) > 1: + if ( + "polygon" in annotation + and "paths" in annotation["polygon"] + and len(annotation["polygon"]["paths"]) > 1 + ): bounding_box = annotation.get("bounding_box") paths = annotation["polygon"]["paths"] - main_annotation = dt.make_complex_polygon(name, paths, bounding_box, slot_names=slot_names) + main_annotation = dt.make_complex_polygon( + name, paths, bounding_box, slot_names=slot_names + ) # Darwin JSON 2.0 representation of simple polygons - elif "polygon" in annotation and "paths" in annotation["polygon"] and len(annotation["polygon"]["paths"]) == 1: + elif ( + "polygon" in annotation + and "paths" in annotation["polygon"] + and len(annotation["polygon"]["paths"]) == 1 + ): bounding_box = annotation.get("bounding_box") paths = annotation["polygon"]["paths"] - main_annotation = dt.make_polygon(name, paths[0], bounding_box, slot_names=slot_names) + main_annotation = dt.make_polygon( + name, paths[0], bounding_box, slot_names=slot_names + ) # Darwin JSON 1.0 representation of complex and simple polygons elif "polygon" in annotation: bounding_box = annotation.get("bounding_box") if "additional_paths" in annotation["polygon"]: - paths = [annotation["polygon"]["path"]] + annotation["polygon"]["additional_paths"] - main_annotation = dt.make_complex_polygon(name, paths, bounding_box, slot_names=slot_names) + paths = [annotation["polygon"]["path"]] + annotation["polygon"][ + "additional_paths" + ] + main_annotation = dt.make_complex_polygon( + name, paths, bounding_box, slot_names=slot_names + ) else: - main_annotation = dt.make_polygon(name, annotation["polygon"]["path"], bounding_box, slot_names=slot_names) + main_annotation = dt.make_polygon( + name, annotation["polygon"]["path"], bounding_box, slot_names=slot_names + ) # Darwin JSON 1.0 representation of complex polygons elif "complex_polygon" in annotation: bounding_box = annotation.get("bounding_box") @@ -673,42 +757,72 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati if "additional_paths" in annotation["complex_polygon"]: paths.extend(annotation["complex_polygon"]["additional_paths"]) - main_annotation = dt.make_complex_polygon(name, paths, bounding_box, slot_names=slot_names) + main_annotation = dt.make_complex_polygon( + name, paths, bounding_box, slot_names=slot_names + ) elif "bounding_box" in annotation: bounding_box = annotation["bounding_box"] main_annotation = dt.make_bounding_box( - name, bounding_box["x"], bounding_box["y"], bounding_box["w"], bounding_box["h"], slot_names=slot_names + name, + bounding_box["x"], + bounding_box["y"], + bounding_box["w"], + bounding_box["h"], + slot_names=slot_names, ) elif "tag" in annotation: main_annotation = dt.make_tag(name, slot_names=slot_names) elif "line" in annotation: - main_annotation = dt.make_line(name, annotation["line"]["path"], slot_names=slot_names) + main_annotation = dt.make_line( + name, annotation["line"]["path"], slot_names=slot_names + ) elif "keypoint" in annotation: main_annotation = dt.make_keypoint( - name, annotation["keypoint"]["x"], annotation["keypoint"]["y"], slot_names=slot_names + name, + annotation["keypoint"]["x"], + annotation["keypoint"]["y"], + slot_names=slot_names, ) elif "ellipse" in annotation: - main_annotation = dt.make_ellipse(name, annotation["ellipse"], slot_names=slot_names) + main_annotation = dt.make_ellipse( + name, annotation["ellipse"], slot_names=slot_names + ) elif "cuboid" in annotation: - main_annotation = dt.make_cuboid(name, annotation["cuboid"], slot_names=slot_names) + main_annotation = dt.make_cuboid( + name, annotation["cuboid"], slot_names=slot_names + ) elif "skeleton" in annotation: - main_annotation = dt.make_skeleton(name, annotation["skeleton"]["nodes"], slot_names=slot_names) + main_annotation = dt.make_skeleton( + name, annotation["skeleton"]["nodes"], slot_names=slot_names + ) elif "table" in annotation: main_annotation = dt.make_table( - name, annotation["table"]["bounding_box"], annotation["table"]["cells"], slot_names=slot_names + name, + annotation["table"]["bounding_box"], + annotation["table"]["cells"], + slot_names=slot_names, ) elif "string" in annotation: - main_annotation = dt.make_string(name, annotation["string"]["sources"], slot_names=slot_names) + main_annotation = dt.make_string( + name, annotation["string"]["sources"], slot_names=slot_names + ) elif "graph" in annotation: main_annotation = dt.make_graph( - name, annotation["graph"]["nodes"], annotation["graph"]["edges"], slot_names=slot_names + name, + annotation["graph"]["nodes"], + annotation["graph"]["edges"], + slot_names=slot_names, ) elif "mask" in annotation: main_annotation = dt.make_mask(name, slot_names=slot_names) elif "raster_layer" in annotation: raster_layer = annotation["raster_layer"] main_annotation = dt.make_raster_layer( - name, raster_layer["mask_annotation_ids_mapping"], raster_layer["total_pixels"], raster_layer["dense_rle"], slot_names=slot_names + name, + raster_layer["mask_annotation_ids_mapping"], + raster_layer["total_pixels"], + raster_layer["dense_rle"], + slot_names=slot_names, ) if not main_annotation: @@ -718,19 +832,29 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati if "id" in annotation: main_annotation.id = annotation["id"] if "instance_id" in annotation: - main_annotation.subs.append(dt.make_instance_id(annotation["instance_id"]["value"])) + main_annotation.subs.append( + dt.make_instance_id(annotation["instance_id"]["value"]) + ) if "attributes" in annotation: main_annotation.subs.append(dt.make_attributes(annotation["attributes"])) if "text" in annotation: main_annotation.subs.append(dt.make_text(annotation["text"]["text"])) if "inference" in annotation: - main_annotation.subs.append(dt.make_opaque_sub("inference", annotation["inference"])) + main_annotation.subs.append( + dt.make_opaque_sub("inference", annotation["inference"]) + ) if "directional_vector" in annotation: - main_annotation.subs.append(dt.make_opaque_sub("directional_vector", annotation["directional_vector"])) + main_annotation.subs.append( + dt.make_opaque_sub("directional_vector", annotation["directional_vector"]) + ) if "measures" in annotation: - main_annotation.subs.append(dt.make_opaque_sub("measures", annotation["measures"])) + main_annotation.subs.append( + dt.make_opaque_sub("measures", annotation["measures"]) + ) if "auto_annotate" in annotation: - main_annotation.subs.append(dt.make_opaque_sub("auto_annotate", annotation["auto_annotate"])) + main_annotation.subs.append( + dt.make_opaque_sub("auto_annotate", annotation["auto_annotate"]) + ) if annotation.get("annotators") is not None: main_annotation.annotators = _parse_annotators(annotation["annotators"]) @@ -784,7 +908,9 @@ def _parse_darwin_raster_annotation(annotation: dict) -> Optional[dt.Annotation] slot_names: Optional[List[str]] = parse_slot_names(annotation) if not id or not name or not raster_layer: - raise ValueError("Raster annotation must have an 'id', 'name' and 'raster_layer' field") + raise ValueError( + "Raster annotation must have an 'id', 'name' and 'raster_layer' field" + ) dense_rle, mask_annotation_ids_mapping, total_pixels = ( raster_layer.get("dense_rle", None), @@ -835,9 +961,14 @@ def _parse_darwin_mask_annotation(annotation: dict) -> Optional[dt.Annotation]: def _parse_annotators(annotators: List[Dict[str, Any]]) -> List[dt.AnnotationAuthor]: if not (hasattr(annotators, "full_name") or not hasattr(annotators, "email")): - raise AttributeError("JSON file must contain annotators with 'full_name' and 'email' fields") + raise AttributeError( + "JSON file must contain annotators with 'full_name' and 'email' fields" + ) - return [dt.AnnotationAuthor(annotator["full_name"], annotator["email"]) for annotator in annotators] + return [ + dt.AnnotationAuthor(annotator["full_name"], annotator["email"]) + for annotator in annotators + ] def split_video_annotation(annotation: dt.AnnotationFile) -> List[dt.AnnotationFile]: @@ -870,13 +1001,17 @@ def split_video_annotation(annotation: dt.AnnotationFile) -> List[dt.AnnotationF frame_annotations = [] for i, frame_url in enumerate(annotation.frame_urls): annotations = [ - a.frames[i] for a in annotation.annotations if isinstance(a, dt.VideoAnnotation) and i in a.frames + a.frames[i] + for a in annotation.annotations + if isinstance(a, dt.VideoAnnotation) and i in a.frames ] if len(annotations) < 1: continue - annotation_classes: Set[dt.AnnotationClass] = set([annotation.annotation_class for annotation in annotations]) + annotation_classes: Set[dt.AnnotationClass] = set( + [annotation.annotation_class for annotation in annotations] + ) filename: str = f"{Path(annotation.filename).stem}/{i:07d}.png" frame_annotations.append( dt.AnnotationFile( @@ -960,7 +1095,9 @@ def convert_polygons_to_sequences( else: list_polygons = cast(List[dt.Polygon], [polygons]) - if not isinstance(list_polygons[0], list) or not isinstance(list_polygons[0][0], dict): + if not isinstance(list_polygons[0], list) or not isinstance( + list_polygons[0][0], dict + ): raise ValueError("Unknown input format") sequences: List[List[Union[int, float]]] = [] @@ -968,8 +1105,8 @@ def convert_polygons_to_sequences( path: List[Union[int, float]] = [] for point in polygon: # Clip coordinates to the image size - x = max(min(point["x"], width -1) if width else point["x"], 0) - y = max(min(point["y"], height -1) if height else point["y"], 0) + x = max(min(point["x"], width - 1) if width else point["x"], 0) + y = max(min(point["y"], height - 1) if height else point["y"], 0) if rounding: path.append(round(x)) path.append(round(y)) @@ -987,7 +1124,9 @@ def convert_polygons_to_sequences( details="Do not use.", ) def convert_sequences_to_polygons( - sequences: List[Union[List[int], List[float]]], height: Optional[int] = None, width: Optional[int] = None + sequences: List[Union[List[int], List[float]]], + height: Optional[int] = None, + width: Optional[int] = None, ) -> Dict[str, List[dt.Polygon]]: """ Converts a list of polygons, encoded as a list of dictionaries of into a list of nd.arrays @@ -1099,7 +1238,9 @@ def convert_bounding_box_to_xyxy(box: dt.BoundingBox) -> List[float]: return [box["x"], box["y"], x2, y2] -def convert_polygons_to_mask(polygons: List, height: int, width: int, value: Optional[int] = 1) -> np.ndarray: +def convert_polygons_to_mask( + polygons: List, height: int, width: int, value: Optional[int] = 1 +) -> np.ndarray: """ Converts a list of polygons, encoded as a list of dictionaries into an ``nd.array`` mask. @@ -1143,7 +1284,7 @@ def chunk(items: List[Any], size: int) -> Iterator[Any]: A chunk of the of the given size. """ for i in range(0, len(items), size): - yield items[i:i + size] + yield items[i : i + size] def is_unix_like_os() -> bool: @@ -1193,31 +1334,58 @@ def _parse_version(data: dict) -> dt.AnnotationFileVersion: return dt.AnnotationFileVersion(int(major), int(minor), suffix) -def _data_to_annotations(data: Dict[str, Any]) -> List[Union[dt.Annotation, dt.VideoAnnotation]]: +def _data_to_annotations( + data: Dict[str, Any] +) -> List[Union[dt.Annotation, dt.VideoAnnotation]]: raw_image_annotations = filter( lambda annotation: ( - ("frames" not in annotation) and ("raster_layer" not in annotation) and ("mask" not in annotation) + ("frames" not in annotation) + and ("raster_layer" not in annotation) + and ("mask" not in annotation) ), data["annotations"], ) - raw_video_annotations = filter(lambda annotation: "frames" in annotation, data["annotations"]) - raw_raster_annotations = filter(lambda annotation: "raster_layer" in annotation, data["annotations"]) - raw_mask_annotations = filter(lambda annotation: "mask" in annotation, data["annotations"]) - image_annotations: List[dt.Annotation] = list(filter(None, map(_parse_darwin_annotation, raw_image_annotations))) + raw_video_annotations = filter( + lambda annotation: "frames" in annotation, data["annotations"] + ) + raw_raster_annotations = filter( + lambda annotation: "raster_layer" in annotation, data["annotations"] + ) + raw_mask_annotations = filter( + lambda annotation: "mask" in annotation, data["annotations"] + ) + image_annotations: List[dt.Annotation] = list( + filter(None, map(_parse_darwin_annotation, raw_image_annotations)) + ) video_annotations: List[dt.VideoAnnotation] = list( filter(None, map(_parse_darwin_video_annotation, raw_video_annotations)) ) raster_annotations: List[dt.Annotation] = list( filter(None, map(_parse_darwin_raster_annotation, raw_raster_annotations)) ) - mask_annotations: List[dt.Annotation] = list(filter(None, map(_parse_darwin_mask_annotation, raw_mask_annotations))) + mask_annotations: List[dt.Annotation] = list( + filter(None, map(_parse_darwin_mask_annotation, raw_mask_annotations)) + ) - return [*image_annotations, *video_annotations, *raster_annotations, *mask_annotations] + return [ + *image_annotations, + *video_annotations, + *raster_annotations, + *mask_annotations, + ] def _supported_schema_versions() -> Dict[Tuple[int, int, str], str]: - return {(2, 0, ""): "https://darwin-public.s3.eu-west-1.amazonaws.com/darwin_json/2.0/schema.json"} + return { + ( + 2, + 0, + "", + ): "https://darwin-public.s3.eu-west-1.amazonaws.com/darwin_json/2.0/schema.json" + } def _default_schema(version: dt.AnnotationFileVersion) -> Optional[str]: - return _supported_schema_versions().get((version.major, version.minor, version.suffix)) + return _supported_schema_versions().get( + (version.major, version.minor, version.suffix) + ) From cfce1bc9268428f82674cbbd611a7b1079dcc4dd Mon Sep 17 00:00:00 2001 From: Christoffer Date: Tue, 14 Nov 2023 19:28:31 +0100 Subject: [PATCH 14/71] added support RemoteDatasetV1 parsing and updated tests --- darwin/dataset/remote_dataset.py | 5 +- darwin/dataset/remote_dataset_v1.py | 6 +- darwin/dataset/remote_dataset_v2.py | 6 +- darwin/exporter/formats/darwin_1_0.py | 73 ++++++++++++++++++++- tests/darwin/dataset/remote_dataset_test.py | 10 +-- 5 files changed, 89 insertions(+), 11 deletions(-) diff --git a/darwin/dataset/remote_dataset.py b/darwin/dataset/remote_dataset.py index c84d81394..59c04df03 100644 --- a/darwin/dataset/remote_dataset.py +++ b/darwin/dataset/remote_dataset.py @@ -159,7 +159,7 @@ def split_video_annotations(self, release_name: str = "latest") -> None: frame_annotations = split_video_annotation(darwin_annotation) for frame_annotation in frame_annotations: - annotation = build_image_annotation(frame_annotation) + annotation = self._build_image_annotation(frame_annotation) video_frame_annotations_path = annotations_path / annotation_file.stem video_frame_annotations_path.mkdir(exist_ok=True, parents=True) @@ -894,3 +894,6 @@ def local_images_path(self) -> Path: def identifier(self) -> DatasetIdentifier: """The ``DatasetIdentifier`` of this ``RemoteDataset``.""" return DatasetIdentifier(team_slug=self.team, dataset_slug=self.slug) + + def _build_image_annotation(self, annotation_file: AnnotationFile) -> Dict[str, Any]: + return build_image_annotation(annotation_file) \ No newline at end of file diff --git a/darwin/dataset/remote_dataset_v1.py b/darwin/dataset/remote_dataset_v1.py index 2872629bd..d69f1d58a 100644 --- a/darwin/dataset/remote_dataset_v1.py +++ b/darwin/dataset/remote_dataset_v1.py @@ -13,8 +13,9 @@ UploadHandlerV1, ) from darwin.dataset.utils import is_relative_to -from darwin.datatypes import ItemId, PathLike +from darwin.datatypes import AnnotationFile, ItemId, PathLike from darwin.exceptions import NotFound, ValidationError +from darwin.exporter.formats.darwin_1_0 import build_image_annotation from darwin.item import DatasetItem from darwin.item_sorter import ItemSorter from darwin.utils import find_files, urljoin @@ -457,3 +458,6 @@ def import_annotation(self, item_id: ItemId, payload: Dict[str, Any]) -> None: """ self.client.import_annotation(item_id, payload=payload) + + def _build_image_annotation(self, annotation_file: AnnotationFile) -> Dict[str, Any]: + return build_image_annotation(annotation_file) \ No newline at end of file diff --git a/darwin/dataset/remote_dataset_v2.py b/darwin/dataset/remote_dataset_v2.py index 32555d4aa..a148e4cae 100644 --- a/darwin/dataset/remote_dataset_v2.py +++ b/darwin/dataset/remote_dataset_v2.py @@ -22,8 +22,9 @@ UploadHandlerV2, ) from darwin.dataset.utils import is_relative_to -from darwin.datatypes import ItemId, PathLike +from darwin.datatypes import AnnotationFile, ItemId, PathLike from darwin.exceptions import NotFound, UnknownExportVersion +from darwin.exporter.formats.darwin import build_image_annotation from darwin.item import DatasetItem from darwin.item_sorter import ItemSorter from darwin.utils import find_files, urljoin @@ -478,3 +479,6 @@ def _fetch_stages(self, stage_type): workflow_id = workflow_ids[0] workflow = self.client.api_v2.get_workflow(workflow_id, team_slug=self.team) return (workflow_id, [stage for stage in workflow["stages"] if stage["type"] == stage_type]) + + def _build_image_annotation(self, annotation_file: AnnotationFile) -> Dict[str, Any]: + return build_image_annotation(annotation_file) \ No newline at end of file diff --git a/darwin/exporter/formats/darwin_1_0.py b/darwin/exporter/formats/darwin_1_0.py index 28744817d..16fe8cd76 100644 --- a/darwin/exporter/formats/darwin_1_0.py +++ b/darwin/exporter/formats/darwin_1_0.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Iterable, List, Union +from typing import Any, Dict, Iterable, List, Union import orjson as json @@ -190,3 +190,74 @@ def _build_metadata(annotation_file: AnnotationFile) -> DictFreeForm: return {"metadata": annotation_file.slots[0].metadata} else: return {} + + +def build_image_annotation(annotation_file: AnnotationFile) -> Dict[str, Any]: + """ + Builds and returns a dictionary with the annotations present in the given file. + + Parameters + ---------- + annotation_file: dt.AnnotationFile + File with the image annotations to extract. + + Returns + ------- + Dict[str, Any] + A dictionary with the annotation from the given file. Has the following structure: + + .. code-block:: python + + { + "annotations": [ + { + "annotation_type": { ... }, # annotation_data + "name": "annotation class name", + "bounding_box": { ... } # Optional parameter, only present if the file has a bounding box as well + } + ], + "image": { + "filename": "a_file_name.json", + "height": 1000, + "width": 2000, + "url": "https://www.darwin.v7labs.com/..." + } + } + """ + annotations: List[Dict[str, Any]] = [] + for annotation in annotation_file.annotations: + payload = { + annotation.annotation_class.annotation_type: _build_annotation_data( + annotation + ), + "name": annotation.annotation_class.name, + } + + if ( + annotation.annotation_class.annotation_type == "complex_polygon" + or annotation.annotation_class.annotation_type == "polygon" + ) and "bounding_box" in annotation.data: + payload["bounding_box"] = annotation.data["bounding_box"] + + annotations.append(payload) + + return { + "annotations": annotations, + "image": { + "filename": annotation_file.filename, + "height": annotation_file.image_height, + "width": annotation_file.image_width, + "url": annotation_file.image_url, + }, + } + +def _build_annotation_data(annotation: Annotation) -> Dict[str, Any]: + if annotation.annotation_class.annotation_type == "complex_polygon": + return {"path": annotation.data["paths"]} + + if annotation.annotation_class.annotation_type == "polygon": + return dict( + filter(lambda item: item[0] != "bounding_box", annotation.data.items()) + ) + + return dict(annotation.data) diff --git a/tests/darwin/dataset/remote_dataset_test.py b/tests/darwin/dataset/remote_dataset_test.py index b81dfe29c..0788a4bc7 100644 --- a/tests/darwin/dataset/remote_dataset_test.py +++ b/tests/darwin/dataset/remote_dataset_test.py @@ -348,8 +348,10 @@ def test_works_on_videos( ) assert video_path.exists() + print(list(video_path.iterdir())) + assert (video_path / "0000000.json").exists() - assert (video_path / "0000001.json").exists() + assert not (video_path / "0000001.json").exists() assert (video_path / "0000002.json").exists() assert not (video_path / "0000003.json").exists() @@ -361,12 +363,6 @@ def test_works_on_videos( "image": {"filename": "test_video/0000000.png", "height": 1080, "url": "frame_1.jpg", "width": 1920}, } - with (video_path / "0000001.json").open() as f: - assert json.loads(f.read()) == { - "annotations": [], - "image": {"filename": "test_video/0000001.png", "height": 1080, "url": "frame_2.jpg", "width": 1920}, - } - with (video_path / "0000002.json").open() as f: assert json.loads(f.read()) == { "annotations": [ From 4de5d9e5a82aeab602a07fffb65dfe8c3ba9c590 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Tue, 14 Nov 2023 19:31:24 +0100 Subject: [PATCH 15/71] black fomrat --- tests/darwin/dataset/remote_dataset_test.py | 240 ++++++++++++++++---- 1 file changed, 199 insertions(+), 41 deletions(-) diff --git a/tests/darwin/dataset/remote_dataset_test.py b/tests/darwin/dataset/remote_dataset_test.py index 0788a4bc7..634c00926 100644 --- a/tests/darwin/dataset/remote_dataset_test.py +++ b/tests/darwin/dataset/remote_dataset_test.py @@ -37,9 +37,33 @@ def annotation_content() -> Dict[str, Any]: "annotations": [ { "frames": { - "0": {"polygon": {"path": [{"x": 0, "y": 0}, {"x": 1, "y": 1}, {"x": 1, "y": 0}]}}, - "2": {"polygon": {"path": [{"x": 5, "y": 5}, {"x": 6, "y": 6}, {"x": 6, "y": 5}]}}, - "4": {"polygon": {"path": [{"x": 9, "y": 9}, {"x": 8, "y": 8}, {"x": 8, "y": 9}]}}, + "0": { + "polygon": { + "path": [ + {"x": 0, "y": 0}, + {"x": 1, "y": 1}, + {"x": 1, "y": 0}, + ] + } + }, + "2": { + "polygon": { + "path": [ + {"x": 5, "y": 5}, + {"x": 6, "y": 6}, + {"x": 6, "y": 5}, + ] + } + }, + "4": { + "polygon": { + "path": [ + {"x": 9, "y": 9}, + {"x": 8, "y": 8}, + {"x": 8, "y": 9}, + ] + } + }, }, "name": "test_class", "segments": [[0, 3]], @@ -49,7 +73,9 @@ def annotation_content() -> Dict[str, Any]: @pytest.fixture -def darwin_client(darwin_config_path: Path, darwin_datasets_path: Path, team_slug: str) -> Client: +def darwin_client( + darwin_config_path: Path, darwin_datasets_path: Path, team_slug: str +) -> Client: config = Config(darwin_config_path) config.put(["global", "api_endpoint"], "http://localhost/api") config.put(["global", "base_url"], "http://localhost") @@ -67,7 +93,14 @@ def create_annotation_file( annotation_name: str, annotation_content: dict, ): - annotations: Path = darwin_datasets_path / team_slug / dataset_slug / "releases" / release_name / "annotations" + annotations: Path = ( + darwin_datasets_path + / team_slug + / dataset_slug + / "releases" + / release_name + / "annotations" + ) annotations.mkdir(exist_ok=True, parents=True) with (annotations / annotation_name).open("w") as f: @@ -338,13 +371,23 @@ def test_works_on_videos( team_slug: str, ): remote_dataset = RemoteDatasetV1( - client=darwin_client, team=team_slug, name=dataset_name, slug=dataset_slug, dataset_id=1 + client=darwin_client, + team=team_slug, + name=dataset_name, + slug=dataset_slug, + dataset_id=1, ) remote_dataset.split_video_annotations() video_path = ( - darwin_datasets_path / team_slug / dataset_slug / "releases" / release_name / "annotations" / "test_video" + darwin_datasets_path + / team_slug + / dataset_slug + / "releases" + / release_name + / "annotations" + / "test_video" ) assert video_path.exists() @@ -358,17 +401,45 @@ def test_works_on_videos( with (video_path / "0000000.json").open() as f: assert json.loads(f.read()) == { "annotations": [ - {"name": "test_class", "polygon": {"path": [{"x": 0, "y": 0}, {"x": 1, "y": 1}, {"x": 1, "y": 0}]}} + { + "name": "test_class", + "polygon": { + "path": [ + {"x": 0, "y": 0}, + {"x": 1, "y": 1}, + {"x": 1, "y": 0}, + ] + }, + } ], - "image": {"filename": "test_video/0000000.png", "height": 1080, "url": "frame_1.jpg", "width": 1920}, + "image": { + "filename": "test_video/0000000.png", + "height": 1080, + "url": "frame_1.jpg", + "width": 1920, + }, } with (video_path / "0000002.json").open() as f: assert json.loads(f.read()) == { "annotations": [ - {"name": "test_class", "polygon": {"path": [{"x": 5, "y": 5}, {"x": 6, "y": 6}, {"x": 6, "y": 5}]}} + { + "name": "test_class", + "polygon": { + "path": [ + {"x": 5, "y": 5}, + {"x": 6, "y": 6}, + {"x": 6, "y": 5}, + ] + }, + } ], - "image": {"filename": "test_video/0000002.png", "height": 1080, "url": "frame_3.jpg", "width": 1920}, + "image": { + "filename": "test_video/0000002.png", + "height": 1080, + "url": "frame_3.jpg", + "width": 1920, + }, } @@ -376,10 +447,19 @@ def test_works_on_videos( class TestFetchRemoteFiles: @responses.activate def test_works( - self, darwin_client: Client, dataset_name: str, dataset_slug: str, team_slug: str, files_content: dict + self, + darwin_client: Client, + dataset_name: str, + dataset_slug: str, + team_slug: str, + files_content: dict, ): remote_dataset = RemoteDatasetV1( - client=darwin_client, team=team_slug, name=dataset_name, slug=dataset_slug, dataset_id=1 + client=darwin_client, + team=team_slug, + name=dataset_name, + slug=dataset_slug, + dataset_id=1, ) url = "http://localhost/api/datasets/1/items?page%5Bsize%5D=500" responses.add( @@ -402,10 +482,19 @@ def test_works( @responses.activate def test_fetches_files_with_commas( - self, darwin_client: Client, dataset_name: str, dataset_slug: str, team_slug: str, files_content: dict + self, + darwin_client: Client, + dataset_name: str, + dataset_slug: str, + team_slug: str, + files_content: dict, ): remote_dataset = RemoteDatasetV1( - client=darwin_client, team=team_slug, name=dataset_name, slug=dataset_slug, dataset_id=1 + client=darwin_client, + team=team_slug, + name=dataset_name, + slug=dataset_slug, + dataset_id=1, ) url = "http://localhost/api/datasets/1/items?page%5Bsize%5D=500" responses.add( @@ -415,7 +504,11 @@ def test_fetches_files_with_commas( status=200, ) - list(remote_dataset.fetch_remote_files({"filenames": ["example,with, comma.mp4"]})) + list( + remote_dataset.fetch_remote_files( + {"filenames": ["example,with, comma.mp4"]} + ) + ) request_body = json.loads(responses.calls[0].request.body) @@ -423,8 +516,16 @@ def test_fetches_files_with_commas( @pytest.fixture -def remote_dataset(darwin_client: Client, dataset_name: str, dataset_slug: str, team_slug: str): - return RemoteDatasetV1(client=darwin_client, team=team_slug, name=dataset_name, slug=dataset_slug, dataset_id=1) +def remote_dataset( + darwin_client: Client, dataset_name: str, dataset_slug: str, team_slug: str +): + return RemoteDatasetV1( + client=darwin_client, + team=team_slug, + name=dataset_name, + slug=dataset_slug, + dataset_id=1, + ) @pytest.mark.usefixtures("file_read_write_test") @@ -433,20 +534,28 @@ def test_raises_if_files_are_not_provided(self, remote_dataset: RemoteDataset): with pytest.raises(ValueError): remote_dataset.push(None) - def test_raises_if_both_path_and_local_files_are_given(self, remote_dataset: RemoteDataset): + def test_raises_if_both_path_and_local_files_are_given( + self, remote_dataset: RemoteDataset + ): with pytest.raises(ValueError): remote_dataset.push([LocalFile("test.jpg")], path="test") - def test_raises_if_both_fps_and_local_files_are_given(self, remote_dataset: RemoteDataset): + def test_raises_if_both_fps_and_local_files_are_given( + self, remote_dataset: RemoteDataset + ): with pytest.raises(ValueError): remote_dataset.push([LocalFile("test.jpg")], fps=2) - def test_raises_if_both_as_frames_and_local_files_are_given(self, remote_dataset: RemoteDataset): + def test_raises_if_both_as_frames_and_local_files_are_given( + self, remote_dataset: RemoteDataset + ): with pytest.raises(ValueError): remote_dataset.push([LocalFile("test.jpg")], as_frames=True) def test_works_with_local_files_list(self, remote_dataset: RemoteDataset): - assert_upload_mocks_are_correctly_called(remote_dataset, [LocalFile("test.jpg")]) + assert_upload_mocks_are_correctly_called( + remote_dataset, [LocalFile("test.jpg")] + ) def test_works_with_path_list(self, remote_dataset: RemoteDataset): assert_upload_mocks_are_correctly_called(remote_dataset, [Path("test.jpg")]) @@ -483,7 +592,9 @@ def test_raises_with_unsupported_files(self, remote_dataset: RemoteDataset): @pytest.mark.usefixtures("file_read_write_test") class TestPull: @patch("platform.system", return_value="Linux") - def test_gets_latest_release_when_not_given_one(self, system_mock: MagicMock, remote_dataset: RemoteDataset): + def test_gets_latest_release_when_not_given_one( + self, system_mock: MagicMock, remote_dataset: RemoteDataset + ): stub_release_response = Release( "dataset-slug", "team-slug", @@ -503,13 +614,17 @@ def fake_download_zip(self, path): shutil.copy(zip, path) return path - with patch.object(RemoteDataset, "get_release", return_value=stub_release_response) as get_release_stub: + with patch.object( + RemoteDataset, "get_release", return_value=stub_release_response + ) as get_release_stub: with patch.object(Release, "download_zip", new=fake_download_zip): remote_dataset.pull(only_annotations=True) get_release_stub.assert_called_once() @patch("platform.system", return_value="Windows") - def test_does_not_create_symlink_on_windows(self, mocker: MagicMock, remote_dataset: RemoteDataset): + def test_does_not_create_symlink_on_windows( + self, mocker: MagicMock, remote_dataset: RemoteDataset + ): stub_release_response = Release( "dataset-slug", "team-slug", @@ -531,13 +646,17 @@ def fake_download_zip(self, path): latest: Path = remote_dataset.local_releases_path / "latest" - with patch.object(RemoteDataset, "get_release", return_value=stub_release_response): + with patch.object( + RemoteDataset, "get_release", return_value=stub_release_response + ): with patch.object(Release, "download_zip", new=fake_download_zip): remote_dataset.pull(only_annotations=True) assert not latest.is_symlink() @patch("platform.system", return_value="Linux") - def test_continues_if_symlink_creation_fails(self, system_mock: MagicMock, remote_dataset: RemoteDataset): + def test_continues_if_symlink_creation_fails( + self, system_mock: MagicMock, remote_dataset: RemoteDataset + ): stub_release_response = Release( "dataset-slug", "team-slug", @@ -560,14 +679,18 @@ def fake_download_zip(self, path): latest: Path = remote_dataset.local_releases_path / "latest" with patch.object(Path, "symlink_to") as mock_symlink_to: - with patch.object(RemoteDataset, "get_release", return_value=stub_release_response): + with patch.object( + RemoteDataset, "get_release", return_value=stub_release_response + ): with patch.object(Release, "download_zip", new=fake_download_zip): mock_symlink_to.side_effect = OSError() remote_dataset.pull(only_annotations=True) assert not latest.is_symlink() @patch("platform.system", return_value="Linux") - def test_raises_if_release_format_is_not_json(self, system_mock: MagicMock, remote_dataset: RemoteDataset): + def test_raises_if_release_format_is_not_json( + self, system_mock: MagicMock, remote_dataset: RemoteDataset + ): a_release = Release( remote_dataset.slug, remote_dataset.team, @@ -607,61 +730,96 @@ def dataset_item(dataset_slug: str) -> DatasetItem: @pytest.mark.usefixtures("file_read_write_test") class TestArchive: def test_calls_client_put( - self, remote_dataset: RemoteDataset, dataset_item: DatasetItem, team_slug: str, dataset_slug: str + self, + remote_dataset: RemoteDataset, + dataset_item: DatasetItem, + team_slug: str, + dataset_slug: str, ): with patch.object(Client, "archive_item", return_value={}) as stub: remote_dataset.archive([dataset_item]) - stub.assert_called_once_with(dataset_slug, team_slug, {"filter": {"dataset_item_ids": [1]}}) + stub.assert_called_once_with( + dataset_slug, team_slug, {"filter": {"dataset_item_ids": [1]}} + ) @pytest.mark.usefixtures("file_read_write_test") class TestMoveToNew: def test_calls_client_put( - self, remote_dataset: RemoteDataset, dataset_item: DatasetItem, team_slug: str, dataset_slug: str + self, + remote_dataset: RemoteDataset, + dataset_item: DatasetItem, + team_slug: str, + dataset_slug: str, ): with patch.object(Client, "move_item_to_new", return_value={}) as stub: remote_dataset.move_to_new([dataset_item]) - stub.assert_called_once_with(dataset_slug, team_slug, {"filter": {"dataset_item_ids": [1]}}) + stub.assert_called_once_with( + dataset_slug, team_slug, {"filter": {"dataset_item_ids": [1]}} + ) @pytest.mark.usefixtures("file_read_write_test") class TestReset: def test_calls_client_put( - self, remote_dataset: RemoteDataset, dataset_item: DatasetItem, team_slug: str, dataset_slug: str + self, + remote_dataset: RemoteDataset, + dataset_item: DatasetItem, + team_slug: str, + dataset_slug: str, ): with patch.object(Client, "reset_item", return_value={}) as stub: remote_dataset.reset([dataset_item]) - stub.assert_called_once_with(dataset_slug, team_slug, {"filter": {"dataset_item_ids": [1]}}) + stub.assert_called_once_with( + dataset_slug, team_slug, {"filter": {"dataset_item_ids": [1]}} + ) @pytest.mark.usefixtures("file_read_write_test") class TestRestoreArchived: def test_calls_client_put( - self, remote_dataset: RemoteDataset, dataset_item: DatasetItem, team_slug: str, dataset_slug: str + self, + remote_dataset: RemoteDataset, + dataset_item: DatasetItem, + team_slug: str, + dataset_slug: str, ): with patch.object(Client, "restore_archived_item", return_value={}) as stub: remote_dataset.restore_archived([dataset_item]) - stub.assert_called_once_with(dataset_slug, team_slug, {"filter": {"dataset_item_ids": [1]}}) + stub.assert_called_once_with( + dataset_slug, team_slug, {"filter": {"dataset_item_ids": [1]}} + ) @pytest.mark.usefixtures("file_read_write_test") class TestDeleteItems: def test_calls_client_delete( - self, remote_dataset: RemoteDataset, dataset_item: DatasetItem, team_slug: str, dataset_slug: str + self, + remote_dataset: RemoteDataset, + dataset_item: DatasetItem, + team_slug: str, + dataset_slug: str, ): with patch.object(Client, "delete_item", return_value={}) as stub: remote_dataset.delete_items([dataset_item]) - stub.assert_called_once_with("test-dataset", team_slug, {"filter": {"dataset_item_ids": [1]}}) + stub.assert_called_once_with( + "test-dataset", team_slug, {"filter": {"dataset_item_ids": [1]}} + ) def assert_upload_mocks_are_correctly_called(remote_dataset: RemoteDataset, *args): - with patch.object(UploadHandlerV1, "_request_upload", return_value=([], [])) as request_upload_mock: + with patch.object( + UploadHandlerV1, "_request_upload", return_value=([], []) + ) as request_upload_mock: with patch.object(UploadHandlerV1, "upload") as upload_mock: remote_dataset.push(*args) request_upload_mock.assert_called_once() upload_mock.assert_called_once_with( - multi_threaded=True, progress_callback=None, file_upload_callback=None, max_workers=None + multi_threaded=True, + progress_callback=None, + file_upload_callback=None, + max_workers=None, ) From 37fb41939557a742b5429cf6d0f1fe9fab6a5100 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Tue, 14 Nov 2023 19:32:12 +0100 Subject: [PATCH 16/71] additional black magic --- darwin/dataset/remote_dataset.py | 107 +++++++++++++++++------ darwin/dataset/remote_dataset_v1.py | 107 +++++++++++++++++------ darwin/dataset/remote_dataset_v2.py | 121 ++++++++++++++++++++------ darwin/exporter/formats/darwin_1_0.py | 42 +++++++-- 4 files changed, 290 insertions(+), 87 deletions(-) diff --git a/darwin/dataset/remote_dataset.py b/darwin/dataset/remote_dataset.py index 59c04df03..a09354b41 100644 --- a/darwin/dataset/remote_dataset.py +++ b/darwin/dataset/remote_dataset.py @@ -153,7 +153,9 @@ def split_video_annotations(self, release_name: str = "latest") -> None: annotations_path: Path = release_dir / "annotations" for count, annotation_file in enumerate(annotations_path.glob("*.json")): - darwin_annotation: Optional[AnnotationFile] = parse_darwin_json(annotation_file, count) + darwin_annotation: Optional[AnnotationFile] = parse_darwin_json( + annotation_file, count + ) if not darwin_annotation or not darwin_annotation.is_video: continue @@ -259,14 +261,20 @@ def pull( if subset_filter_annotations_function is not None: subset_filter_annotations_function(tmp_dir) if subset_folder_name is None: - subset_folder_name = datetime.now().strftime("%m/%d/%Y_%H:%M:%S") - annotations_dir: Path = release_dir / (subset_folder_name or "") / "annotations" + subset_folder_name = datetime.now().strftime( + "%m/%d/%Y_%H:%M:%S" + ) + annotations_dir: Path = ( + release_dir / (subset_folder_name or "") / "annotations" + ) # Remove existing annotations if necessary if annotations_dir.exists(): try: shutil.rmtree(annotations_dir) except PermissionError: - print(f"Could not remove dataset in {annotations_dir}. Permission denied.") + print( + f"Could not remove dataset in {annotations_dir}. Permission denied." + ) annotations_dir.mkdir(parents=True, exist_ok=False) stems: dict = {} @@ -277,7 +285,9 @@ def pull( if annotation is None: continue - if video_frames and any([not slot.frame_urls for slot in annotation.slots]): + if video_frames and any( + [not slot.frame_urls for slot in annotation.slots] + ): # will raise if not installed via pip install darwin-py[ocv] try: from cv2 import ( @@ -294,7 +304,9 @@ def pull( else: stems[filename] = 1 - destination_name = annotations_dir / f"{filename}{annotation_path.suffix}" + destination_name = ( + annotations_dir / f"{filename}{annotation_path.suffix}" + ) shutil.move(str(annotation_path), str(destination_name)) # Extract the list of classes and create the text files @@ -309,7 +321,9 @@ def pull( target_link: Path = self.local_releases_path / release_dir.name latest_dir.symlink_to(target_link) except OSError: - self.console.log(f"Could not mark release {release.name} as latest. Continuing...") + self.console.log( + f"Could not mark release {release.name} as latest. Continuing..." + ) if only_annotations: # No images will be downloaded @@ -344,18 +358,33 @@ def pull( if env_max_workers and int(env_max_workers) > 0: max_workers = int(env_max_workers) - console.print(f"Going to download {str(count)} files to {self.local_images_path.as_posix()} .") + console.print( + f"Going to download {str(count)} files to {self.local_images_path.as_posix()} ." + ) successes, errors = exhaust_generator( - progress=progress(), count=count, multi_threaded=multi_threaded, worker_count=max_workers + progress=progress(), + count=count, + multi_threaded=multi_threaded, + worker_count=max_workers, ) if errors: - self.console.print(f"Encountered errors downloading {len(errors)} files") + self.console.print( + f"Encountered errors downloading {len(errors)} files" + ) for error in errors: self.console.print(f"\t - {error}") - downloaded_file_count = len([f for f in self.local_images_path.rglob("*") if f.is_file() and not f.name.startswith('.')]) + downloaded_file_count = len( + [ + f + for f in self.local_images_path.rglob("*") + if f.is_file() and not f.name.startswith(".") + ] + ) - console.print(f"Total file count after download completed {str(downloaded_file_count)}.") + console.print( + f"Total file count after download completed {str(downloaded_file_count)}." + ) return None, count else: @@ -367,7 +396,9 @@ def remove_remote(self) -> None: @abstractmethod def fetch_remote_files( - self, filters: Optional[Dict[str, Union[str, List[str]]]] = None, sort: Optional[Union[str, ItemSorter]] = None + self, + filters: Optional[Dict[str, Union[str, List[str]]]] = None, + sort: Optional[Union[str, ItemSorter]] = None, ) -> Iterator[DatasetItem]: """ Fetch and lists all files on the remote dataset. @@ -476,7 +507,9 @@ def fetch_annotation_type_id_for_name(self, name: str) -> Optional[int]: return None - def create_annotation_class(self, name: str, type: str, subtypes: List[str] = []) -> Dict[str, Any]: + def create_annotation_class( + self, name: str, type: str, subtypes: List[str] = [] + ) -> Dict[str, Any]: """ Creates an annotation class for this ``RemoteDataset``. @@ -502,9 +535,13 @@ def create_annotation_class(self, name: str, type: str, subtypes: List[str] = [] type_ids: List[int] = [] for annotation_type in [type] + subtypes: - type_id: Optional[int] = self.fetch_annotation_type_id_for_name(annotation_type) + type_id: Optional[int] = self.fetch_annotation_type_id_for_name( + annotation_type + ) if not type_id and self.annotation_types is not None: - list_of_annotation_types = ", ".join([type["name"] for type in self.annotation_types]) + list_of_annotation_types = ", ".join( + [type["name"] for type in self.annotation_types] + ) raise ValueError( f"Unknown annotation type: '{annotation_type}', valid values: {list_of_annotation_types}" ) @@ -514,7 +551,9 @@ def create_annotation_class(self, name: str, type: str, subtypes: List[str] = [] return self.client.create_annotation_class(self.dataset_id, type_ids, name) - def add_annotation_class(self, annotation_class: Union[AnnotationClass, int]) -> Optional[Dict[str, Any]]: + def add_annotation_class( + self, annotation_class: Union[AnnotationClass, int] + ) -> Optional[Dict[str, Any]]: """ Adds an annotation class to this ``RemoteDataset``. @@ -541,13 +580,19 @@ def add_annotation_class(self, annotation_class: Union[AnnotationClass, int]) -> if isinstance(annotation_class, int): match = [cls for cls in all_classes if cls["id"] == annotation_class] if not match: - raise ValueError(f"Annotation class id: `{annotation_class}` does not exist in Team.") + raise ValueError( + f"Annotation class id: `{annotation_class}` does not exist in Team." + ) else: - annotation_class_type = annotation_class.annotation_internal_type or annotation_class.annotation_type + annotation_class_type = ( + annotation_class.annotation_internal_type + or annotation_class.annotation_type + ) match = [ cls for cls in all_classes - if cls["name"] == annotation_class.name and annotation_class_type in cls["annotation_types"] + if cls["name"] == annotation_class.name + and annotation_class_type in cls["annotation_types"] ] if not match: # We do not expect to reach here; as pervious logic divides annotation classes in imports @@ -586,7 +631,9 @@ def fetch_remote_classes(self, team_wide=False) -> List[Dict[str, Any]]: classes_to_return = [] for cls in all_classes: - belongs_to_current_dataset = any([dataset["id"] == self.dataset_id for dataset in cls["datasets"]]) + belongs_to_current_dataset = any( + [dataset["id"] == self.dataset_id for dataset in cls["datasets"]] + ) cls["available"] = belongs_to_current_dataset if team_wide or belongs_to_current_dataset: classes_to_return.append(cls) @@ -740,7 +787,9 @@ def split( make_default_split=make_default_split, ) - def classes(self, annotation_type: str, release_name: Optional[str] = None) -> List[str]: + def classes( + self, annotation_type: str, release_name: Optional[str] = None + ) -> List[str]: """ Returns the list of ``class_type`` classes. @@ -762,7 +811,9 @@ def classes(self, annotation_type: str, release_name: Optional[str] = None) -> L release = self.get_release("latest") release_name = release.name - return get_classes(self.local_path, release_name=release_name, annotation_type=annotation_type) + return get_classes( + self.local_path, release_name=release_name, annotation_type=annotation_type + ) def annotations( self, @@ -829,7 +880,9 @@ def workview_url_for_item(self, item: DatasetItem) -> str: """ @abstractmethod - def post_comment(self, item: DatasetItem, text: str, x: float, y: float, w: float, h: float) -> None: + def post_comment( + self, item: DatasetItem, text: str, x: float, y: float, w: float, h: float + ) -> None: """ Adds a comment to an item in this dataset. The comment will be added with a bounding box. Creates the workflow for said item if necessary. @@ -895,5 +948,7 @@ def identifier(self) -> DatasetIdentifier: """The ``DatasetIdentifier`` of this ``RemoteDataset``.""" return DatasetIdentifier(team_slug=self.team, dataset_slug=self.slug) - def _build_image_annotation(self, annotation_file: AnnotationFile) -> Dict[str, Any]: - return build_image_annotation(annotation_file) \ No newline at end of file + def _build_image_annotation( + self, annotation_file: AnnotationFile + ) -> Dict[str, Any]: + return build_image_annotation(annotation_file) diff --git a/darwin/dataset/remote_dataset_v1.py b/darwin/dataset/remote_dataset_v1.py index d69f1d58a..084a83cba 100644 --- a/darwin/dataset/remote_dataset_v1.py +++ b/darwin/dataset/remote_dataset_v1.py @@ -102,12 +102,21 @@ def get_releases(self) -> List["Release"]: Returns a sorted list of available ``Release``\\s with the most recent first. """ try: - releases_json: List[Dict[str, Any]] = self.client.get_exports(self.dataset_id, self.team) + releases_json: List[Dict[str, Any]] = self.client.get_exports( + self.dataset_id, self.team + ) except NotFound: return [] - releases = [Release.parse_json(self.slug, self.team, payload) for payload in releases_json] - return sorted(filter(lambda x: x.available, releases), key=lambda x: x.version, reverse=True) + releases = [ + Release.parse_json(self.slug, self.team, payload) + for payload in releases_json + ] + return sorted( + filter(lambda x: x.available, releases), + key=lambda x: x.version, + reverse=True, + ) def push( self, @@ -173,23 +182,37 @@ def push( if files_to_upload is None: raise ValueError("No files or directory specified.") - uploading_files = [item for item in files_to_upload if isinstance(item, LocalFile)] - search_files = [item for item in files_to_upload if not isinstance(item, LocalFile)] + uploading_files = [ + item for item in files_to_upload if isinstance(item, LocalFile) + ] + search_files = [ + item for item in files_to_upload if not isinstance(item, LocalFile) + ] - generic_parameters_specified = path is not None or fps != 0 or as_frames is not False + generic_parameters_specified = ( + path is not None or fps != 0 or as_frames is not False + ) if uploading_files and generic_parameters_specified: raise ValueError("Cannot specify a path when uploading a LocalFile object.") for found_file in find_files(search_files, files_to_exclude=files_to_exclude): local_path = path if preserve_folders: - source_files = [source_file for source_file in search_files if is_relative_to(found_file, source_file)] + source_files = [ + source_file + for source_file in search_files + if is_relative_to(found_file, source_file) + ] if source_files: local_path = str(found_file.relative_to(source_files[0]).parent) - uploading_files.append(LocalFile(found_file, fps=fps, as_frames=as_frames, path=local_path)) + uploading_files.append( + LocalFile(found_file, fps=fps, as_frames=as_frames, path=local_path) + ) if not uploading_files: - raise ValueError("No files to upload, check your path, exclusion filters and resume flag") + raise ValueError( + "No files to upload, check your path, exclusion filters and resume flag" + ) handler = UploadHandlerV1(self, uploading_files) if blocking: @@ -205,7 +228,9 @@ def push( return handler def fetch_remote_files( - self, filters: Optional[Dict[str, Union[str, List[str]]]] = None, sort: Optional[Union[str, ItemSorter]] = None + self, + filters: Optional[Dict[str, Union[str, List[str]]]] = None, + sort: Optional[Union[str, ItemSorter]] = None, ) -> Iterator[DatasetItem]: """ Fetch and lists all files on the remote dataset. @@ -246,7 +271,9 @@ def fetch_remote_files( cursor = {"page[size]": 500} while True: payload = {"filter": post_filters, "sort": post_sort} - response = self.client.fetch_remote_files(self.dataset_id, cursor, payload, self.team) + response = self.client.fetch_remote_files( + self.dataset_id, cursor, payload, self.team + ) yield from [DatasetItem.parse(item) for item in response["items"]] @@ -264,7 +291,9 @@ def archive(self, items: Iterator[DatasetItem]) -> None: items : Iterator[DatasetItem] The ``DatasetItem``\\s to be archived. """ - payload: Dict[str, Any] = {"filter": {"dataset_item_ids": [item.id for item in items]}} + payload: Dict[str, Any] = { + "filter": {"dataset_item_ids": [item.id for item in items]} + } self.client.archive_item(self.slug, self.team, payload) def restore_archived(self, items: Iterator[DatasetItem]) -> None: @@ -276,7 +305,9 @@ def restore_archived(self, items: Iterator[DatasetItem]) -> None: items : Iterator[DatasetItem] The ``DatasetItem``\\s to be restored. """ - payload: Dict[str, Any] = {"filter": {"dataset_item_ids": [item.id for item in items]}} + payload: Dict[str, Any] = { + "filter": {"dataset_item_ids": [item.id for item in items]} + } self.client.restore_archived_item(self.slug, self.team, payload) def move_to_new(self, items: Iterator[DatasetItem]) -> None: @@ -288,7 +319,9 @@ def move_to_new(self, items: Iterator[DatasetItem]) -> None: items : Iterator[DatasetItem] The ``DatasetItem``\\s whose status will change. """ - payload: Dict[str, Any] = {"filter": {"dataset_item_ids": [item.id for item in items]}} + payload: Dict[str, Any] = { + "filter": {"dataset_item_ids": [item.id for item in items]} + } self.client.move_item_to_new(self.slug, self.team, payload) def reset(self, items: Iterator[DatasetItem]) -> None: @@ -300,7 +333,9 @@ def reset(self, items: Iterator[DatasetItem]) -> None: items : Iterator[DatasetItem] The ``DatasetItem``\\s to be resetted. """ - payload: Dict[str, Any] = {"filter": {"dataset_item_ids": [item.id for item in items]}} + payload: Dict[str, Any] = { + "filter": {"dataset_item_ids": [item.id for item in items]} + } self.client.reset_item(self.slug, self.team, payload) def complete(self, items: Iterator[DatasetItem]) -> None: @@ -312,7 +347,9 @@ def complete(self, items: Iterator[DatasetItem]) -> None: items : Iterator[DatasetItem] The ``DatasetItem``\\s to be completed. """ - wf_template_id_mapper = lambda item: item.current_workflow["workflow_template_id"] + wf_template_id_mapper = lambda item: item.current_workflow[ + "workflow_template_id" + ] input_items: List[DatasetItem] = list(items) # We split into items with and without workflow @@ -340,13 +377,22 @@ def complete(self, items: Iterator[DatasetItem]) -> None: sample_item = current_items[0] deep_sample_stages = sample_item.current_workflow["stages"].values() sample_stages = [item for sublist in deep_sample_stages for item in sublist] - complete_stage = list(filter(lambda stage: stage["type"] == "complete", sample_stages))[0] + complete_stage = list( + filter(lambda stage: stage["type"] == "complete", sample_stages) + )[0] filters = {"dataset_item_ids": [item.id for item in current_items]} try: - self.client.move_to_stage(self.slug, self.team, filters, complete_stage["workflow_stage_template_id"]) + self.client.move_to_stage( + self.slug, + self.team, + filters, + complete_stage["workflow_stage_template_id"], + ) except ValidationError: - raise ValueError("Unable to complete some of provided items. Make sure to assign them to a user first.") + raise ValueError( + "Unable to complete some of provided items. Make sure to assign them to a user first." + ) def delete_items(self, items: Iterator[DatasetItem]) -> None: """ @@ -357,7 +403,9 @@ def delete_items(self, items: Iterator[DatasetItem]) -> None: items : Iterator[DatasetItem] The ``DatasetItem``\\s to be deleted. """ - payload: Dict[str, Any] = {"filter": {"dataset_item_ids": [item.id for item in items]}} + payload: Dict[str, Any] = { + "filter": {"dataset_item_ids": [item.id for item in items]} + } self.client.delete_item(self.slug, self.team, payload) def export( @@ -411,7 +459,9 @@ def get_report(self, granularity: str = "day") -> str: str A CSV report. """ - response: Response = self.client.get_report(self.dataset_id, granularity, self.team) + response: Response = self.client.get_report( + self.dataset_id, granularity, self.team + ) return response.text def workview_url_for_item(self, item: DatasetItem) -> str: @@ -428,9 +478,14 @@ def workview_url_for_item(self, item: DatasetItem) -> str: str The url. """ - return urljoin(self.client.base_url, f"/workview?dataset={self.dataset_id}&image={item.seq}") + return urljoin( + self.client.base_url, + f"/workview?dataset={self.dataset_id}&image={item.seq}", + ) - def post_comment(self, item: DatasetItem, text: str, x: float, y: float, w: float, h: float): + def post_comment( + self, item: DatasetItem, text: str, x: float, y: float, w: float, h: float + ): """ Adds a comment to an item in this dataset Instantiates a workflow if needed @@ -459,5 +514,7 @@ def import_annotation(self, item_id: ItemId, payload: Dict[str, Any]) -> None: self.client.import_annotation(item_id, payload=payload) - def _build_image_annotation(self, annotation_file: AnnotationFile) -> Dict[str, Any]: - return build_image_annotation(annotation_file) \ No newline at end of file + def _build_image_annotation( + self, annotation_file: AnnotationFile + ) -> Dict[str, Any]: + return build_image_annotation(annotation_file) diff --git a/darwin/dataset/remote_dataset_v2.py b/darwin/dataset/remote_dataset_v2.py index a148e4cae..cb50cdacb 100644 --- a/darwin/dataset/remote_dataset_v2.py +++ b/darwin/dataset/remote_dataset_v2.py @@ -110,12 +110,21 @@ def get_releases(self) -> List["Release"]: Returns a sorted list of available ``Release``\\s with the most recent first. """ try: - releases_json: List[Dict[str, Any]] = self.client.api_v2.get_exports(self.slug, team_slug=self.team) + releases_json: List[Dict[str, Any]] = self.client.api_v2.get_exports( + self.slug, team_slug=self.team + ) except NotFound: return [] - releases = [Release.parse_json(self.slug, self.team, payload) for payload in releases_json] - return sorted(filter(lambda x: x.available, releases), key=lambda x: x.version, reverse=True) + releases = [ + Release.parse_json(self.slug, self.team, payload) + for payload in releases_json + ] + return sorted( + filter(lambda x: x.available, releases), + key=lambda x: x.version, + reverse=True, + ) def push( self, @@ -182,25 +191,43 @@ def push( if files_to_upload is None: raise ValueError("No files or directory specified.") - uploading_files = [item for item in files_to_upload if isinstance(item, LocalFile)] - search_files = [item for item in files_to_upload if not isinstance(item, LocalFile)] + uploading_files = [ + item for item in files_to_upload if isinstance(item, LocalFile) + ] + search_files = [ + item for item in files_to_upload if not isinstance(item, LocalFile) + ] - generic_parameters_specified = path is not None or fps != 0 or as_frames is not False + generic_parameters_specified = ( + path is not None or fps != 0 or as_frames is not False + ) if uploading_files and generic_parameters_specified: raise ValueError("Cannot specify a path when uploading a LocalFile object.") for found_file in find_files(search_files, files_to_exclude=files_to_exclude): local_path = path if preserve_folders: - source_files = [source_file for source_file in search_files if is_relative_to(found_file, source_file)] + source_files = [ + source_file + for source_file in search_files + if is_relative_to(found_file, source_file) + ] if source_files: local_path = str(found_file.relative_to(source_files[0]).parent) uploading_files.append( - LocalFile(found_file, fps=fps, as_frames=as_frames, extract_views=extract_views, path=local_path) + LocalFile( + found_file, + fps=fps, + as_frames=as_frames, + extract_views=extract_views, + path=local_path, + ) ) if not uploading_files: - raise ValueError("No files to upload, check your path, exclusion filters and resume flag") + raise ValueError( + "No files to upload, check your path, exclusion filters and resume flag" + ) handler = UploadHandlerV2(self, uploading_files) if blocking: @@ -216,7 +243,9 @@ def push( return handler def fetch_remote_files( - self, filters: Optional[Dict[str, Union[str, List[str]]]] = None, sort: Optional[Union[str, ItemSorter]] = None + self, + filters: Optional[Dict[str, Union[str, List[str]]]] = None, + sort: Optional[Union[str, ItemSorter]] = None, ) -> Iterator[DatasetItem]: """ Fetch and lists all files on the remote dataset. @@ -257,8 +286,13 @@ def fetch_remote_files( cursor = {"page[size]": 500, "include_workflow_data": "true"} while True: query = post_filters + list(post_sort.items()) + list(cursor.items()) - response = self.client.api_v2.fetch_items(self.dataset_id, query, team_slug=self.team) - yield from [DatasetItem.parse(item, dataset_slug=self.slug) for item in response["items"]] + response = self.client.api_v2.fetch_items( + self.dataset_id, query, team_slug=self.team + ) + yield from [ + DatasetItem.parse(item, dataset_slug=self.slug) + for item in response["items"] + ] if response["page"]["next"]: cursor["page[from]"] = response["page"]["next"] @@ -275,7 +309,10 @@ def archive(self, items: Iterator[DatasetItem]) -> None: The ``DatasetItem``\\s to be archived. """ payload: Dict[str, Any] = { - "filters": {"item_ids": [item.id for item in items], "dataset_ids": [self.dataset_id]} + "filters": { + "item_ids": [item.id for item in items], + "dataset_ids": [self.dataset_id], + } } self.client.api_v2.archive_items(payload, team_slug=self.team) @@ -289,7 +326,10 @@ def restore_archived(self, items: Iterator[DatasetItem]) -> None: The ``DatasetItem``\\s to be restored. """ payload: Dict[str, Any] = { - "filters": {"item_ids": [item.id for item in items], "dataset_ids": [self.dataset_id]} + "filters": { + "item_ids": [item.id for item in items], + "dataset_ids": [self.dataset_id], + } } self.client.api_v2.restore_archived_items(payload, team_slug=self.team) @@ -356,7 +396,8 @@ def delete_items(self, items: Iterator[DatasetItem]) -> None: The ``DatasetItem``\\s to be deleted. """ self.client.api_v2.delete_items( - {"dataset_ids": [self.dataset_id], "item_ids": [item.id for item in items]}, team_slug=self.team + {"dataset_ids": [self.dataset_id], "item_ids": [item.id for item in items]}, + team_slug=self.team, ) def export( @@ -394,9 +435,13 @@ def export( format = None else: raise UnknownExportVersion(version) - - filters = None if not annotation_class_ids else {"annotation_class_ids": list(map(int, annotation_class_ids))} - + + filters = ( + None + if not annotation_class_ids + else {"annotation_class_ids": list(map(int, annotation_class_ids))} + ) + self.client.api_v2.export_dataset( format=format, name=name, @@ -422,7 +467,9 @@ def get_report(self, granularity: str = "day") -> str: str A CSV report. """ - response: Response = self.client.get_report(self.dataset_id, granularity, self.team) + response: Response = self.client.get_report( + self.dataset_id, granularity, self.team + ) return response.text def workview_url_for_item(self, item: DatasetItem) -> str: @@ -439,10 +486,19 @@ def workview_url_for_item(self, item: DatasetItem) -> str: str The url. """ - return urljoin(self.client.base_url, f"/workview?dataset={self.dataset_id}&item={item.id}") + return urljoin( + self.client.base_url, f"/workview?dataset={self.dataset_id}&item={item.id}" + ) def post_comment( - self, item: DatasetItem, text: str, x: float, y: float, w: float, h: float, slot_name: Optional[str] = None + self, + item: DatasetItem, + text: str, + x: float, + y: float, + w: float, + h: float, + slot_name: Optional[str] = None, ): """ Adds a comment to an item in this dataset, @@ -450,10 +506,14 @@ def post_comment( """ if not slot_name: if len(item.slots) != 1: - raise ValueError(f"Unable to infer slot for '{item.id}', has multiple slots: {','.join(item.slots)}") + raise ValueError( + f"Unable to infer slot for '{item.id}', has multiple slots: {','.join(item.slots)}" + ) slot_name = item.slots[0]["slot_name"] - self.client.api_v2.post_comment(item.id, text, x, y, w, h, slot_name, team_slug=self.team) + self.client.api_v2.post_comment( + item.id, text, x, y, w, h, slot_name, team_slug=self.team + ) def import_annotation(self, item_id: ItemId, payload: Dict[str, Any]) -> None: """ @@ -468,7 +528,9 @@ def import_annotation(self, item_id: ItemId, payload: Dict[str, Any]) -> None: `{"annotations": serialized_annotations, "overwrite": "false"}` """ - self.client.api_v2.import_annotation(item_id, payload=payload, team_slug=self.team) + self.client.api_v2.import_annotation( + item_id, payload=payload, team_slug=self.team + ) def _fetch_stages(self, stage_type): detailed_dataset = self.client.api_v2.get_dataset(self.dataset_id) @@ -478,7 +540,12 @@ def _fetch_stages(self, stage_type): # currently we can only be part of one workflow workflow_id = workflow_ids[0] workflow = self.client.api_v2.get_workflow(workflow_id, team_slug=self.team) - return (workflow_id, [stage for stage in workflow["stages"] if stage["type"] == stage_type]) + return ( + workflow_id, + [stage for stage in workflow["stages"] if stage["type"] == stage_type], + ) - def _build_image_annotation(self, annotation_file: AnnotationFile) -> Dict[str, Any]: - return build_image_annotation(annotation_file) \ No newline at end of file + def _build_image_annotation( + self, annotation_file: AnnotationFile + ) -> Dict[str, Any]: + return build_image_annotation(annotation_file) diff --git a/darwin/exporter/formats/darwin_1_0.py b/darwin/exporter/formats/darwin_1_0.py index 16fe8cd76..4adc6b3ad 100644 --- a/darwin/exporter/formats/darwin_1_0.py +++ b/darwin/exporter/formats/darwin_1_0.py @@ -38,19 +38,30 @@ def _export_file(annotation_file: AnnotationFile, _: int, output_dir: Path) -> N filename = annotation_file.path.parts[-1] output_file_path = (output_dir / filename).with_suffix(".json") except Exception as e: - raise ExportException_CouldNotAssembleOutputPath(f"Could not export file {annotation_file.path} to {output_dir}") from e + raise ExportException_CouldNotAssembleOutputPath( + f"Could not export file {annotation_file.path} to {output_dir}" + ) from e try: output: DictFreeForm = _build_json(annotation_file) except Exception as e: - raise ExportException_CouldNotBuildOutput(f"Could not build output for {annotation_file.path}") from e + raise ExportException_CouldNotBuildOutput( + f"Could not build output for {annotation_file.path}" + ) from e try: with open(output_file_path, "w") as f: - op = json.dumps(output, option=json.OPT_INDENT_2 | json.OPT_SERIALIZE_NUMPY | json.OPT_NON_STR_KEYS).decode("utf-8") + op = json.dumps( + output, + option=json.OPT_INDENT_2 + | json.OPT_SERIALIZE_NUMPY + | json.OPT_NON_STR_KEYS, + ).decode("utf-8") f.write(op) except Exception as e: - raise ExportException_CouldNotWriteFile(f"Could not write output for {annotation_file.path}") from e + raise ExportException_CouldNotWriteFile( + f"Could not write output for {annotation_file.path}" + ) from e def _build_json(annotation_file: AnnotationFile) -> DictFreeForm: @@ -125,11 +136,17 @@ def _build_sub_annotation(sub: SubAnnotation) -> DictFreeForm: def _build_authorship(annotation: Union[VideoAnnotation, Annotation]) -> DictFreeForm: annotators = {} if annotation.annotators: - annotators = {"annotators": [_build_author(annotator) for annotator in annotation.annotators]} + annotators = { + "annotators": [ + _build_author(annotator) for annotator in annotation.annotators + ] + } reviewers = {} if annotation.reviewers: - reviewers = {"annotators": [_build_author(reviewer) for reviewer in annotation.reviewers]} + reviewers = { + "annotators": [_build_author(reviewer) for reviewer in annotation.reviewers] + } return {**annotators, **reviewers} @@ -138,7 +155,9 @@ def _build_video_annotation(annotation: VideoAnnotation) -> DictFreeForm: return { **annotation.get_data( only_keyframes=False, - post_processing=lambda annotation, _: _build_image_annotation(annotation, skip_slots=True), + post_processing=lambda annotation, _: _build_image_annotation( + annotation, skip_slots=True + ), ), "name": annotation.annotation_class.name, "slot_names": annotation.slot_names, @@ -146,7 +165,9 @@ def _build_video_annotation(annotation: VideoAnnotation) -> DictFreeForm: } -def _build_image_annotation(annotation: Annotation, skip_slots: bool = False) -> DictFreeForm: +def _build_image_annotation( + annotation: Annotation, skip_slots: bool = False +) -> DictFreeForm: json_subs = {} for sub in annotation.subs: json_subs.update(_build_sub_annotation(sub)) @@ -164,7 +185,9 @@ def _build_image_annotation(annotation: Annotation, skip_slots: bool = False) -> return {**base_json, "slot_names": annotation.slot_names} -def _build_legacy_annotation_data(annotation_class: AnnotationClass, data: DictFreeForm) -> DictFreeForm: +def _build_legacy_annotation_data( + annotation_class: AnnotationClass, data: DictFreeForm +) -> DictFreeForm: v1_data = {} polygon_annotation_mappings = {"complex_polygon": "paths", "polygon": "path"} @@ -251,6 +274,7 @@ def build_image_annotation(annotation_file: AnnotationFile) -> Dict[str, Any]: }, } + def _build_annotation_data(annotation: Annotation) -> Dict[str, Any]: if annotation.annotation_class.annotation_type == "complex_polygon": return {"path": annotation.data["paths"]} From 0914e604c8c6f96e3edea829072c0e697b43931d Mon Sep 17 00:00:00 2001 From: Christoffer Date: Wed, 15 Nov 2023 14:23:05 +0100 Subject: [PATCH 17/71] fixed conflic --- .../formats/export_darwin_1_0_test.py | 66 +++++++++++++++--- .../exporter/formats/export_darwin_test.py | 68 +++++++------------ 2 files changed, 80 insertions(+), 54 deletions(-) diff --git a/tests/darwin/exporter/formats/export_darwin_1_0_test.py b/tests/darwin/exporter/formats/export_darwin_1_0_test.py index f966d4094..306908d84 100644 --- a/tests/darwin/exporter/formats/export_darwin_1_0_test.py +++ b/tests/darwin/exporter/formats/export_darwin_1_0_test.py @@ -357,7 +357,11 @@ def test_polygon_annotation_file_with_bbox(self): bounding_box = {"x": 557.66, "y": 428.98, "w": 160.76, "h": 315.3} annotation_class = dt.AnnotationClass(name="test", annotation_type="polygon") - annotation = dt.Annotation(annotation_class=annotation_class, data={"path": polygon_path, "bounding_box": bounding_box}, subs=[]) + annotation = dt.Annotation( + annotation_class=annotation_class, + data={"path": polygon_path, "bounding_box": bounding_box}, + subs=[], + ) annotation_file = dt.AnnotationFile( path=Path("test.json"), @@ -381,14 +385,29 @@ def test_polygon_annotation_file_with_bbox(self): "path": None, "workview_url": None, }, - "annotations": [{"polygon": {"path": polygon_path}, "name": "test", "slot_names": [], "bounding_box": bounding_box}], + "annotations": [ + { + "polygon": {"path": polygon_path}, + "name": "test", + "slot_names": [], + "bounding_box": bounding_box, + } + ], "dataset": "None", } def test_complex_polygon_with_bbox(self): polygon_path = [ - [{"x": 230.06, "y": 174.04}, {"x": 226.39, "y": 170.36}, {"x": 224.61, "y": 166.81}], - [{"x": 238.98, "y": 171.69}, {"x": 236.97, "y": 174.04}, {"x": 238.67, "y": 174.04}], + [ + {"x": 230.06, "y": 174.04}, + {"x": 226.39, "y": 170.36}, + {"x": 224.61, "y": 166.81}, + ], + [ + {"x": 238.98, "y": 171.69}, + {"x": 236.97, "y": 174.04}, + {"x": 238.67, "y": 174.04}, + ], [ {"x": 251.75, "y": 169.77}, {"x": 251.75, "y": 154.34}, @@ -399,8 +418,14 @@ def test_complex_polygon_with_bbox(self): bounding_box = {"x": 557.66, "y": 428.98, "w": 160.76, "h": 315.3} - annotation_class = dt.AnnotationClass(name="test", annotation_type="complex_polygon") - annotation = dt.Annotation(annotation_class=annotation_class, data={"paths": polygon_path, "bounding_box": bounding_box}, subs=[]) + annotation_class = dt.AnnotationClass( + name="test", annotation_type="complex_polygon" + ) + annotation = dt.Annotation( + annotation_class=annotation_class, + data={"paths": polygon_path, "bounding_box": bounding_box}, + subs=[], + ) annotation_file = dt.AnnotationFile( path=Path("test.json"), @@ -424,14 +449,25 @@ def test_complex_polygon_with_bbox(self): "path": None, "workview_url": None, }, - "annotations": [{"complex_polygon": {"path": polygon_path}, "name": "test", "slot_names": [], "bounding_box": bounding_box}], + "annotations": [ + { + "complex_polygon": {"path": polygon_path}, + "name": "test", + "slot_names": [], + "bounding_box": bounding_box, + } + ], "dataset": "None", } def test_bounding_box(self): bounding_box_data = {"x": 100, "y": 150, "w": 50, "h": 30} - annotation_class = dt.AnnotationClass(name="bbox_test", annotation_type="bounding_box") - annotation = dt.Annotation(annotation_class=annotation_class, data=bounding_box_data, subs=[]) + annotation_class = dt.AnnotationClass( + name="bbox_test", annotation_type="bounding_box" + ) + annotation = dt.Annotation( + annotation_class=annotation_class, data=bounding_box_data, subs=[] + ) annotation_file = dt.AnnotationFile( path=Path("test.json"), @@ -455,14 +491,22 @@ def test_bounding_box(self): "path": None, "workview_url": None, }, - "annotations": [{"bounding_box": bounding_box_data, "name": "bbox_test", "slot_names": []}], + "annotations": [ + { + "bounding_box": bounding_box_data, + "name": "bbox_test", + "slot_names": [], + } + ], "dataset": "None", } def test_tags(self): tag_data = "sample_tag" annotation_class = dt.AnnotationClass(name="tag_test", annotation_type="tag") - annotation = dt.Annotation(annotation_class=annotation_class, data=tag_data, subs=[]) + annotation = dt.Annotation( + annotation_class=annotation_class, data=tag_data, subs=[] + ) annotation_file = dt.AnnotationFile( path=Path("test.json"), diff --git a/tests/darwin/exporter/formats/export_darwin_test.py b/tests/darwin/exporter/formats/export_darwin_test.py index 016254f68..3eb965f55 100644 --- a/tests/darwin/exporter/formats/export_darwin_test.py +++ b/tests/darwin/exporter/formats/export_darwin_test.py @@ -10,11 +10,11 @@ def test_empty_annotation_file_v2(): annotation_file = AnnotationFile( - path=Path("test.json"), - filename="test.json", - annotation_classes=[], + path=Path("test.json"), + filename="test.json", + annotation_classes=[], annotations=[], - dataset_name="Test Dataset" + dataset_name="Test Dataset", ) expected_output = { @@ -27,28 +27,20 @@ def test_empty_annotation_file_v2(): "dataset": {"name": "Test Dataset", "slug": "test-dataset"}, "item_id": "unknown-item-id", "team": {"name": None, "slug": None}, - "workview_url": None + "workview_url": None, }, - "slots": [] # Include an empty slots list as per Darwin v2 format + "slots": [], # Include an empty slots list as per Darwin v2 format }, - "annotations": [] + "annotations": [], } assert build_image_annotation(annotation_file) == expected_output - def test_complete_annotation_file_v2(): annotation_class = AnnotationClass(name="test", annotation_type="polygon") annotation = Annotation( -<<<<<<< HEAD - id="12345", - annotation_class=annotation_class, - data={"paths": [[]]}, - subs=[] -======= - annotation_class=annotation_class, data={"path": []}, subs=[] ->>>>>>> origin + id="12345", annotation_class=annotation_class, data={"paths": [[]]}, subs=[] ) annotation_file = AnnotationFile( @@ -56,10 +48,9 @@ def test_complete_annotation_file_v2(): filename="test.json", annotation_classes=[annotation_class], annotations=[annotation], - dataset_name="Test Dataset" + dataset_name="Test Dataset", ) -<<<<<<< HEAD expected_output = { "version": "2.0", "schema_ref": "https://darwin-public.s3.eu-west-1.amazonaws.com/darwin_json/2.0/schema.json", @@ -70,51 +61,42 @@ def test_complete_annotation_file_v2(): "dataset": {"name": "Test Dataset", "slug": "test-dataset"}, "item_id": "unknown-item-id", "team": {"name": None, "slug": None}, - "workview_url": None + "workview_url": None, }, - "slots": [] # Include an empty slots list as per Darwin v2 format + "slots": [], # Include an empty slots list as per Darwin v2 format }, - "annotations": [_build_v2_annotation_data(annotation)] -======= - assert build_image_annotation(annotation_file) == { - "annotations": [{"name": "test", "polygon": {"path": []}}], - "image": { - "filename": "test.json", - "height": 1080, - "url": "https://darwin.v7labs.com/image.jpg", - "width": 1920, - }, ->>>>>>> origin + "annotations": [_build_v2_annotation_data(annotation)], } assert build_image_annotation(annotation_file) == expected_output + def test_complete_annotation_file_with_bounding_box_and_tag_v2(): # Annotation for a polygon polygon_class = AnnotationClass(name="polygon_test", annotation_type="polygon") polygon_annotation = Annotation( id="polygon_id", - annotation_class=polygon_class, - data={"paths": [[{"x": 10, "y": 10}, {"x": 20, "y": 20}]]}, - subs=[] + annotation_class=polygon_class, + data={"paths": [[{"x": 10, "y": 10}, {"x": 20, "y": 20}]]}, + subs=[], ) # Annotation for a bounding box bbox_class = AnnotationClass(name="bbox_test", annotation_type="bounding_box") bbox_annotation = Annotation( id="bbox_id", - annotation_class=bbox_class, + annotation_class=bbox_class, data={"h": 100, "w": 200, "x": 50, "y": 60}, - subs=[] + subs=[], ) # Annotation for a tag tag_class = AnnotationClass(name="tag_test", annotation_type="tag") tag_annotation = Annotation( id="tag_id", - annotation_class=tag_class, + annotation_class=tag_class, data={}, # Assuming tag annotations have empty data - subs=[] + subs=[], ) annotation_file = AnnotationFile( @@ -122,7 +104,7 @@ def test_complete_annotation_file_with_bounding_box_and_tag_v2(): filename="test.json", annotation_classes=[polygon_class, bbox_class, tag_class], annotations=[polygon_annotation, bbox_annotation, tag_annotation], - dataset_name="Test Dataset" + dataset_name="Test Dataset", ) expected_output = { @@ -135,15 +117,15 @@ def test_complete_annotation_file_with_bounding_box_and_tag_v2(): "dataset": {"name": "Test Dataset", "slug": "test-dataset"}, "item_id": "unknown-item-id", "team": {"name": None, "slug": None}, - "workview_url": None + "workview_url": None, }, - "slots": [] # Include an empty slots list as per Darwin v2 format + "slots": [], # Include an empty slots list as per Darwin v2 format }, "annotations": [ _build_v2_annotation_data(polygon_annotation), _build_v2_annotation_data(bbox_annotation), - _build_v2_annotation_data(tag_annotation) - ] + _build_v2_annotation_data(tag_annotation), + ], } assert build_image_annotation(annotation_file) == expected_output From 3d620445f9bc20828a6e0cc01bf28266d866360e Mon Sep 17 00:00:00 2001 From: Christoffer Date: Wed, 15 Nov 2023 15:20:55 +0100 Subject: [PATCH 18/71] minor fixes --- darwin/exporter/formats/darwin.py | 2 +- tests/darwin/dataset/remote_dataset_test.py | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/darwin/exporter/formats/darwin.py b/darwin/exporter/formats/darwin.py index 98903c800..68f1131f2 100644 --- a/darwin/exporter/formats/darwin.py +++ b/darwin/exporter/formats/darwin.py @@ -124,7 +124,7 @@ def _build_item_data(annotation_file: dt.AnnotationFile) -> Dict[str, Any]: if annotation_file.dataset_name else None, }, - "item_id": annotation_file.item_id or "unknown-item-id", + "item_id": annotation_file.item_id, "team": { "name": None, # TODO Replace with actual team name "slug": None, # TODO Replace with actual team slug diff --git a/tests/darwin/dataset/remote_dataset_test.py b/tests/darwin/dataset/remote_dataset_test.py index 634c00926..0382f901e 100644 --- a/tests/darwin/dataset/remote_dataset_test.py +++ b/tests/darwin/dataset/remote_dataset_test.py @@ -390,9 +390,6 @@ def test_works_on_videos( / "test_video" ) assert video_path.exists() - - print(list(video_path.iterdir())) - assert (video_path / "0000000.json").exists() assert not (video_path / "0000001.json").exists() assert (video_path / "0000002.json").exists() From ab60ec6be5a4bc26ff538cea281584c86b1a2bc4 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Wed, 15 Nov 2023 16:55:38 +0100 Subject: [PATCH 19/71] removed ignore of empty files --- darwin/utils/utils.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/darwin/utils/utils.py b/darwin/utils/utils.py index b0987b134..a2b429c24 100644 --- a/darwin/utils/utils.py +++ b/darwin/utils/utils.py @@ -1006,9 +1006,6 @@ def split_video_annotation(annotation: dt.AnnotationFile) -> List[dt.AnnotationF if isinstance(a, dt.VideoAnnotation) and i in a.frames ] - if len(annotations) < 1: - continue - annotation_classes: Set[dt.AnnotationClass] = set( [annotation.annotation_class for annotation in annotations] ) From b178a6c730840df27305bd4941a45c894dc15ff5 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Wed, 15 Nov 2023 17:04:27 +0100 Subject: [PATCH 20/71] updated tests for new (old) behaviour --- tests/darwin/dataset/remote_dataset_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/darwin/dataset/remote_dataset_test.py b/tests/darwin/dataset/remote_dataset_test.py index 0382f901e..167892dd9 100644 --- a/tests/darwin/dataset/remote_dataset_test.py +++ b/tests/darwin/dataset/remote_dataset_test.py @@ -391,7 +391,7 @@ def test_works_on_videos( ) assert video_path.exists() assert (video_path / "0000000.json").exists() - assert not (video_path / "0000001.json").exists() + assert (video_path / "0000001.json").exists() assert (video_path / "0000002.json").exists() assert not (video_path / "0000003.json").exists() From c21ab07f7b18a27c1661289368f3920356843713 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Wed, 15 Nov 2023 18:24:26 +0100 Subject: [PATCH 21/71] added test case --- tests/darwin/dataset/remote_dataset_test.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/darwin/dataset/remote_dataset_test.py b/tests/darwin/dataset/remote_dataset_test.py index 167892dd9..939f9cc36 100644 --- a/tests/darwin/dataset/remote_dataset_test.py +++ b/tests/darwin/dataset/remote_dataset_test.py @@ -417,6 +417,17 @@ def test_works_on_videos( }, } + with (video_path / "0000001.json").open() as f: + assert json.loads(f.read()) == { + "annotations": [], + "image": { + "filename": "test_video/0000001.png", + "height": 1080, + "url": "frame_2.jpg", + "width": 1920, + }, + } + with (video_path / "0000002.json").open() as f: assert json.loads(f.read()) == { "annotations": [ From 2991bc8cbe91f08ae846d13c462d28a596818516 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Thu, 16 Nov 2023 11:01:51 +0100 Subject: [PATCH 22/71] updated test --- tests/darwin/exporter/formats/export_darwin_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/darwin/exporter/formats/export_darwin_test.py b/tests/darwin/exporter/formats/export_darwin_test.py index 3eb965f55..40dad1b3e 100644 --- a/tests/darwin/exporter/formats/export_darwin_test.py +++ b/tests/darwin/exporter/formats/export_darwin_test.py @@ -25,7 +25,7 @@ def test_empty_annotation_file_v2(): "path": "/", "source_info": { "dataset": {"name": "Test Dataset", "slug": "test-dataset"}, - "item_id": "unknown-item-id", + "item_id": None, "team": {"name": None, "slug": None}, "workview_url": None, }, @@ -59,7 +59,7 @@ def test_complete_annotation_file_v2(): "path": "/", "source_info": { "dataset": {"name": "Test Dataset", "slug": "test-dataset"}, - "item_id": "unknown-item-id", + "item_id": None, "team": {"name": None, "slug": None}, "workview_url": None, }, @@ -115,7 +115,7 @@ def test_complete_annotation_file_with_bounding_box_and_tag_v2(): "path": "/", "source_info": { "dataset": {"name": "Test Dataset", "slug": "test-dataset"}, - "item_id": "unknown-item-id", + "item_id": None, "team": {"name": None, "slug": None}, "workview_url": None, }, From f712ce74b00b2a511be5dcd010823d6c1166bbb6 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Fri, 17 Nov 2023 14:15:16 +0100 Subject: [PATCH 23/71] updated code to work with bounding boxes and polygon, also added toggle to use all or only non empty annotations to local dataset class --- darwin/dataset/local_dataset.py | 7 +++++++ darwin/datatypes.py | 2 +- darwin/exporter/formats/darwin.py | 22 +++++++--------------- darwin/torch/dataset.py | 18 ++++++++++++------ darwin/utils/utils.py | 18 ++++++++++-------- 5 files changed, 37 insertions(+), 30 deletions(-) diff --git a/darwin/dataset/local_dataset.py b/darwin/dataset/local_dataset.py index d79ecae76..77b534031 100644 --- a/darwin/dataset/local_dataset.py +++ b/darwin/dataset/local_dataset.py @@ -67,6 +67,7 @@ def __init__( split: str = "default", split_type: str = "random", release_name: Optional[str] = None, + keep_empty_annotations=False, ): self.dataset_path = dataset_path self.annotation_type = annotation_type @@ -75,6 +76,7 @@ def __init__( self.original_classes = None self.original_images_path: Optional[List[Path]] = None self.original_annotations_path: Optional[List[Path]] = None + self.keep_empty_annotations = keep_empty_annotations release_path, annotations_dir, images_dir = self._initial_setup( dataset_path, release_name @@ -101,6 +103,7 @@ def __init__( split, partition, split_type, + keep_empty_annotations, ) if len(self.images_path) == 0: @@ -130,12 +133,16 @@ def _setup_annotations_and_images( split, partition, split_type, + keep_empty_annotations=False, ): # Find all the annotations and their corresponding images for annotation_path in sorted(annotations_dir.glob("**/*.json")): darwin_json = stream_darwin_json(annotation_path) image_path = get_image_path_from_stream(darwin_json, images_dir) if image_path.exists(): + if not keep_empty_annotations: + if len(darwin_json['annotations']) < 1: + continue self.images_path.append(image_path) self.annotations_path.append(annotation_path) continue diff --git a/darwin/datatypes.py b/darwin/datatypes.py index bd39b90cd..636a02252 100644 --- a/darwin/datatypes.py +++ b/darwin/datatypes.py @@ -568,7 +568,7 @@ def make_polygon( """ return Annotation( AnnotationClass(class_name, "polygon"), - _maybe_add_bounding_box_data({"path": point_path}, bounding_box), + _maybe_add_bounding_box_data({"paths": point_path}, bounding_box), subs or [], slot_names=slot_names or [], ) diff --git a/darwin/exporter/formats/darwin.py b/darwin/exporter/formats/darwin.py index 68f1131f2..94e274b6f 100644 --- a/darwin/exporter/formats/darwin.py +++ b/darwin/exporter/formats/darwin.py @@ -48,19 +48,22 @@ def build_image_annotation(annotation_file: dt.AnnotationFile) -> Dict[str, Any] def _build_v2_annotation_data(annotation: dt.Annotation) -> Dict[str, Any]: + annotation_data = {"id": annotation.id, "name": annotation.annotation_class.name} - if annotation.annotation_class.annotation_type == "bounding_box": annotation_data["bounding_box"] = _build_bounding_box_data(annotation.data) elif annotation.annotation_class.annotation_type == "tag": annotation_data["tag"] = {} elif annotation.annotation_class.annotation_type == "polygon": - annotation_data["polygon"] = _build_polygon_data(annotation.data) + polygon_data = _build_polygon_data(annotation.data) + annotation_data["polygon"] = polygon_data + annotation_data["bounding_box"] = _build_bounding_box_data(annotation.data) return annotation_data def _build_bounding_box_data(data: Dict[str, Any]) -> Dict[str, Any]: + data = data["bounding_box"] return { "h": data.get("h"), "w": data.get("w"), @@ -85,19 +88,8 @@ def _build_polygon_data( Dict[str, List[List[Dict[str, float]]]] The polygon data in the format required for Darwin v2 annotations. """ - # Assuming the data contains a 'paths' key that is a list of lists of points, - # where each point is a dictionary with 'x' and 'y' keys. - paths = data.get("paths", []) - v2_paths = [] - - for path in paths: - v2_path = [] - for point in path: - v2_point = {"x": point.get("x"), "y": point.get("y")} - v2_path.append(v2_point) - v2_paths.append(v2_path) - - return {"paths": v2_paths} + + return {"paths": data.get("paths", [])} def _build_item_data(annotation_file: dt.AnnotationFile) -> Dict[str, Any]: diff --git a/darwin/torch/dataset.py b/darwin/torch/dataset.py index 080fef467..e3dd8f077 100644 --- a/darwin/torch/dataset.py +++ b/darwin/torch/dataset.py @@ -634,12 +634,18 @@ def get_target(self, index: int) -> Dict[str, Tensor]: targets.append(ann) # following https://pytorch.org/tutorials/intermediate/torchvision_tutorial.html - stacked_targets = { - "boxes": torch.stack([v["bbox"] for v in targets]), - "area": torch.stack([v["area"] for v in targets]), - "labels": torch.stack([v["label"] for v in targets]), - "image_id": torch.tensor([index]), - } + + try: + + stacked_targets = { + "boxes": torch.stack([v["bbox"] for v in targets]), + "area": torch.stack([v["area"] for v in targets]), + "labels": torch.stack([v["label"] for v in targets]), + "image_id": torch.tensor([index]), + } + except Exception as e: + print(target) + raise e stacked_targets["iscrowd"] = torch.zeros_like(stacked_targets["labels"]) diff --git a/darwin/utils/utils.py b/darwin/utils/utils.py index a2b429c24..9991b06e2 100644 --- a/darwin/utils/utils.py +++ b/darwin/utils/utils.py @@ -1,9 +1,9 @@ """ Contains several unrelated utility functions used across the SDK. """ - import platform import re +from dataclasses import asdict from pathlib import Path from typing import ( TYPE_CHECKING, @@ -532,12 +532,14 @@ def _parse_darwin_v2(path: Path, data: Dict[str, Any]) -> dt.AnnotationFile: slots: List[dt.Slot] = list( filter(None, map(_parse_darwin_slot, item.get("slots", []))) ) + annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations( data ) - annotation_classes: Set[dt.AnnotationClass] = set( - [annotation.annotation_class for annotation in annotations] - ) + + annotation_classes: Set[dt.AnnotationClass] = { + annotation.annotation_class for annotation in annotations + } if len(slots) == 0: annotation_file = dt.AnnotationFile( @@ -658,9 +660,9 @@ def _parse_darwin_video( annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations( data ) - annotation_classes: Set[dt.AnnotationClass] = set( - [annotation.annotation_class for annotation in annotations] - ) + annotation_classes: Set[dt.AnnotationClass] = { + annotation.annotation_class for annotation in annotations + } if "width" not in data["image"] or "height" not in data["image"]: raise OutdatedDarwinJSONFormat( @@ -715,7 +717,7 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati "polygon" in annotation and "paths" in annotation["polygon"] and len(annotation["polygon"]["paths"]) > 1 - ): + ): bounding_box = annotation.get("bounding_box") paths = annotation["polygon"]["paths"] main_annotation = dt.make_complex_polygon( From b090f612c63e9f18f266da22ddd61cc8f46e62e0 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Fri, 17 Nov 2023 14:16:22 +0100 Subject: [PATCH 24/71] black --- darwin/dataset/local_dataset.py | 2 +- darwin/exporter/formats/darwin.py | 1 - darwin/torch/dataset.py | 1 - darwin/utils/utils.py | 2 +- 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/darwin/dataset/local_dataset.py b/darwin/dataset/local_dataset.py index 77b534031..58c9d6e71 100644 --- a/darwin/dataset/local_dataset.py +++ b/darwin/dataset/local_dataset.py @@ -141,7 +141,7 @@ def _setup_annotations_and_images( image_path = get_image_path_from_stream(darwin_json, images_dir) if image_path.exists(): if not keep_empty_annotations: - if len(darwin_json['annotations']) < 1: + if len(darwin_json["annotations"]) < 1: continue self.images_path.append(image_path) self.annotations_path.append(annotation_path) diff --git a/darwin/exporter/formats/darwin.py b/darwin/exporter/formats/darwin.py index 94e274b6f..9cf3be0f5 100644 --- a/darwin/exporter/formats/darwin.py +++ b/darwin/exporter/formats/darwin.py @@ -48,7 +48,6 @@ def build_image_annotation(annotation_file: dt.AnnotationFile) -> Dict[str, Any] def _build_v2_annotation_data(annotation: dt.Annotation) -> Dict[str, Any]: - annotation_data = {"id": annotation.id, "name": annotation.annotation_class.name} if annotation.annotation_class.annotation_type == "bounding_box": annotation_data["bounding_box"] = _build_bounding_box_data(annotation.data) diff --git a/darwin/torch/dataset.py b/darwin/torch/dataset.py index e3dd8f077..d06ca69f8 100644 --- a/darwin/torch/dataset.py +++ b/darwin/torch/dataset.py @@ -636,7 +636,6 @@ def get_target(self, index: int) -> Dict[str, Tensor]: # following https://pytorch.org/tutorials/intermediate/torchvision_tutorial.html try: - stacked_targets = { "boxes": torch.stack([v["bbox"] for v in targets]), "area": torch.stack([v["area"] for v in targets]), diff --git a/darwin/utils/utils.py b/darwin/utils/utils.py index 9991b06e2..da29651e1 100644 --- a/darwin/utils/utils.py +++ b/darwin/utils/utils.py @@ -717,7 +717,7 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati "polygon" in annotation and "paths" in annotation["polygon"] and len(annotation["polygon"]["paths"]) > 1 - ): + ): bounding_box = annotation.get("bounding_box") paths = annotation["polygon"]["paths"] main_annotation = dt.make_complex_polygon( From 2af57b4c8a3e1cf21cc79b5e7b8f770da247c58d Mon Sep 17 00:00:00 2001 From: Christoffer Date: Fri, 17 Nov 2023 15:16:03 +0100 Subject: [PATCH 25/71] adjusting paths to pass tests --- darwin/dataset/local_dataset.py | 3 ++- darwin/datatypes.py | 13 ++++++++++++- darwin/torch/dataset.py | 7 ++++++- darwin/utils/utils.py | 7 ++++++- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/darwin/dataset/local_dataset.py b/darwin/dataset/local_dataset.py index 58c9d6e71..db860e245 100644 --- a/darwin/dataset/local_dataset.py +++ b/darwin/dataset/local_dataset.py @@ -1,6 +1,7 @@ import multiprocessing as mp from pathlib import Path from typing import Any, Dict, Iterator, List, Optional, Tuple +from xmlrpc.client import Boolean import numpy as np from PIL import Image as PILImage @@ -67,7 +68,7 @@ def __init__( split: str = "default", split_type: str = "random", release_name: Optional[str] = None, - keep_empty_annotations=False, + keep_empty_annotations: Boolean = False, ): self.dataset_path = dataset_path self.annotation_type = annotation_type diff --git a/darwin/datatypes.py b/darwin/datatypes.py index 636a02252..15ba14855 100644 --- a/darwin/datatypes.py +++ b/darwin/datatypes.py @@ -15,6 +15,7 @@ Tuple, Union, ) +from xmlrpc.client import Boolean try: from numpy.typing import NDArray @@ -538,6 +539,7 @@ def make_polygon( bounding_box: Optional[Dict] = None, subs: Optional[List[SubAnnotation]] = None, slot_names: Optional[List[str]] = None, + darwin_v1: Boolean = False, ) -> Annotation: """ Creates and returns a polygon annotation. @@ -566,9 +568,18 @@ def make_polygon( Annotation A polygon ``Annotation``. """ + + if darwin_v1: + polygon_data = ({"path": point_path}) + else: + # Lets handle darwin V2 datasets + if not isinstance(point_path[0], list): + point_path = [point_path] + polygon_data = ({"paths": point_path}) + return Annotation( AnnotationClass(class_name, "polygon"), - _maybe_add_bounding_box_data({"paths": point_path}, bounding_box), + _maybe_add_bounding_box_data(polygon_data, bounding_box), subs or [], slot_names=slot_names or [], ) diff --git a/darwin/torch/dataset.py b/darwin/torch/dataset.py index d06ca69f8..433232b98 100644 --- a/darwin/torch/dataset.py +++ b/darwin/torch/dataset.py @@ -6,6 +6,7 @@ from torch.functional import Tensor from torchvision.transforms.functional import to_tensor +import darwin from darwin.cli_functions import _error, _load_client from darwin.client import Client from darwin.dataset.identifier import DatasetIdentifier @@ -328,6 +329,11 @@ def get_target(self, index: int) -> Dict[str, Any]: for annotation in target["annotations"]: annotation_type: str = annotation.annotation_class.annotation_type path_key = "paths" if annotation_type == "complex_polygon" else "path" + + # Darwin V2 only has paths (TODO it might be more robust fixes) + if "paths" in annotation.data: + path_key = "paths" + if path_key not in annotation.data: print( f"Warning: missing polygon in annotation {self.annotations_path[index]}" @@ -489,7 +495,6 @@ def get_target(self, index: int) -> Dict[str, Any]: paths = obj.data["paths"] else: paths = [obj.data["path"]] - for path in paths: sequences = convert_polygons_to_sequences( path, diff --git a/darwin/utils/utils.py b/darwin/utils/utils.py index da29651e1..544ca9370 100644 --- a/darwin/utils/utils.py +++ b/darwin/utils/utils.py @@ -746,7 +746,11 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati ) else: main_annotation = dt.make_polygon( - name, annotation["polygon"]["path"], bounding_box, slot_names=slot_names + name, + annotation["polygon"]["path"], + bounding_box, + slot_names=slot_names, + darwin_v1=True, ) # Darwin JSON 1.0 representation of complex polygons elif "complex_polygon" in annotation: @@ -1088,6 +1092,7 @@ def convert_polygons_to_sequences( raise ValueError("No polygons provided") # If there is a single polygon composing the instance then this is # transformed to polygons = [[{x: x1, y:y1}, ..., {x: xn, y:yn}]] + list_polygons: List[dt.Polygon] = [] if isinstance(polygons[0], list): list_polygons = cast(List[dt.Polygon], polygons) From 2d9f9d22c03bc5db5e605eebc8b7bde88564d92e Mon Sep 17 00:00:00 2001 From: Christoffer Date: Fri, 17 Nov 2023 15:20:05 +0100 Subject: [PATCH 26/71] black --- darwin/datatypes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/darwin/datatypes.py b/darwin/datatypes.py index 15ba14855..63d3552d2 100644 --- a/darwin/datatypes.py +++ b/darwin/datatypes.py @@ -570,12 +570,12 @@ def make_polygon( """ if darwin_v1: - polygon_data = ({"path": point_path}) + polygon_data = {"path": point_path} else: # Lets handle darwin V2 datasets if not isinstance(point_path[0], list): point_path = [point_path] - polygon_data = ({"paths": point_path}) + polygon_data = {"paths": point_path} return Annotation( AnnotationClass(class_name, "polygon"), From 20ae22462ec48426228e8213211e9b66acb452e2 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Fri, 17 Nov 2023 15:46:45 +0100 Subject: [PATCH 27/71] handling polyg and bbox bounding box anno --- darwin/exporter/formats/darwin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/darwin/exporter/formats/darwin.py b/darwin/exporter/formats/darwin.py index 9cf3be0f5..92748cdba 100644 --- a/darwin/exporter/formats/darwin.py +++ b/darwin/exporter/formats/darwin.py @@ -62,7 +62,8 @@ def _build_v2_annotation_data(annotation: dt.Annotation) -> Dict[str, Any]: def _build_bounding_box_data(data: Dict[str, Any]) -> Dict[str, Any]: - data = data["bounding_box"] + if "bounding_box" in data: + data = data["bounding_box"] return { "h": data.get("h"), "w": data.get("w"), From aa65a294e206f4d987a796694f8130c78b604a3e Mon Sep 17 00:00:00 2001 From: John Wilkie <124276291+JBWilkie@users.noreply.github.com> Date: Thu, 16 Nov 2023 12:51:58 +0000 Subject: [PATCH 28/71] [PY-401][external] Restore Meta Item & ItemQuery functions (#718) * Meta functions & tests to restore items * Test fixes --- darwin/future/meta/objects/item.py | 13 +++++++ darwin/future/meta/queries/item.py | 20 +++++++++++ .../tests/meta/objects/test_itemmeta.py | 23 ++++++++++++ darwin/future/tests/meta/queries/test_item.py | 35 +++++++++++++++++++ 4 files changed, 91 insertions(+) diff --git a/darwin/future/meta/objects/item.py b/darwin/future/meta/objects/item.py index 33422e13c..20d954bbd 100644 --- a/darwin/future/meta/objects/item.py +++ b/darwin/future/meta/objects/item.py @@ -5,6 +5,7 @@ from darwin.future.core.items.delete_items import delete_list_of_items from darwin.future.core.items.move_items_to_folder import move_list_of_items_to_folder +from darwin.future.core.items.restore_items import restore_list_of_items from darwin.future.data_objects.item import ItemCore, ItemLayout, ItemSlot from darwin.future.meta.objects.base import MetaBase @@ -66,6 +67,18 @@ def move_to_folder(self, path: str) -> None: filters = {"item_ids": [str(self.id)]} move_list_of_items_to_folder(self.client, team_slug, dataset_id, path, filters) + def restore(self) -> None: + team_slug, dataset_id = ( + self.meta_params["team_slug"], + self.meta_params["dataset_id"] + if "dataset_id" in self.meta_params + else self.meta_params["dataset_ids"], + ) + assert isinstance(team_slug, str) + dataset_id = cast(Union[int, List[int]], dataset_id) + filters = {"item_ids": [str(self.id)]} + restore_list_of_items(self.client, team_slug, dataset_id, filters) + @property def name(self) -> str: return self._element.name diff --git a/darwin/future/meta/queries/item.py b/darwin/future/meta/queries/item.py index e2f120d9e..356278cab 100644 --- a/darwin/future/meta/queries/item.py +++ b/darwin/future/meta/queries/item.py @@ -4,6 +4,7 @@ from darwin.future.core.items.delete_items import delete_list_of_items from darwin.future.core.items.get import list_items from darwin.future.core.items.move_items_to_folder import move_list_of_items_to_folder +from darwin.future.core.items.restore_items import restore_list_of_items from darwin.future.core.types.common import QueryString from darwin.future.core.types.query import PaginatedQuery from darwin.future.meta.objects.item import Item @@ -81,3 +82,22 @@ def move_to_folder(self, path) -> None: ids = [item.id for item in self] filters = {"item_ids": [str(item) for item in ids]} move_list_of_items_to_folder(self.client, team_slug, dataset_ids, path, filters) + + def restore(self) -> None: + if "team_slug" not in self.meta_params: + raise ValueError("Must specify team_slug to query items") + if ( + "dataset_ids" not in self.meta_params + and "dataset_id" not in self.meta_params + ): + raise ValueError("Must specify dataset_ids to query items") + dataset_ids = ( + self.meta_params["dataset_ids"] + if "dataset_ids" in self.meta_params + else self.meta_params["dataset_id"] + ) + team_slug = self.meta_params["team_slug"] + self.collect_all() + ids = [item.id for item in self] + filters = {"item_ids": [str(item) for item in ids]} + restore_list_of_items(self.client, team_slug, dataset_ids, filters) diff --git a/darwin/future/tests/meta/objects/test_itemmeta.py b/darwin/future/tests/meta/objects/test_itemmeta.py index 39fe840e0..6e9093381 100644 --- a/darwin/future/tests/meta/objects/test_itemmeta.py +++ b/darwin/future/tests/meta/objects/test_itemmeta.py @@ -97,3 +97,26 @@ def test_move_to_folder_raises_on_incorrect_parameters(item: Item) -> None: ) with pytest.raises(BadRequest): item.move_to_folder(path) + + +def test_restore(item: Item) -> None: + with responses.RequestsMock() as rsps: + team_slug = item.meta_params["team_slug"] + dataset_id = item.meta_params["dataset_id"] + rsps.add( + rsps.POST, + item.client.config.api_endpoint + f"v2/teams/{team_slug}/items/restore", + status=200, + match=[ + json_params_matcher( + { + "filters": { + "item_ids": [str(item.id)], + "dataset_ids": [dataset_id], + } + } + ) + ], + json={}, + ) + item.restore() diff --git a/darwin/future/tests/meta/queries/test_item.py b/darwin/future/tests/meta/queries/test_item.py index 314a353c8..35fb32092 100644 --- a/darwin/future/tests/meta/queries/test_item.py +++ b/darwin/future/tests/meta/queries/test_item.py @@ -146,3 +146,38 @@ def test_move_to_folder_raises_on_incorrect_parameters( ) with pytest.raises(BadRequest): item_query.move_to_folder(path) + + +def test_restore( + item_query: ItemQuery, items_json: List[dict], items: List[Item] +) -> None: + with responses.RequestsMock() as rsps: + rsps.add( + rsps.GET, + item_query.client.config.api_endpoint + "v2/teams/test/items", + match=[ + query_param_matcher( + {"page[offset]": "0", "page[size]": "500", "dataset_ids": "1"} + ) + ], + json={"items": items_json, "errors": []}, + ) + team_slug = items[0].meta_params["team_slug"] + dataset_id = items[0].meta_params["dataset_id"] + rsps.add( + rsps.POST, + items[0].client.config.api_endpoint + f"v2/teams/{team_slug}/items/restore", + status=200, + match=[ + json_params_matcher( + { + "filters": { + "item_ids": [str(item.id) for item in items], + "dataset_ids": [dataset_id], + } + } + ) + ], + json={}, + ) + item_query.restore() From ab4627a77648136a27a5e71aeb0bcc5fbe950d99 Mon Sep 17 00:00:00 2001 From: John Wilkie <124276291+JBWilkie@users.noreply.github.com> Date: Thu, 16 Nov 2023 13:37:32 +0000 Subject: [PATCH 29/71] [PY-402][external] Archive Meta Item & ItemQuery functions (#719) * Meta functions & tests to archive items * Test improvements * Linting --- darwin/future/meta/objects/item.py | 13 +++++ darwin/future/meta/queries/item.py | 20 ++++++++ .../tests/meta/objects/test_itemmeta.py | 48 +++++++++++++++++++ darwin/future/tests/meta/queries/test_item.py | 35 ++++++++++++++ 4 files changed, 116 insertions(+) diff --git a/darwin/future/meta/objects/item.py b/darwin/future/meta/objects/item.py index 20d954bbd..d531ee8a2 100644 --- a/darwin/future/meta/objects/item.py +++ b/darwin/future/meta/objects/item.py @@ -3,6 +3,7 @@ from typing import Dict, List, Optional, Union, cast from uuid import UUID +from darwin.future.core.items.archive_items import archive_list_of_items from darwin.future.core.items.delete_items import delete_list_of_items from darwin.future.core.items.move_items_to_folder import move_list_of_items_to_folder from darwin.future.core.items.restore_items import restore_list_of_items @@ -79,6 +80,18 @@ def restore(self) -> None: filters = {"item_ids": [str(self.id)]} restore_list_of_items(self.client, team_slug, dataset_id, filters) + def archive(self) -> None: + team_slug, dataset_id = ( + self.meta_params["team_slug"], + self.meta_params["dataset_id"] + if "dataset_id" in self.meta_params + else self.meta_params["dataset_ids"], + ) + assert isinstance(team_slug, str) + dataset_id = cast(Union[int, List[int]], dataset_id) + filters = {"item_ids": [str(self.id)]} + archive_list_of_items(self.client, team_slug, dataset_id, filters) + @property def name(self) -> str: return self._element.name diff --git a/darwin/future/meta/queries/item.py b/darwin/future/meta/queries/item.py index 356278cab..e868e8228 100644 --- a/darwin/future/meta/queries/item.py +++ b/darwin/future/meta/queries/item.py @@ -1,6 +1,7 @@ from functools import reduce from typing import Dict +from darwin.future.core.items.archive_items import archive_list_of_items from darwin.future.core.items.delete_items import delete_list_of_items from darwin.future.core.items.get import list_items from darwin.future.core.items.move_items_to_folder import move_list_of_items_to_folder @@ -101,3 +102,22 @@ def restore(self) -> None: ids = [item.id for item in self] filters = {"item_ids": [str(item) for item in ids]} restore_list_of_items(self.client, team_slug, dataset_ids, filters) + + def archive(self) -> None: + if "team_slug" not in self.meta_params: + raise ValueError("Must specify team_slug to query items") + if ( + "dataset_ids" not in self.meta_params + and "dataset_id" not in self.meta_params + ): + raise ValueError("Must specify dataset_ids to query items") + dataset_ids = ( + self.meta_params["dataset_ids"] + if "dataset_ids" in self.meta_params + else self.meta_params["dataset_id"] + ) + team_slug = self.meta_params["team_slug"] + self.collect_all() + ids = [item.id for item in self] + filters = {"item_ids": [str(item) for item in ids]} + archive_list_of_items(self.client, team_slug, dataset_ids, filters) diff --git a/darwin/future/tests/meta/objects/test_itemmeta.py b/darwin/future/tests/meta/objects/test_itemmeta.py index 6e9093381..d9134bd5c 100644 --- a/darwin/future/tests/meta/objects/test_itemmeta.py +++ b/darwin/future/tests/meta/objects/test_itemmeta.py @@ -48,6 +48,12 @@ def test_delete(item: Item) -> None: item.delete() +def test_delete_with_bad_team_slug(item: Item) -> None: + with pytest.raises(AssertionError): + item.meta_params["team_slug"] = 123 + item.delete() + + def test_move_to_folder(item: Item) -> None: with responses.RequestsMock() as rsps: team_slug = item.meta_params["team_slug"] @@ -99,6 +105,13 @@ def test_move_to_folder_raises_on_incorrect_parameters(item: Item) -> None: item.move_to_folder(path) +def test_move_to_folder_with_bad_team_slug(item: Item) -> None: + with pytest.raises(AssertionError): + path = "/new_folder" + item.meta_params["team_slug"] = 123 + item.move_to_folder(path) + + def test_restore(item: Item) -> None: with responses.RequestsMock() as rsps: team_slug = item.meta_params["team_slug"] @@ -120,3 +133,38 @@ def test_restore(item: Item) -> None: json={}, ) item.restore() + + +def test_restore_with_bad_team_slug(item: Item) -> None: + with pytest.raises(AssertionError): + item.meta_params["team_slug"] = 123 + item.restore() + + +def test_archive(item: Item) -> None: + with responses.RequestsMock() as rsps: + team_slug = item.meta_params["team_slug"] + dataset_id = item.meta_params["dataset_id"] + rsps.add( + rsps.POST, + item.client.config.api_endpoint + f"v2/teams/{team_slug}/items/archive", + status=200, + match=[ + json_params_matcher( + { + "filters": { + "item_ids": [str(item.id)], + "dataset_ids": [dataset_id], + } + } + ) + ], + json={}, + ) + item.archive() + + +def test_archive_with_bad_team_slug(item: Item) -> None: + with pytest.raises(AssertionError): + item.meta_params["team_slug"] = 123 + item.archive() diff --git a/darwin/future/tests/meta/queries/test_item.py b/darwin/future/tests/meta/queries/test_item.py index 35fb32092..abae86b5c 100644 --- a/darwin/future/tests/meta/queries/test_item.py +++ b/darwin/future/tests/meta/queries/test_item.py @@ -181,3 +181,38 @@ def test_restore( json={}, ) item_query.restore() + + +def test_archive( + item_query: ItemQuery, items_json: List[dict], items: List[Item] +) -> None: + with responses.RequestsMock() as rsps: + rsps.add( + rsps.GET, + item_query.client.config.api_endpoint + "v2/teams/test/items", + match=[ + query_param_matcher( + {"page[offset]": "0", "page[size]": "500", "dataset_ids": "1"} + ) + ], + json={"items": items_json, "errors": []}, + ) + team_slug = items[0].meta_params["team_slug"] + dataset_id = items[0].meta_params["dataset_id"] + rsps.add( + rsps.POST, + items[0].client.config.api_endpoint + f"v2/teams/{team_slug}/items/archive", + status=200, + match=[ + json_params_matcher( + { + "filters": { + "item_ids": [str(item.id) for item in items], + "dataset_ids": [dataset_id], + } + } + ) + ], + json={}, + ) + item_query.archive() From 96227e554743a82c02960d4c2d839437efb0b90a Mon Sep 17 00:00:00 2001 From: John Wilkie <124276291+JBWilkie@users.noreply.github.com> Date: Thu, 16 Nov 2023 14:02:07 +0000 Subject: [PATCH 30/71] [PY-403][external] Set Priority Meta Item & ItemQuery functions (#720) * Fixed missing path parameter in core move items to folder method * Fixed missing priority parameter in core set item priority method * Linting * Meta Item & ItemQuery functions to move between folders * Update darwin/future/tests/meta/queries/test_item.py Co-authored-by: Owen Jones * Added sad path tests * Meta functions & tests to set item priority * Linting --------- Co-authored-by: Owen Jones --- darwin/future/meta/objects/item.py | 13 ++++ darwin/future/meta/queries/item.py | 22 ++++++ .../tests/meta/objects/test_itemmeta.py | 58 ++++++++++++++ darwin/future/tests/meta/queries/test_item.py | 77 +++++++++++++++++++ 4 files changed, 170 insertions(+) diff --git a/darwin/future/meta/objects/item.py b/darwin/future/meta/objects/item.py index d531ee8a2..f2b40e5f4 100644 --- a/darwin/future/meta/objects/item.py +++ b/darwin/future/meta/objects/item.py @@ -6,6 +6,7 @@ from darwin.future.core.items.archive_items import archive_list_of_items from darwin.future.core.items.delete_items import delete_list_of_items from darwin.future.core.items.move_items_to_folder import move_list_of_items_to_folder +from darwin.future.core.items.set_item_priority import set_item_priority from darwin.future.core.items.restore_items import restore_list_of_items from darwin.future.data_objects.item import ItemCore, ItemLayout, ItemSlot from darwin.future.meta.objects.base import MetaBase @@ -68,6 +69,18 @@ def move_to_folder(self, path: str) -> None: filters = {"item_ids": [str(self.id)]} move_list_of_items_to_folder(self.client, team_slug, dataset_id, path, filters) + def set_priority(self, priority: int) -> None: + team_slug, dataset_id = ( + self.meta_params["team_slug"], + self.meta_params["dataset_id"] + if "dataset_id" in self.meta_params + else self.meta_params["dataset_ids"], + ) + assert isinstance(team_slug, str) + dataset_id = cast(Union[int, List[int]], dataset_id) + filters = {"item_ids": [str(self.id)]} + set_item_priority(self.client, team_slug, dataset_id, priority, filters) + def restore(self) -> None: team_slug, dataset_id = ( self.meta_params["team_slug"], diff --git a/darwin/future/meta/queries/item.py b/darwin/future/meta/queries/item.py index e868e8228..0874c1163 100644 --- a/darwin/future/meta/queries/item.py +++ b/darwin/future/meta/queries/item.py @@ -5,6 +5,7 @@ from darwin.future.core.items.delete_items import delete_list_of_items from darwin.future.core.items.get import list_items from darwin.future.core.items.move_items_to_folder import move_list_of_items_to_folder +from darwin.future.core.items.set_item_priority import set_item_priority from darwin.future.core.items.restore_items import restore_list_of_items from darwin.future.core.types.common import QueryString from darwin.future.core.types.query import PaginatedQuery @@ -84,6 +85,27 @@ def move_to_folder(self, path) -> None: filters = {"item_ids": [str(item) for item in ids]} move_list_of_items_to_folder(self.client, team_slug, dataset_ids, path, filters) + def set_priority(self, priority: int) -> None: + if "team_slug" not in self.meta_params: + raise ValueError("Must specify team_slug to query items") + if ( + "dataset_ids" not in self.meta_params + and "dataset_id" not in self.meta_params + ): + raise ValueError("Must specify dataset_ids to query items") + if not priority: + raise ValueError("Must specify priority to set items to") + dataset_ids = ( + self.meta_params["dataset_ids"] + if "dataset_ids" in self.meta_params + else self.meta_params["dataset_id"] + ) + team_slug = self.meta_params["team_slug"] + self.collect_all() + ids = [item.id for item in self] + filters = {"item_ids": [str(item) for item in ids]} + set_item_priority(self.client, team_slug, dataset_ids, priority, filters) + def restore(self) -> None: if "team_slug" not in self.meta_params: raise ValueError("Must specify team_slug to query items") diff --git a/darwin/future/tests/meta/objects/test_itemmeta.py b/darwin/future/tests/meta/objects/test_itemmeta.py index d9134bd5c..4d92a7326 100644 --- a/darwin/future/tests/meta/objects/test_itemmeta.py +++ b/darwin/future/tests/meta/objects/test_itemmeta.py @@ -112,6 +112,64 @@ def test_move_to_folder_with_bad_team_slug(item: Item) -> None: item.move_to_folder(path) +def test_set_priority(item: Item) -> None: + with responses.RequestsMock() as rsps: + team_slug = item.meta_params["team_slug"] + dataset_id = item.meta_params["dataset_id"] + priority = 10 + rsps.add( + rsps.POST, + item.client.config.api_endpoint + f"v2/teams/{team_slug}/items/priority", + status=200, + match=[ + json_params_matcher( + { + "filters": { + "item_ids": [str(item.id)], + "dataset_ids": [dataset_id], + }, + "priority": priority, + } + ) + ], + json={}, + ) + item.set_priority(priority) + + +def test_set_priority_raises_on_incorrect_parameters(item: Item) -> None: + with responses.RequestsMock() as rsps: + team_slug = item.meta_params["team_slug"] + dataset_id = item.meta_params["dataset_id"] + priority = "10" + rsps.add( + rsps.POST, + item.client.config.api_endpoint + f"v2/teams/{team_slug}/items/priority", + status=400, + match=[ + json_params_matcher( + { + "filters": { + "item_ids": [str(item.id)], + "dataset_ids": [dataset_id], + }, + "priority": priority, + } + ) + ], + json={}, + ) + with pytest.raises(BadRequest): + item.set_priority(priority) + + +def test_set_priority_with_bad_team_slug(item: Item) -> None: + with pytest.raises(AssertionError): + priority = 10 + item.meta_params["team_slug"] = 123 + item.set_priority(priority) + + def test_restore(item: Item) -> None: with responses.RequestsMock() as rsps: team_slug = item.meta_params["team_slug"] diff --git a/darwin/future/tests/meta/queries/test_item.py b/darwin/future/tests/meta/queries/test_item.py index abae86b5c..c1d2acb6d 100644 --- a/darwin/future/tests/meta/queries/test_item.py +++ b/darwin/future/tests/meta/queries/test_item.py @@ -148,6 +148,83 @@ def test_move_to_folder_raises_on_incorrect_parameters( item_query.move_to_folder(path) +def test_set_priority( + item_query: ItemQuery, items_json: List[dict], items: List[Item] +) -> None: + with responses.RequestsMock() as rsps: + rsps.add( + rsps.GET, + item_query.client.config.api_endpoint + "v2/teams/test/items", + match=[ + query_param_matcher( + {"page[offset]": "0", "page[size]": "500", "dataset_ids": "1"} + ) + ], + json={"items": items_json, "errors": []}, + ) + team_slug = items[0].meta_params["team_slug"] + dataset_id = items[0].meta_params["dataset_id"] + priority = 10 + rsps.add( + rsps.POST, + items[0].client.config.api_endpoint + + f"v2/teams/{team_slug}/items/priority", + status=200, + match=[ + json_params_matcher( + { + "filters": { + "item_ids": [str(item.id) for item in items], + "dataset_ids": [dataset_id], + }, + "priority": priority, + } + ) + ], + json={}, + ) + item_query.set_priority(priority) + + +def test_set_priority_raises_on_incorrect_parameters( + item_query: ItemQuery, items_json: List[dict], items: List[Item] +) -> None: + with responses.RequestsMock() as rsps: + rsps.add( + rsps.GET, + item_query.client.config.api_endpoint + "v2/teams/test/items", + match=[ + query_param_matcher( + {"page[offset]": "0", "page[size]": "500", "dataset_ids": "1"} + ) + ], + json={"items": items_json, "errors": []}, + ) + team_slug = items[0].meta_params["team_slug"] + dataset_id = items[0].meta_params["dataset_id"] + priority = "10" + rsps.add( + rsps.POST, + items[0].client.config.api_endpoint + + f"v2/teams/{team_slug}/items/priority", + status=400, + match=[ + json_params_matcher( + { + "filters": { + "item_ids": [str(item.id) for item in items], + "dataset_ids": [dataset_id], + }, + "priority": priority, + } + ) + ], + json={}, + ) + with pytest.raises(BadRequest): + item_query.set_priority(priority) + + def test_restore( item_query: ItemQuery, items_json: List[dict], items: List[Item] ) -> None: From 859de117fd5f809463d32d63879c284ddd182bfa Mon Sep 17 00:00:00 2001 From: Nathan Perkins Date: Thu, 16 Nov 2023 19:27:17 +0000 Subject: [PATCH 31/71] automatic ruff --fix changes (#723) * automatic ruff --fix changes * black changes * revert of client --- darwin/cli.py | 3 -- darwin/cli_functions.py | 4 +- darwin/dataset/download_manager.py | 10 ++--- darwin/dataset/remote_dataset.py | 4 +- darwin/dataset/remote_dataset_v1.py | 8 ++-- darwin/dataset/remote_dataset_v2.py | 2 +- darwin/dataset/upload_manager.py | 4 +- darwin/datatypes.py | 1 - darwin/exceptions.py | 1 - darwin/exporter/exporter.py | 2 +- darwin/exporter/formats/coco.py | 1 - darwin/exporter/formats/dataloop.py | 1 - darwin/exporter/formats/mask.py | 45 ++++++++----------- darwin/exporter/formats/nifti.py | 8 +--- darwin/exporter/formats/pascalvoc.py | 1 - darwin/exporter/formats/yolo_segmented.py | 5 +-- darwin/importer/formats/coco.py | 4 +- darwin/importer/formats/csv_tags.py | 8 ++-- darwin/importer/formats/csv_tags_video.py | 6 +-- darwin/importer/formats/dataloop.py | 7 ++- darwin/importer/formats/labelbox.py | 1 - darwin/importer/formats/nifti.py | 18 +++----- darwin/importer/formats/pascal_voc.py | 4 +- darwin/importer/importer.py | 6 +-- darwin/torch/__init__.py | 2 +- darwin/torch/dataset.py | 2 +- darwin/torch/utils.py | 4 +- darwin/utils/utils.py | 26 +++++------ .../importer/formats/import_dataloop_test.py | 11 ++--- 29 files changed, 78 insertions(+), 121 deletions(-) diff --git a/darwin/cli.py b/darwin/cli.py index da18d2ec6..fb78a6502 100644 --- a/darwin/cli.py +++ b/darwin/cli.py @@ -2,10 +2,7 @@ import getpass import os -import platform from argparse import ArgumentParser, Namespace -from datetime import datetime -from json import dumps import requests.exceptions from rich.console import Console diff --git a/darwin/cli_functions.py b/darwin/cli_functions.py index c0ce35322..4caddb77b 100644 --- a/darwin/cli_functions.py +++ b/darwin/cli_functions.py @@ -8,7 +8,7 @@ from glob import glob from itertools import tee from pathlib import Path -from typing import Any, Dict, Iterator, List, NoReturn, Optional, Set, Union +from typing import Dict, Iterator, List, NoReturn, Optional, Set, Union import humanize from rich.console import Console @@ -1065,7 +1065,7 @@ def delete_files( console.print("Cancelled.") return - found_filenames: Set[str] = set([item.filename for item in items_2]) + found_filenames: Set[str] = {item.filename for item in items_2} not_found_filenames: Set[str] = set(files) - found_filenames for filename in not_found_filenames: console.print(f"File not found: {filename}", style="warning") diff --git a/darwin/dataset/download_manager.py b/darwin/dataset/download_manager.py index b851a6c59..de99672d3 100644 --- a/darwin/dataset/download_manager.py +++ b/darwin/dataset/download_manager.py @@ -93,9 +93,7 @@ def download_all_images_from_annotations( raise ValueError(f"Annotation format {annotation_format} not supported") # Verify that there is not already image in the images folder - unfiltered_files = ( - images_path.rglob(f"*") if use_folders else images_path.glob(f"*") - ) + unfiltered_files = images_path.rglob("*") if use_folders else images_path.glob("*") existing_images = { image for image in unfiltered_files if is_image_extension_allowed(image.suffix) } @@ -666,9 +664,9 @@ def _extract_frames_from_segment(path: Path, manifest: dt.SegmentManifest) -> No cap = VideoCapture(str(path)) # Read and save frames. Iterates over every frame because frame seeking in OCV is not reliable or guaranteed. - frames_to_extract = dict( - [(item.frame, item.visible_frame) for item in manifest.items if item.visibility] - ) + frames_to_extract = { + item.frame: item.visible_frame for item in manifest.items if item.visibility + } frame_index = 0 while cap.isOpened(): success, frame = cap.read() diff --git a/darwin/dataset/remote_dataset.py b/darwin/dataset/remote_dataset.py index a09354b41..7f9cc0467 100644 --- a/darwin/dataset/remote_dataset.py +++ b/darwin/dataset/remote_dataset.py @@ -286,7 +286,7 @@ def pull( continue if video_frames and any( - [not slot.frame_urls for slot in annotation.slots] + not slot.frame_urls for slot in annotation.slots ): # will raise if not installed via pip install darwin-py[ocv] try: @@ -632,7 +632,7 @@ def fetch_remote_classes(self, team_wide=False) -> List[Dict[str, Any]]: classes_to_return = [] for cls in all_classes: belongs_to_current_dataset = any( - [dataset["id"] == self.dataset_id for dataset in cls["datasets"]] + dataset["id"] == self.dataset_id for dataset in cls["datasets"] ) cls["available"] = belongs_to_current_dataset if team_wide or belongs_to_current_dataset: diff --git a/darwin/dataset/remote_dataset_v1.py b/darwin/dataset/remote_dataset_v1.py index 084a83cba..57fe9d744 100644 --- a/darwin/dataset/remote_dataset_v1.py +++ b/darwin/dataset/remote_dataset_v1.py @@ -1,6 +1,5 @@ import itertools from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Sequence, Union -from xml.dom import ValidationErr from requests.models import Response @@ -347,9 +346,10 @@ def complete(self, items: Iterator[DatasetItem]) -> None: items : Iterator[DatasetItem] The ``DatasetItem``\\s to be completed. """ - wf_template_id_mapper = lambda item: item.current_workflow[ - "workflow_template_id" - ] + + def wf_template_id_mapper(item): + return item.current_workflow["workflow_template_id"] + input_items: List[DatasetItem] = list(items) # We split into items with and without workflow diff --git a/darwin/dataset/remote_dataset_v2.py b/darwin/dataset/remote_dataset_v2.py index cb50cdacb..215ff05b8 100644 --- a/darwin/dataset/remote_dataset_v2.py +++ b/darwin/dataset/remote_dataset_v2.py @@ -431,7 +431,7 @@ def export( format = "darwin_json_2" elif str_version == "1.0": format = "json" - elif version == None: + elif version is None: format = None else: raise UnknownExportVersion(version) diff --git a/darwin/dataset/upload_manager.py b/darwin/dataset/upload_manager.py index 79b6213b2..58019167e 100644 --- a/darwin/dataset/upload_manager.py +++ b/darwin/dataset/upload_manager.py @@ -76,7 +76,7 @@ def __init__( @staticmethod def parse_v2(payload): if len(payload["slots"]) > 1: - raise NotImplemented("multiple files support not yet implemented") + raise NotImplementedError("multiple files support not yet implemented") slot = payload["slots"][0] return ItemPayload( dataset_item_id=payload.get("id", None), @@ -552,7 +552,7 @@ def upload_function( file_lookup = {file.full_path: file for file in self.local_files} for item in self.pending_items: if len(item.slots) != 1: - raise NotImplemented("Multi file upload is not supported") + raise NotImplementedError("Multi file upload is not supported") upload_id = item.slots[0]["upload_id"] file = file_lookup.get(item.full_path) if not file: diff --git a/darwin/datatypes.py b/darwin/datatypes.py index 63d3552d2..4ea553e73 100644 --- a/darwin/datatypes.py +++ b/darwin/datatypes.py @@ -1,5 +1,4 @@ from dataclasses import dataclass, field -from email.policy import default from enum import Enum, auto from pathlib import Path from typing import ( diff --git a/darwin/exceptions.py b/darwin/exceptions.py index e688dd6ee..e42d3621d 100644 --- a/darwin/exceptions.py +++ b/darwin/exceptions.py @@ -1,4 +1,3 @@ -from cmath import exp from pathlib import Path from pprint import pprint from textwrap import dedent diff --git a/darwin/exporter/exporter.py b/darwin/exporter/exporter.py index 0d1a97ca9..8cf4d95c6 100644 --- a/darwin/exporter/exporter.py +++ b/darwin/exporter/exporter.py @@ -60,7 +60,7 @@ def export_annotations( output_directory : PathLike Where the parsed files will be placed after the operation is complete. """ - print(f"Converting annotations...") + print("Converting annotations...") exporter( darwin_to_dt_gen(file_paths, split_sequences=split_sequences), Path(output_directory), diff --git a/darwin/exporter/formats/coco.py b/darwin/exporter/formats/coco.py index 53e85353b..fbba4d504 100644 --- a/darwin/exporter/formats/coco.py +++ b/darwin/exporter/formats/coco.py @@ -9,7 +9,6 @@ from upolygon import draw_polygon, rle_encode import darwin.datatypes as dt -from darwin.exporter.formats.numpy_encoder import NumpyEncoder from darwin.utils import convert_polygons_to_sequences from darwin.version import __version__ diff --git a/darwin/exporter/formats/dataloop.py b/darwin/exporter/formats/dataloop.py index 1aedd9d77..96fc47bdd 100644 --- a/darwin/exporter/formats/dataloop.py +++ b/darwin/exporter/formats/dataloop.py @@ -5,7 +5,6 @@ import orjson as json import darwin.datatypes as dt -from darwin.exporter.formats.numpy_encoder import NumpyEncoder from darwin.version import __version__ DEPRECATION_MESSAGE = """ diff --git a/darwin/exporter/formats/mask.py b/darwin/exporter/formats/mask.py index 0f7e8b735..78e7037db 100644 --- a/darwin/exporter/formats/mask.py +++ b/darwin/exporter/formats/mask.py @@ -2,9 +2,8 @@ import math import os from csv import writer as csv_writer -from functools import reduce from pathlib import Path -from typing import Dict, Iterable, List, Literal, Optional, Set, Tuple, get_args +from typing import Dict, Iterable, List, Optional, Set, Tuple, get_args import numpy as np @@ -17,7 +16,7 @@ import darwin.datatypes as dt from darwin.exceptions import DarwinException -from darwin.utils import convert_polygons_to_sequences, ispolygon +from darwin.utils import convert_polygons_to_sequences def get_palette(mode: dt.MaskTypes.Mode, categories: List[str]) -> dt.MaskTypes.Palette: @@ -37,7 +36,7 @@ def get_palette(mode: dt.MaskTypes.Mode, categories: List[str]) -> dt.MaskTypes. A dict of categories and their corresponding palette value. """ - if not mode in get_args(dt.MaskTypes.Mode): + if mode not in get_args(dt.MaskTypes.Mode): raise ValueError(f"Unknown mode {mode}.") from DarwinException if not isinstance(categories, list) or not categories: @@ -68,7 +67,7 @@ def get_palette(mode: dt.MaskTypes.Mode, categories: List[str]) -> dt.MaskTypes. if not palette: raise ValueError( - f"Failed to generate a palette.", mode, categories + "Failed to generate a palette.", mode, categories ) from DarwinException return palette @@ -101,14 +100,12 @@ def get_rgb_colours( (x / num_categories, SATURATION_OF_COLOUR, VALUE_OF_COLOUR) for x in range(num_categories - 1) ] - rgb_colour_list: dt.MaskTypes.RgbColorList = list( - map(lambda x: [int(e * 255) for e in colorsys.hsv_to_rgb(*x)], hsv_colours) - ) + rgb_colour_list: dt.MaskTypes.RgbColorList = [ + [int(e * 255) for e in colorsys.hsv_to_rgb(*x)] for x in hsv_colours + ] # Now we add BG class with [0 0 0] RGB value rgb_colour_list.insert(0, [0, 0, 0]) - palette_rgb: dt.MaskTypes.RgbPalette = { - c: rgb for c, rgb in zip(categories, rgb_colour_list) - } + palette_rgb: dt.MaskTypes.RgbPalette = dict(zip(categories, rgb_colour_list)) rgb_colours: dt.MaskTypes.RgbColors = [c for e in rgb_colour_list for c in e] return rgb_colours, palette_rgb @@ -195,7 +192,7 @@ def colours_in_rle( f"Could not find mask with uuid {uuid} in mask lookup table." ) - if not mask.name in colours: + if mask.name not in colours: colours[mask.name] = colour_value return colours # Returns same item as the outset, technically not needed, but best practice. @@ -235,7 +232,7 @@ def get_or_generate_colour(cat_name: str, colours: dt.MaskTypes.ColoursDict) -> ------- int - the integer for the colour name. These will later be reassigned to a wider spread across the colour spectrum. """ - if not cat_name in colours: + if cat_name not in colours: colours[cat_name] = len(colours) + 1 return colours[cat_name] @@ -293,7 +290,7 @@ def render_polygons( for a in filtered_annotations: try: cat = a.annotation_class.name - if not cat in categories: + if cat not in categories: categories.append(cat) if a.annotation_class.annotation_type == "polygon": @@ -368,7 +365,7 @@ def render_raster( mask_annotations: List[dt.AnnotationMask] = [] raster_layer: Optional[dt.RasterLayer] = None - mask_lookup: Dict[str, dt.AnnotationMask] = dict() + mask_lookup: Dict[str, dt.AnnotationMask] = {} for a in annotations: if isinstance(a, dt.VideoAnnotation): @@ -390,11 +387,11 @@ def render_raster( mask_annotations.append(new_mask) - if not new_mask.id in mask_lookup: + if new_mask.id not in mask_lookup: mask_lookup[new_mask.id] = new_mask # Add the category to the list of categories - if not new_mask.name in categories: + if new_mask.name not in categories: categories.append(new_mask.name) if a.annotation_class.annotation_type == "raster_layer" and (rl := data): @@ -415,11 +412,11 @@ def render_raster( raster_layer = new_rl if not raster_layer: - errors.append(ValueError(f"Annotation has no raster layer")) + errors.append(ValueError("Annotation has no raster layer")) return errors, mask, categories, colours if not mask_annotations: - errors.append(ValueError(f"Annotation has no masks")) + errors.append(ValueError("Annotation has no masks")) return errors, mask, categories, colours try: @@ -447,19 +444,15 @@ def export( if len(all_classes_sets) > 0: all_classes: Set[dt.AnnotationClass] = set.union(*all_classes_sets) categories: List[str] = ["__background__"] + sorted( - list( - set( - [c.name for c in all_classes if c.annotation_type in accepted_types] - ) - ), + {c.name for c in all_classes if c.annotation_type in accepted_types}, key=lambda x: x.lower(), ) palette = get_palette(mode, categories) else: categories = ["__background__"] - palette = dict() + palette = {} - colours: dt.MaskTypes.ColoursDict = dict() + colours: dt.MaskTypes.ColoursDict = {} for annotation_file in annotation_files: image_rel_path = os.path.splitext(annotation_file.full_path)[0].lstrip("/") diff --git a/darwin/exporter/formats/nifti.py b/darwin/exporter/formats/nifti.py index dacf21b7a..527c70846 100644 --- a/darwin/exporter/formats/nifti.py +++ b/darwin/exporter/formats/nifti.py @@ -1,6 +1,5 @@ import ast import json as native_json -from asyncore import loop from dataclasses import dataclass from pathlib import Path from typing import Dict, Iterable, List, Optional, Tuple, Union @@ -19,11 +18,9 @@ console.print(import_fail_string) exit() import numpy as np -import orjson as json -from PIL import Image import darwin.datatypes as dt -from darwin.utils import convert_polygons_to_mask, get_progress_bar +from darwin.utils import convert_polygons_to_mask @dataclass @@ -141,7 +138,6 @@ def check_for_error_and_return_imageid( """ - output_volumes = None filename = Path(video_annotation.filename) try: suffixes = filename.suffixes[-2:] @@ -277,7 +273,7 @@ def populate_output_volumes( ) else: continue - class_name = frames[frame_idx].annotation_class.name + frames[frame_idx].annotation_class.name im_mask = convert_polygons_to_mask(polygons, height=height, width=width) volume = output_volumes[series_instance_uid] if view_idx == 0: diff --git a/darwin/exporter/formats/pascalvoc.py b/darwin/exporter/formats/pascalvoc.py index af8acffa4..aa7acd08b 100644 --- a/darwin/exporter/formats/pascalvoc.py +++ b/darwin/exporter/formats/pascalvoc.py @@ -3,7 +3,6 @@ from xml.etree.ElementTree import Element, SubElement, tostring import deprecation -import orjson as json import darwin.datatypes as dt from darwin.utils import attempt_decode diff --git a/darwin/exporter/formats/yolo_segmented.py b/darwin/exporter/formats/yolo_segmented.py index ef599e7e5..88702c1c7 100644 --- a/darwin/exporter/formats/yolo_segmented.py +++ b/darwin/exporter/formats/yolo_segmented.py @@ -1,11 +1,10 @@ from collections import namedtuple from enum import Enum, auto from logging import getLogger -from multiprocessing.pool import CLOSE from pathlib import Path from typing import Iterable, List -from darwin.datatypes import Annotation, AnnotationFile, JSONType, VideoAnnotation +from darwin.datatypes import Annotation, AnnotationFile, VideoAnnotation from darwin.exceptions import DarwinException from darwin.exporter.formats.helpers.yolo_class_builder import ( ClassIndex, @@ -208,7 +207,7 @@ def _handle_polygon( ) return False - except Exception as exc: + except Exception: logger.error( f"An unexpected error occured while exporting annotation at index {annotation_index}." ) diff --git a/darwin/importer/formats/coco.py b/darwin/importer/formats/coco.py index 05034b479..28d4f4f8f 100644 --- a/darwin/importer/formats/coco.py +++ b/darwin/importer/formats/coco.py @@ -97,9 +97,7 @@ def parse_json( for image_id in image_annotations.keys(): image = image_lookup_table[int(image_id)] annotations = list(filter(None, image_annotations[image_id])) - annotation_classes = set( - [annotation.annotation_class for annotation in annotations] - ) + annotation_classes = {annotation.annotation_class for annotation in annotations} remote_path, filename = deconstruct_full_path(image["file_name"]) yield dt.AnnotationFile( path, filename, annotation_classes, annotations, remote_path=remote_path diff --git a/darwin/importer/formats/csv_tags.py b/darwin/importer/formats/csv_tags.py index f181b8296..f10a87880 100644 --- a/darwin/importer/formats/csv_tags.py +++ b/darwin/importer/formats/csv_tags.py @@ -28,13 +28,13 @@ def parse_path(path: Path) -> Optional[List[dt.AnnotationFile]]: with path.open() as f: reader = csv.reader(f) for row in reader: - filename, *tags = map(lambda s: s.strip(), row) + filename, *tags = (s.strip() for s in row) if filename == "": continue annotations = [dt.make_tag(tag) for tag in tags if len(tag) > 0] - annotation_classes = set( - [annotation.annotation_class for annotation in annotations] - ) + annotation_classes = { + annotation.annotation_class for annotation in annotations + } remote_path, filename = deconstruct_full_path(filename) files.append( dt.AnnotationFile( diff --git a/darwin/importer/formats/csv_tags_video.py b/darwin/importer/formats/csv_tags_video.py index 35f05e230..00e9a0c37 100644 --- a/darwin/importer/formats/csv_tags_video.py +++ b/darwin/importer/formats/csv_tags_video.py @@ -30,7 +30,7 @@ def parse_path(path: Path) -> Optional[List[dt.AnnotationFile]]: reader = csv.reader(f) for row in reader: try: - filename, tag, start_frame, end_frame = map(lambda s: s.strip(), row) + filename, tag, start_frame, end_frame = (s.strip() for s in row) except ValueError: continue if filename == "": @@ -51,9 +51,7 @@ def parse_path(path: Path) -> Optional[List[dt.AnnotationFile]]: file_annotation_map[filename].append(annotation) for filename in file_annotation_map: annotations = file_annotation_map[filename] - annotation_classes = set( - [annotation.annotation_class for annotation in annotations] - ) + annotation_classes = {annotation.annotation_class for annotation in annotations} files.append( dt.AnnotationFile( path, diff --git a/darwin/importer/formats/dataloop.py b/darwin/importer/formats/dataloop.py index 207027316..4f546ab09 100644 --- a/darwin/importer/formats/dataloop.py +++ b/darwin/importer/formats/dataloop.py @@ -1,7 +1,6 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Set -import orjson as json import darwin.datatypes as dt from darwin.exceptions import ( @@ -33,9 +32,9 @@ def parse_path(path: Path) -> Optional[dt.AnnotationFile]: annotations: List[dt.Annotation] = list( filter(None, map(_parse_annotation, data["annotations"])) ) - annotation_classes: Set[dt.AnnotationClass] = set( - [annotation.annotation_class for annotation in annotations] - ) + annotation_classes: Set[dt.AnnotationClass] = { + annotation.annotation_class for annotation in annotations + } return dt.AnnotationFile( path, _remove_leading_slash(data["filename"]), diff --git a/darwin/importer/formats/labelbox.py b/darwin/importer/formats/labelbox.py index d6cd48eaa..d8b66fb46 100644 --- a/darwin/importer/formats/labelbox.py +++ b/darwin/importer/formats/labelbox.py @@ -2,7 +2,6 @@ from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Set, cast -import orjson as json from jsonschema import validate from darwin.datatypes import ( diff --git a/darwin/importer/formats/nifti.py b/darwin/importer/formats/nifti.py index 49be7b951..9254a6821 100644 --- a/darwin/importer/formats/nifti.py +++ b/darwin/importer/formats/nifti.py @@ -1,11 +1,9 @@ import sys import warnings -import zipfile from collections import OrderedDict, defaultdict from pathlib import Path from typing import Dict, List, Optional, Sequence, Tuple, Union -import orjson as json from rich.console import Console from darwin.utils import attempt_decode @@ -27,7 +25,6 @@ import darwin.datatypes as dt from darwin.importer.formats.nifti_schemas import nifti_import_schema -from darwin.version import __version__ def parse_path(path: Path) -> Optional[List[dt.AnnotationFile]]: @@ -56,7 +53,7 @@ def parse_path(path: Path) -> Optional[List[dt.AnnotationFile]]: data = attempt_decode(path) try: validate(data, schema=nifti_import_schema) - except Exception as e: + except Exception: console.print( "Skipping file: {} (invalid json file, see schema for details)".format( path @@ -96,7 +93,6 @@ def _parse_nifti( ) -> dt.AnnotationFile: img, pixdims = process_nifti(nib.load(nifti_path)) - shape = img.shape processed_class_map = process_class_map(class_map) video_annotations = [] if mode == "instances": # For each instance produce a video annotation @@ -131,12 +127,10 @@ def _parse_nifti( if _video_annotations is None: continue video_annotations += _video_annotations - annotation_classes = set( - [ - dt.AnnotationClass(class_name, "polygon", "polygon") - for class_name in class_map.values() - ] - ) + annotation_classes = { + dt.AnnotationClass(class_name, "polygon", "polygon") + for class_name in class_map.values() + } return dt.AnnotationFile( path=json_path, filename=str(filename), @@ -376,7 +370,7 @@ def process_nifti( if isinstance(input_data, nib.nifti1.Nifti1Image): img = correct_nifti_header_if_necessary(input_data) img = nib.funcs.as_closest_canonical(img) - axcodes = nib.orientations.aff2axcodes(img.affine) + nib.orientations.aff2axcodes(img.affine) # TODO: Future feature to pass custom ornt could go here. ornt = [[0.0, -1.0], [1.0, -1.0], [1.0, -1.0]] data_array = nib.orientations.apply_orientation(img.get_fdata(), ornt) diff --git a/darwin/importer/formats/pascal_voc.py b/darwin/importer/formats/pascal_voc.py index f8abf441d..4470a6149 100644 --- a/darwin/importer/formats/pascal_voc.py +++ b/darwin/importer/formats/pascal_voc.py @@ -55,9 +55,7 @@ def parse_path(path: Path) -> Optional[dt.AnnotationFile]: annotations: List[dt.Annotation] = list( filter(None, map(_parse_annotation, root.findall("object"))) ) - annotation_classes = set( - [annotation.annotation_class for annotation in annotations] - ) + annotation_classes = {annotation.annotation_class for annotation in annotations} return dt.AnnotationFile( path, filename, annotation_classes, annotations, remote_path="/" diff --git a/darwin/importer/importer.py b/darwin/importer/importer.py index 06b5f5af7..7bc12edc6 100644 --- a/darwin/importer/importer.py +++ b/darwin/importer/importer.py @@ -603,9 +603,9 @@ def _warn_unsupported_annotations(parsed_files: List[AnnotationFile]) -> None: if annotation.annotation_class.annotation_type in UNSUPPORTED_CLASSES: skipped_annotations.append(annotation) if len(skipped_annotations) > 0: - types = set( - map(lambda c: c.annotation_class.annotation_type, skipped_annotations) - ) # noqa: C417 + types = { + c.annotation_class.annotation_type for c in skipped_annotations + } # noqa: C417 console.print( f"Import of annotation class types '{', '.join(types)}' is not yet supported. Skipping {len(skipped_annotations)} " + "annotations from '{parsed_file.full_path}'.\n", diff --git a/darwin/torch/__init__.py b/darwin/torch/__init__.py index ef55392ff..4c4fca583 100644 --- a/darwin/torch/__init__.py +++ b/darwin/torch/__init__.py @@ -15,7 +15,7 @@ import torch # noqa except ImportError: raise ImportError( - f"darwin.torch requires pytorch and torchvision. Install it using: pip install torch torchvision" + "darwin.torch requires pytorch and torchvision. Install it using: pip install torch torchvision" ) from None from .dataset import get_dataset # noqa diff --git a/darwin/torch/dataset.py b/darwin/torch/dataset.py index 433232b98..fb55e7678 100644 --- a/darwin/torch/dataset.py +++ b/darwin/torch/dataset.py @@ -427,7 +427,7 @@ def __init__( self, transform: Optional[Union[List[Callable], Callable]] = None, **kwargs ): super().__init__(annotation_type="polygon", **kwargs) - if not "__background__" in self.classes: + if "__background__" not in self.classes: self.classes.insert(0, "__background__") self.num_classes += 1 if transform is not None and isinstance(transform, list): diff --git a/darwin/torch/utils.py b/darwin/torch/utils.py index f5ffcac94..00e20178b 100644 --- a/darwin/torch/utils.py +++ b/darwin/torch/utils.py @@ -1,7 +1,7 @@ import os import sys from pathlib import Path -from typing import Iterable, List, Optional, Tuple, Union +from typing import Iterable, List, Optional, Tuple import numpy as np import torch @@ -31,7 +31,7 @@ def flatten_masks_by_category(masks: torch.Tensor, cats: List[int]) -> torch.Ten assert isinstance(masks, torch.Tensor) assert isinstance(cats, List) assert masks.shape[0] == len(cats) - order_of_polygons = [i for i in range(1, len(cats) + 1)] + order_of_polygons = list(range(1, len(cats) + 1)) polygon_mapping = {order: cat for cat, order in zip(cats, order_of_polygons)} BACKGROUND: int = 0 polygon_mapping[BACKGROUND] = 0 diff --git a/darwin/utils/utils.py b/darwin/utils/utils.py index 544ca9370..3de915014 100644 --- a/darwin/utils/utils.py +++ b/darwin/utils/utils.py @@ -25,18 +25,16 @@ import orjson as json import requests from json_stream.base import PersistentStreamingJSONObject -from jsonschema import exceptions, validators -from requests import Response, request +from jsonschema import validators +from requests import Response from rich.progress import ProgressType, track from upolygon import draw_polygon import darwin.datatypes as dt from darwin.config import Config from darwin.exceptions import ( - AnnotationFileValidationError, MissingSchema, OutdatedDarwinJSONFormat, - UnknownAnnotationFileSchema, UnrecognizableFileEncoding, UnsupportedFileType, ) @@ -89,7 +87,7 @@ def is_extension_allowed_by_filename(filename: str) -> bool: bool Whether or not the given extension of the filename is allowed. """ - return any([filename.lower().endswith(ext) for ext in SUPPORTED_EXTENSIONS]) + return any(filename.lower().endswith(ext) for ext in SUPPORTED_EXTENSIONS) @deprecation.deprecated(deprecated_in="0.8.4", current_version=__version__) @@ -126,7 +124,7 @@ def is_image_extension_allowed_by_filename(filename: str) -> bool: bool Whether or not the given extension is allowed. """ - return any([filename.lower().endswith(ext) for ext in SUPPORTED_IMAGE_EXTENSIONS]) + return any(filename.lower().endswith(ext) for ext in SUPPORTED_IMAGE_EXTENSIONS) @deprecation.deprecated(deprecated_in="0.8.4", current_version=__version__) @@ -161,7 +159,7 @@ def is_video_extension_allowed_by_filename(extension: str) -> bool: bool Whether or not the given extension is allowed. """ - return any([extension.lower().endswith(ext) for ext in SUPPORTED_VIDEO_EXTENSIONS]) + return any(extension.lower().endswith(ext) for ext in SUPPORTED_VIDEO_EXTENSIONS) @deprecation.deprecated(deprecated_in="0.8.4", current_version=__version__) @@ -536,7 +534,6 @@ def _parse_darwin_v2(path: Path, data: Dict[str, Any]) -> dt.AnnotationFile: annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations( data ) - annotation_classes: Set[dt.AnnotationClass] = { annotation.annotation_class for annotation in annotations } @@ -615,9 +612,9 @@ def _parse_darwin_image( annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations( data ) - annotation_classes: Set[dt.AnnotationClass] = set( - [annotation.annotation_class for annotation in annotations] - ) + annotation_classes: Set[dt.AnnotationClass] = { + annotation.annotation_class for annotation in annotations + } slot = dt.Slot( name=None, @@ -1011,10 +1008,9 @@ def split_video_annotation(annotation: dt.AnnotationFile) -> List[dt.AnnotationF for a in annotation.annotations if isinstance(a, dt.VideoAnnotation) and i in a.frames ] - - annotation_classes: Set[dt.AnnotationClass] = set( - [annotation.annotation_class for annotation in annotations] - ) + annotation_classes: Set[dt.AnnotationClass] = { + annotation.annotation_class for annotation in annotations + } filename: str = f"{Path(annotation.filename).stem}/{i:07d}.png" frame_annotations.append( dt.AnnotationFile( diff --git a/tests/darwin/importer/formats/import_dataloop_test.py b/tests/darwin/importer/formats/import_dataloop_test.py index 944d6be57..de4e4059e 100644 --- a/tests/darwin/importer/formats/import_dataloop_test.py +++ b/tests/darwin/importer/formats/import_dataloop_test.py @@ -58,24 +58,21 @@ def test_returns_none_if_file_extension_is_not_json( @patch( "darwin.importer.formats.dataloop._remove_leading_slash", ) - @patch("darwin.importer.formats.dataloop.json.loads") - @patch("darwin.importer.formats.dataloop.Path.open") + @patch("darwin.importer.formats.dataloop.attempt_decode") @patch("darwin.importer.formats.dataloop._parse_annotation") def test_opens_annotations_file_and_parses( self, _parse_annotation_mock: MagicMock, - path_open_mock: MagicMock, - json_load_mock: MagicMock, + attempt_decode_mock: MagicMock, mock_remove_leading_slash: MagicMock, ): - json_load_mock.return_value = self.DARWIN_PARSED_DATA + attempt_decode_mock.return_value = self.DARWIN_PARSED_DATA test_path = "foo.json" parse_path(Path(test_path)) self.assertEqual(_parse_annotation_mock.call_count, 3) - path_open_mock.assert_called_once() - json_load_mock.assert_called_once() + attempt_decode_mock.assert_called_once() mock_remove_leading_slash.assert_called_once() From ce7f4d374a3b979b48db3be2b34225d6ba92cc12 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Fri, 17 Nov 2023 16:22:36 +0100 Subject: [PATCH 32/71] minor updates to tests --- tests/darwin/datatypes_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/darwin/datatypes_test.py b/tests/darwin/datatypes_test.py index 1311d6867..8153bdcfa 100644 --- a/tests/darwin/datatypes_test.py +++ b/tests/darwin/datatypes_test.py @@ -7,7 +7,7 @@ class TestMakePolygon: def test_it_returns_annotation_with_default_params(self): class_name: str = "class_name" points: List[Point] = [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 1, "y": 2}] - annotation = make_polygon(class_name, points) + annotation = make_polygon(class_name, points, darwin_v1=True) assert_annotation_class(annotation, class_name, "polygon") @@ -18,9 +18,10 @@ def test_it_returns_annotation_with_bounding_box(self): class_name: str = "class_name" points: List[Point] = [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 1, "y": 2}] bbox: Dict[str, float] = {"x": 1, "y": 2, "w": 2, "h": 2} - annotation = make_polygon(class_name, points, bbox) + annotation = make_polygon(class_name, points, bounding_box=bbox, darwin_v1=True) assert_annotation_class(annotation, class_name, "polygon") + print(annotation) path = annotation.data.get("path") assert path == points From c66ceb87ff11bc17b80d9abab3705224446ab742 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Fri, 17 Nov 2023 16:25:25 +0100 Subject: [PATCH 33/71] added make polygon tests for darwin_v2 format --- tests/darwin/datatypes_test.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/tests/darwin/datatypes_test.py b/tests/darwin/datatypes_test.py index 8153bdcfa..040a94cd2 100644 --- a/tests/darwin/datatypes_test.py +++ b/tests/darwin/datatypes_test.py @@ -4,7 +4,7 @@ class TestMakePolygon: - def test_it_returns_annotation_with_default_params(self): + def test_it_returns_annotation_with_default_params_darwin_v1(self): class_name: str = "class_name" points: List[Point] = [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 1, "y": 2}] annotation = make_polygon(class_name, points, darwin_v1=True) @@ -14,14 +14,13 @@ def test_it_returns_annotation_with_default_params(self): path = annotation.data.get("path") assert path == points - def test_it_returns_annotation_with_bounding_box(self): + def test_it_returns_annotation_with_bounding_box_darwin_v1(self): class_name: str = "class_name" points: List[Point] = [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 1, "y": 2}] bbox: Dict[str, float] = {"x": 1, "y": 2, "w": 2, "h": 2} annotation = make_polygon(class_name, points, bounding_box=bbox, darwin_v1=True) assert_annotation_class(annotation, class_name, "polygon") - print(annotation) path = annotation.data.get("path") assert path == points @@ -29,6 +28,30 @@ def test_it_returns_annotation_with_bounding_box(self): class_bbox = annotation.data.get("bounding_box") assert class_bbox == bbox + def test_it_returns_annotation_with_default_params_darwin_v2(self): + class_name: str = "class_name" + points: List[Point] = [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 1, "y": 2}] + annotation = make_polygon(class_name, points) + + assert_annotation_class(annotation, class_name, "polygon") + + path = annotation.data.get("paths") + assert path == [points] + + def test_it_returns_annotation_with_bounding_box_darwin_v2(self): + class_name: str = "class_name" + points: List[Point] = [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 1, "y": 2}] + bbox: Dict[str, float] = {"x": 1, "y": 2, "w": 2, "h": 2} + annotation = make_polygon(class_name, points, bounding_box=bbox) + + assert_annotation_class(annotation, class_name, "polygon") + + path = annotation.data.get("paths") + assert path == [points] + + class_bbox = annotation.data.get("bounding_box") + assert class_bbox == bbox + class TestMakeComplexPolygon: def test_it_returns_annotation_with_default_params(self): From 691eed7074df37bd40559b5e836887f560bb7cf2 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Fri, 17 Nov 2023 17:20:39 +0100 Subject: [PATCH 34/71] updating tests to accomidate darwin V2 format --- darwin/exporter/formats/coco.py | 3 + .../exporter/formats/export_coco_test.py | 2 +- .../importer/formats/import_labelbox_test.py | 96 ++++++++++--------- .../importer/formats/import_nifti_test.py | 1 + .../formats/import_superannotate_test.py | 11 ++- 5 files changed, 64 insertions(+), 49 deletions(-) diff --git a/darwin/exporter/formats/coco.py b/darwin/exporter/formats/coco.py index fbba4d504..0b001c040 100644 --- a/darwin/exporter/formats/coco.py +++ b/darwin/exporter/formats/coco.py @@ -492,6 +492,7 @@ def _build_annotation( categories: Dict[str, int], ) -> Optional[Dict[str, Any]]: annotation_type = annotation.annotation_class.annotation_type + if annotation_type == "polygon": sequences = convert_polygons_to_sequences( annotation.data["path"], rounding=False @@ -561,6 +562,7 @@ def _build_annotation( return _build_annotation( annotation_file, annotation_id, + # TODO Update this to V2 dt.make_polygon( annotation.annotation_class.name, [ @@ -571,6 +573,7 @@ def _build_annotation( ], None, annotation.subs, + darwin_v1=True ), categories, ) diff --git a/tests/darwin/exporter/formats/export_coco_test.py b/tests/darwin/exporter/formats/export_coco_test.py index ecdac9aed..3cdd2050a 100644 --- a/tests/darwin/exporter/formats/export_coco_test.py +++ b/tests/darwin/exporter/formats/export_coco_test.py @@ -16,7 +16,7 @@ def annotation_file(self) -> dt.AnnotationFile: annotations=[], ) - def test_polygon_include_extras(self, annotation_file: dt.AnnotationFile): + def test_polygon_include_extras_darwin(self, annotation_file: dt.AnnotationFile): polygon = dt.Annotation( dt.AnnotationClass("polygon_class", "polygon"), {"path": [{"x": 1, "y": 1}, {"x": 2, "y": 2}, {"x": 1, "y": 2}]}, diff --git a/tests/darwin/importer/formats/import_labelbox_test.py b/tests/darwin/importer/formats/import_labelbox_test.py index f01f7b320..b6cc8ef4e 100644 --- a/tests/darwin/importer/formats/import_labelbox_test.py +++ b/tests/darwin/importer/formats/import_labelbox_test.py @@ -344,54 +344,57 @@ def test_it_raises_if_polygon_point_has_missing_y(self, file_path: Path): assert "'y' is a required property" in str(error.value) def test_it_imports_polygon_images(self, file_path: Path): - json: str = """ - [ - { - "Label":{ - "objects":[ - { - "title":"Fish", - "polygon": [ - {"x": 3665.814, "y": 351.628}, - {"x": 3762.93, "y": 810.419}, - {"x": 3042.93, "y": 914.233} - ] - } - ], - "classifications": [] - }, - "External ID": "demo-image-7.jpg" - } - ] - """ + json: str = """ + [ + { + "Label":{ + "objects":[ + { + "title":"Fish", + "polygon": [ + {"x": 3665.814, "y": 351.628}, + {"x": 3762.93, "y": 810.419}, + {"x": 3042.93, "y": 914.233} + ] + } + ], + "classifications": [] + }, + "External ID": "demo-image-7.jpg" + } + ] + """ - file_path.write_text(json) + file_path.write_text(json) - annotation_files: Optional[List[AnnotationFile]] = parse_path(file_path) - assert annotation_files is not None + annotation_files: Optional[List[AnnotationFile]] = parse_path(file_path) + assert annotation_files is not None - annotation_file: AnnotationFile = annotation_files.pop() - assert annotation_file.path == file_path - assert annotation_file.filename == "demo-image-7.jpg" - assert annotation_file.annotation_classes - assert annotation_file.remote_path == "/" + annotation_file: AnnotationFile = annotation_files.pop() + assert annotation_file.path == file_path + assert annotation_file.filename == "demo-image-7.jpg" + assert annotation_file.annotation_classes + assert annotation_file.remote_path == "/" - assert annotation_file.annotations + assert annotation_file.annotations - polygon_annotation: Annotation = cast( - Annotation, annotation_file.annotations.pop() - ) - assert_polygon( - polygon_annotation, - [ - {"x": 3665.814, "y": 351.628}, - {"x": 3762.93, "y": 810.419}, - {"x": 3042.93, "y": 914.233}, - ], - ) + polygon_annotation: Annotation = cast( + Annotation, annotation_file.annotations.pop() + ) + + print(polygon_annotation) + + assert_polygon( + polygon_annotation, + [ + {"x": 3665.814, "y": 351.628}, + {"x": 3762.93, "y": 810.419}, + {"x": 3042.93, "y": 914.233}, + ], + ) - annotation_class = polygon_annotation.annotation_class - assert_annotation_class(annotation_class, "Fish", "polygon") + annotation_class = polygon_annotation.annotation_class + assert_annotation_class(annotation_class, "Fish", "polygon") def test_it_imports_point_images(self, file_path: Path): json: str = """ @@ -729,9 +732,12 @@ def assert_bbox(annotation: Annotation, x: float, y: float, h: float, w: float) def assert_polygon(annotation: Annotation, points: List[Point]) -> None: - actual_points = annotation.data.get("path") - assert actual_points - assert actual_points == points + actual_points = annotation.data.get("paths") + #Assumes Darwin v2 format + if len(actual_points) == 1: + actual_points = actual_points[0] + assert actual_points + assert actual_points == points def assert_point(annotation: Annotation, point: Point) -> None: diff --git a/tests/darwin/importer/formats/import_nifti_test.py b/tests/darwin/importer/formats/import_nifti_test.py index 8527d153d..b22b9b197 100644 --- a/tests/darwin/importer/formats/import_nifti_test.py +++ b/tests/darwin/importer/formats/import_nifti_test.py @@ -54,6 +54,7 @@ def test_image_annotation_nifti_import_single_slot(team_slug: str): "r", ) ) + assert ( output_json_string["annotations"][0]["frames"] == expected_json_string["annotations"][0]["frames"] diff --git a/tests/darwin/importer/formats/import_superannotate_test.py b/tests/darwin/importer/formats/import_superannotate_test.py index 2b28ada40..7b529af79 100644 --- a/tests/darwin/importer/formats/import_superannotate_test.py +++ b/tests/darwin/importer/formats/import_superannotate_test.py @@ -891,9 +891,14 @@ def assert_bbox(annotation: Annotation, x: float, y: float, h: float, w: float) def assert_polygon(annotation: Annotation, points: List[Point]) -> None: - actual_points = annotation.data.get("path") - assert actual_points - assert actual_points == points + actual_points = annotation.data.get("paths") + assert actual_points + + # Drawin v2 uses a list of lists for paths [][] + if len(actual_points) == 1: + actual_points = actual_points[0] + + assert actual_points == points def assert_point(annotation: Annotation, point: Point) -> None: From 7106dc5e52128adbf47ee14bf7ffea52ed65735f Mon Sep 17 00:00:00 2001 From: Christoffer Date: Fri, 17 Nov 2023 17:20:51 +0100 Subject: [PATCH 35/71] black --- darwin/exporter/formats/coco.py | 2 +- .../importer/formats/import_labelbox_test.py | 60 +++++++++---------- .../formats/import_superannotate_test.py | 12 ++-- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/darwin/exporter/formats/coco.py b/darwin/exporter/formats/coco.py index 0b001c040..2b9709842 100644 --- a/darwin/exporter/formats/coco.py +++ b/darwin/exporter/formats/coco.py @@ -573,7 +573,7 @@ def _build_annotation( ], None, annotation.subs, - darwin_v1=True + darwin_v1=True, ), categories, ) diff --git a/tests/darwin/importer/formats/import_labelbox_test.py b/tests/darwin/importer/formats/import_labelbox_test.py index b6cc8ef4e..c29ae918e 100644 --- a/tests/darwin/importer/formats/import_labelbox_test.py +++ b/tests/darwin/importer/formats/import_labelbox_test.py @@ -344,7 +344,7 @@ def test_it_raises_if_polygon_point_has_missing_y(self, file_path: Path): assert "'y' is a required property" in str(error.value) def test_it_imports_polygon_images(self, file_path: Path): - json: str = """ + json: str = """ [ { "Label":{ @@ -365,36 +365,36 @@ def test_it_imports_polygon_images(self, file_path: Path): ] """ - file_path.write_text(json) + file_path.write_text(json) - annotation_files: Optional[List[AnnotationFile]] = parse_path(file_path) - assert annotation_files is not None + annotation_files: Optional[List[AnnotationFile]] = parse_path(file_path) + assert annotation_files is not None - annotation_file: AnnotationFile = annotation_files.pop() - assert annotation_file.path == file_path - assert annotation_file.filename == "demo-image-7.jpg" - assert annotation_file.annotation_classes - assert annotation_file.remote_path == "/" + annotation_file: AnnotationFile = annotation_files.pop() + assert annotation_file.path == file_path + assert annotation_file.filename == "demo-image-7.jpg" + assert annotation_file.annotation_classes + assert annotation_file.remote_path == "/" - assert annotation_file.annotations + assert annotation_file.annotations - polygon_annotation: Annotation = cast( - Annotation, annotation_file.annotations.pop() - ) + polygon_annotation: Annotation = cast( + Annotation, annotation_file.annotations.pop() + ) - print(polygon_annotation) + print(polygon_annotation) - assert_polygon( - polygon_annotation, - [ - {"x": 3665.814, "y": 351.628}, - {"x": 3762.93, "y": 810.419}, - {"x": 3042.93, "y": 914.233}, - ], - ) + assert_polygon( + polygon_annotation, + [ + {"x": 3665.814, "y": 351.628}, + {"x": 3762.93, "y": 810.419}, + {"x": 3042.93, "y": 914.233}, + ], + ) - annotation_class = polygon_annotation.annotation_class - assert_annotation_class(annotation_class, "Fish", "polygon") + annotation_class = polygon_annotation.annotation_class + assert_annotation_class(annotation_class, "Fish", "polygon") def test_it_imports_point_images(self, file_path: Path): json: str = """ @@ -732,12 +732,12 @@ def assert_bbox(annotation: Annotation, x: float, y: float, h: float, w: float) def assert_polygon(annotation: Annotation, points: List[Point]) -> None: - actual_points = annotation.data.get("paths") - #Assumes Darwin v2 format - if len(actual_points) == 1: - actual_points = actual_points[0] - assert actual_points - assert actual_points == points + actual_points = annotation.data.get("paths") + # Assumes Darwin v2 format + if len(actual_points) == 1: + actual_points = actual_points[0] + assert actual_points + assert actual_points == points def assert_point(annotation: Annotation, point: Point) -> None: diff --git a/tests/darwin/importer/formats/import_superannotate_test.py b/tests/darwin/importer/formats/import_superannotate_test.py index 7b529af79..bad980965 100644 --- a/tests/darwin/importer/formats/import_superannotate_test.py +++ b/tests/darwin/importer/formats/import_superannotate_test.py @@ -891,14 +891,14 @@ def assert_bbox(annotation: Annotation, x: float, y: float, h: float, w: float) def assert_polygon(annotation: Annotation, points: List[Point]) -> None: - actual_points = annotation.data.get("paths") - assert actual_points + actual_points = annotation.data.get("paths") + assert actual_points - # Drawin v2 uses a list of lists for paths [][] - if len(actual_points) == 1: - actual_points = actual_points[0] + # Drawin v2 uses a list of lists for paths [][] + if len(actual_points) == 1: + actual_points = actual_points[0] - assert actual_points == points + assert actual_points == points def assert_point(annotation: Annotation, point: Point) -> None: From 179da547d374335cc833a3216339870223ef20f2 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Fri, 17 Nov 2023 18:29:26 +0100 Subject: [PATCH 36/71] added nifty V2 test --- .../importer/formats/import_nifti_test.py | 19 +++++++++++------- tests/data.zip | Bin 334089 -> 487802 bytes 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/darwin/importer/formats/import_nifti_test.py b/tests/darwin/importer/formats/import_nifti_test.py index b22b9b197..1ee2a537f 100644 --- a/tests/darwin/importer/formats/import_nifti_test.py +++ b/tests/darwin/importer/formats/import_nifti_test.py @@ -16,13 +16,14 @@ from tests.fixtures import * -def test_image_annotation_nifti_import_single_slot(team_slug: str): +def test_image_annotation_nifti_import_single_slot(team_slug_darwin_json_v2: str): + print(team_slug_darwin_json_v2) with tempfile.TemporaryDirectory() as tmpdir: with ZipFile("tests/data.zip") as zfile: zfile.extractall(tmpdir) label_path = ( Path(tmpdir) - / team_slug + / team_slug_darwin_json_v2 / "nifti" / "releases" / "latest" @@ -48,26 +49,30 @@ def test_image_annotation_nifti_import_single_slot(team_slug: str): output_json_string = json.loads( serialise_annotation_file(annotation_file, as_dict=False) ) + expected_json_string = json.load( open( - Path(tmpdir) / team_slug / "nifti" / "vol0_annotation_file.json", + Path(tmpdir) / team_slug_darwin_json_v2 / "nifti" / "vol0_annotation_file.json", "r", ) ) + expected_output_frames = expected_json_string['annotations'][0]['frames'] + assert ( output_json_string["annotations"][0]["frames"] - == expected_json_string["annotations"][0]["frames"] + == expected_output_frames ) -def test_image_annotation_nifti_import_multi_slot(team_slug: str): +def test_image_annotation_nifti_import_multi_slot(team_slug_darwin_json_v2: str): + print(team_slug_darwin_json_v2) with tempfile.TemporaryDirectory() as tmpdir: with ZipFile("tests/data.zip") as zfile: zfile.extractall(tmpdir) label_path = ( Path(tmpdir) - / team_slug + / team_slug_darwin_json_v2 / "nifti" / "releases" / "latest" @@ -98,7 +103,7 @@ def test_image_annotation_nifti_import_multi_slot(team_slug: str): expected_json_string = json.load( open( Path(tmpdir) - / team_slug + / team_slug_darwin_json_v2 / "nifti" / "vol0_annotation_file_multi_slot.json", "r", diff --git a/tests/data.zip b/tests/data.zip index 3fd227be317aeec7095a964e26e0baf5dd07a0b0..570916f2aa30c47a499e9f4fdce6b1778bad183d 100644 GIT binary patch delta 123427 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 z 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 H2|fNF+BgH= From d0b33e5224d7341d3584595077340e0f033b7978 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Fri, 17 Nov 2023 18:33:39 +0100 Subject: [PATCH 37/71] black --- darwin/dataset/local_dataset.py | 5 ++--- darwin/datatypes.py | 3 +-- tests/darwin/importer/formats/import_nifti_test.py | 12 +++++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/darwin/dataset/local_dataset.py b/darwin/dataset/local_dataset.py index db860e245..4645110c7 100644 --- a/darwin/dataset/local_dataset.py +++ b/darwin/dataset/local_dataset.py @@ -1,7 +1,6 @@ import multiprocessing as mp from pathlib import Path from typing import Any, Dict, Iterator, List, Optional, Tuple -from xmlrpc.client import Boolean import numpy as np from PIL import Image as PILImage @@ -68,7 +67,7 @@ def __init__( split: str = "default", split_type: str = "random", release_name: Optional[str] = None, - keep_empty_annotations: Boolean = False, + keep_empty_annotations: bool = False, ): self.dataset_path = dataset_path self.annotation_type = annotation_type @@ -134,7 +133,7 @@ def _setup_annotations_and_images( split, partition, split_type, - keep_empty_annotations=False, + keep_empty_annotations: bool = False, ): # Find all the annotations and their corresponding images for annotation_path in sorted(annotations_dir.glob("**/*.json")): diff --git a/darwin/datatypes.py b/darwin/datatypes.py index 4ea553e73..b0a28d1c8 100644 --- a/darwin/datatypes.py +++ b/darwin/datatypes.py @@ -14,7 +14,6 @@ Tuple, Union, ) -from xmlrpc.client import Boolean try: from numpy.typing import NDArray @@ -538,7 +537,7 @@ def make_polygon( bounding_box: Optional[Dict] = None, subs: Optional[List[SubAnnotation]] = None, slot_names: Optional[List[str]] = None, - darwin_v1: Boolean = False, + darwin_v1: bool = False, ) -> Annotation: """ Creates and returns a polygon annotation. diff --git a/tests/darwin/importer/formats/import_nifti_test.py b/tests/darwin/importer/formats/import_nifti_test.py index 1ee2a537f..52cbe8a46 100644 --- a/tests/darwin/importer/formats/import_nifti_test.py +++ b/tests/darwin/importer/formats/import_nifti_test.py @@ -52,16 +52,18 @@ def test_image_annotation_nifti_import_single_slot(team_slug_darwin_json_v2: str expected_json_string = json.load( open( - Path(tmpdir) / team_slug_darwin_json_v2 / "nifti" / "vol0_annotation_file.json", + Path(tmpdir) + / team_slug_darwin_json_v2 + / "nifti" + / "vol0_annotation_file.json", "r", ) ) - expected_output_frames = expected_json_string['annotations'][0]['frames'] - + expected_output_frames = expected_json_string["annotations"][0]["frames"] + assert ( - output_json_string["annotations"][0]["frames"] - == expected_output_frames + output_json_string["annotations"][0]["frames"] == expected_output_frames ) From 09b105eed12f8d0a3d21beaf6211da85725473d5 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Mon, 20 Nov 2023 10:38:29 +0100 Subject: [PATCH 38/71] refactor --- darwin/dataset/local_dataset.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/darwin/dataset/local_dataset.py b/darwin/dataset/local_dataset.py index 4645110c7..58e7f4afb 100644 --- a/darwin/dataset/local_dataset.py +++ b/darwin/dataset/local_dataset.py @@ -140,8 +140,7 @@ def _setup_annotations_and_images( darwin_json = stream_darwin_json(annotation_path) image_path = get_image_path_from_stream(darwin_json, images_dir) if image_path.exists(): - if not keep_empty_annotations: - if len(darwin_json["annotations"]) < 1: + if not keep_empty_annotations and len(darwin_json["annotations"]) < 1: continue self.images_path.append(image_path) self.annotations_path.append(annotation_path) From 5b1ddc4b144de8335a9be90a41be132592c6043f Mon Sep 17 00:00:00 2001 From: Christoffer Date: Mon, 20 Nov 2023 10:39:00 +0100 Subject: [PATCH 39/71] ruff --fix --- darwin/dataset/local_dataset.py | 2 +- darwin/torch/dataset.py | 1 - darwin/utils/utils.py | 1 - deploy/revert_nightly_setup.py | 2 - e2e_tests/conftest.py | 2 +- e2e_tests/helpers.py | 10 +---- e2e_tests/test_darwin.py | 1 - tests/darwin/dataset/download_manager_test.py | 22 ++++------- .../exporter/formats/export_darwin_test.py | 1 - .../exporter/formats/export_mask_test.py | 39 +++++++++---------- .../exporter/formats/export_nifti_test.py | 4 +- .../importer/formats/import_nifti_test.py | 2 +- tests/darwin/importer/importer_test.py | 6 +-- tests/darwin/torch/utils_test.py | 4 +- tests/darwin/utils/find_files_test.py | 9 ++--- tests/darwin/utils_test.py | 7 ++-- .../test_run_cli_command.py | 1 - 17 files changed, 43 insertions(+), 71 deletions(-) diff --git a/darwin/dataset/local_dataset.py b/darwin/dataset/local_dataset.py index 58e7f4afb..cdec1bf05 100644 --- a/darwin/dataset/local_dataset.py +++ b/darwin/dataset/local_dataset.py @@ -141,7 +141,7 @@ def _setup_annotations_and_images( image_path = get_image_path_from_stream(darwin_json, images_dir) if image_path.exists(): if not keep_empty_annotations and len(darwin_json["annotations"]) < 1: - continue + continue self.images_path.append(image_path) self.annotations_path.append(annotation_path) continue diff --git a/darwin/torch/dataset.py b/darwin/torch/dataset.py index fb55e7678..c0af1fb72 100644 --- a/darwin/torch/dataset.py +++ b/darwin/torch/dataset.py @@ -6,7 +6,6 @@ from torch.functional import Tensor from torchvision.transforms.functional import to_tensor -import darwin from darwin.cli_functions import _error, _load_client from darwin.client import Client from darwin.dataset.identifier import DatasetIdentifier diff --git a/darwin/utils/utils.py b/darwin/utils/utils.py index 3de915014..b333bb69d 100644 --- a/darwin/utils/utils.py +++ b/darwin/utils/utils.py @@ -3,7 +3,6 @@ """ import platform import re -from dataclasses import asdict from pathlib import Path from typing import ( TYPE_CHECKING, diff --git a/deploy/revert_nightly_setup.py b/deploy/revert_nightly_setup.py index bd1e6f698..c5b4a161b 100644 --- a/deploy/revert_nightly_setup.py +++ b/deploy/revert_nightly_setup.py @@ -1,8 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from datetime import datetime -from os import path from pathlib import Path diff --git a/e2e_tests/conftest.py b/e2e_tests/conftest.py index b44f786da..d26b4fbac 100644 --- a/e2e_tests/conftest.py +++ b/e2e_tests/conftest.py @@ -9,7 +9,7 @@ from darwin.future.data_objects.typing import UnknownType from e2e_tests.exceptions import E2EEnvironmentVariableNotSet -from e2e_tests.objects import ConfigValues, E2EDataset +from e2e_tests.objects import ConfigValues from e2e_tests.setup_tests import setup_tests, teardown_tests diff --git a/e2e_tests/helpers.py b/e2e_tests/helpers.py index 8d3b3d393..0c14b37f3 100644 --- a/e2e_tests/helpers.py +++ b/e2e_tests/helpers.py @@ -1,18 +1,10 @@ -import re -import tempfile -import uuid -from pathlib import Path from subprocess import run from time import sleep -from typing import Generator, Optional, Tuple +from typing import Optional -import pytest from attr import dataclass -from cv2 import exp from darwin.exceptions import DarwinException -from e2e_tests.objects import E2EDataset -from e2e_tests.setup_tests import create_random_image @dataclass diff --git a/e2e_tests/test_darwin.py b/e2e_tests/test_darwin.py index dd03d8f3c..1f495817d 100644 --- a/e2e_tests/test_darwin.py +++ b/e2e_tests/test_darwin.py @@ -4,7 +4,6 @@ import tempfile import uuid from pathlib import Path -from time import sleep from typing import Generator import pytest diff --git a/tests/darwin/dataset/download_manager_test.py b/tests/darwin/dataset/download_manager_test.py index de9e1da4e..4f46c1ca1 100644 --- a/tests/darwin/dataset/download_manager_test.py +++ b/tests/darwin/dataset/download_manager_test.py @@ -1,16 +1,10 @@ from pathlib import Path from typing import List -from unittest.mock import MagicMock, patch import pytest import responses -from requests import get -from darwin.client import Client -from darwin.config import Config from darwin.dataset import download_manager as dm -from darwin.dataset.identifier import DatasetIdentifier -from darwin.dataset.remote_dataset_v1 import RemoteDatasetV1 from darwin.datatypes import Slot from tests.fixtures import * @@ -42,16 +36,16 @@ def test_parse_manifests(manifest_paths: List[Path]) -> None: assert len(segment_manifests[3].items) == 2 assert segment_manifests[0].items[0].absolute_frame == 0 assert segment_manifests[0].items[1].absolute_frame == 1 - assert segment_manifests[0].items[1].visibility == True + assert segment_manifests[0].items[1].visibility is True assert segment_manifests[1].items[0].absolute_frame == 2 assert segment_manifests[1].items[1].absolute_frame == 3 - assert segment_manifests[1].items[1].visibility == True + assert segment_manifests[1].items[1].visibility is True assert segment_manifests[2].items[0].absolute_frame == 4 assert segment_manifests[2].items[1].absolute_frame == 5 - assert segment_manifests[2].items[1].visibility == True + assert segment_manifests[2].items[1].visibility is True assert segment_manifests[3].items[0].absolute_frame == 6 assert segment_manifests[3].items[1].absolute_frame == 7 - assert segment_manifests[3].items[1].visibility == True + assert segment_manifests[3].items[1].visibility is True def test_get_segment_manifests( @@ -70,13 +64,13 @@ def test_get_segment_manifests( assert len(segment_manifests[3].items) == 2 assert segment_manifests[0].items[0].absolute_frame == 0 assert segment_manifests[0].items[1].absolute_frame == 1 - assert segment_manifests[0].items[1].visibility == True + assert segment_manifests[0].items[1].visibility is True assert segment_manifests[1].items[0].absolute_frame == 2 assert segment_manifests[1].items[1].absolute_frame == 3 - assert segment_manifests[1].items[1].visibility == True + assert segment_manifests[1].items[1].visibility is True assert segment_manifests[2].items[0].absolute_frame == 4 assert segment_manifests[2].items[1].absolute_frame == 5 - assert segment_manifests[2].items[1].visibility == True + assert segment_manifests[2].items[1].visibility is True assert segment_manifests[3].items[0].absolute_frame == 6 assert segment_manifests[3].items[1].absolute_frame == 7 - assert segment_manifests[3].items[1].visibility == True + assert segment_manifests[3].items[1].visibility is True diff --git a/tests/darwin/exporter/formats/export_darwin_test.py b/tests/darwin/exporter/formats/export_darwin_test.py index 40dad1b3e..aa11fe900 100644 --- a/tests/darwin/exporter/formats/export_darwin_test.py +++ b/tests/darwin/exporter/formats/export_darwin_test.py @@ -2,7 +2,6 @@ from darwin.datatypes import Annotation, AnnotationClass, AnnotationFile from darwin.exporter.formats.darwin import ( - _build_item_data, _build_v2_annotation_data, build_image_annotation, ) diff --git a/tests/darwin/exporter/formats/export_mask_test.py b/tests/darwin/exporter/formats/export_mask_test.py index d8a47e502..4e932d2d7 100644 --- a/tests/darwin/exporter/formats/export_mask_test.py +++ b/tests/darwin/exporter/formats/export_mask_test.py @@ -1,9 +1,8 @@ import csv -import os import platform from pathlib import Path -from tempfile import NamedTemporaryFile, TemporaryDirectory -from typing import Callable, Dict, List, Optional +from tempfile import TemporaryDirectory +from typing import Dict, List, Optional from unittest.mock import patch import numpy as np @@ -16,7 +15,6 @@ except ImportError: NDArray = Any # type:ignore from PIL import Image -from upolygon import draw_polygon from darwin import datatypes as dt from darwin.exporter.formats.mask import ( @@ -293,7 +291,7 @@ def test_beyond_polygon_beyond_window() -> None: annotation_file = dt.AnnotationFile( Path("testfile"), "testfile", - set([a.annotation_class for a in annotations]), + {a.annotation_class for a in annotations}, annotations, ) height, width = 5, 5 @@ -346,7 +344,7 @@ def test_beyond_complex_polygon() -> None: annotation_file = dt.AnnotationFile( Path("testfile"), "testfile", - set([a.annotation_class for a in annotations]), + {a.annotation_class for a in annotations}, annotations, ) height, width = 5, 5 @@ -439,7 +437,7 @@ def test_render_polygons() -> None: annotation_file = dt.AnnotationFile( Path("testfile"), "testfile", - set([a.annotation_class for a in annotations]), + {a.annotation_class for a in annotations}, annotations, ) height = 100 @@ -477,7 +475,7 @@ def test_render_raster() -> None: for c in "1212213111132231132123132221223231113221112111233221121231311132313311221123131313131331113221311322333312233113311333132133212131122313313223111221323331221233312221221233133232232211321321311321133113123232322233222331223321121121113133313113232323122131123322122233311131213132123232322221113131331212212322133121231221213113231322121332222121232133222321311213312332321321212321222121113223321113311333313222232213123121221132332113321132133121221212131123113233313112322332112312113112321222331332121311132312221331312222211113232131112123331121311213113321121223323323232211323113333333321323332312332232332223332123213211332131112121131112233321131112121233131331133223211131333223123121322221332333311213331231122133311131211132231233111322123331223311231323121233233231222331331211322123213112211211231222323113331211113311331332221331131311112213322313322233213122133112313311322133223123221211113333222311222311133331312113322321312312122321133111133233313321221323231331223131321213332123331232123323313332232211312211133221113122322332131212112312121211113122221222131112333322323222312232311312321132212113311111131111113123133323333331212133312312122331212323223311121332232133212333212213132121321232211212233333313311332321231111333122133321211131312221113331112112121122212122322132213113123222231212331312233312113213233233312323211133132131133122311122321132233112313212312122332331312131213213223233222213112312111221131111232223123322133322111221323233333331313221222233322233221213131212322121112323312312321111333132323113331132312232231322232332223223211331322222231122211111311323221331111112123231131212131231112322322321333112331223111311311113123233223123311321322313231222311112113131133111233212121322212131221231222331233222212333312222223313232111111121113132221223332121222311121312322313221211131323111112233231131123111131122321312212112313221131221321221212331333232323132131131211223322221312331122123131332322322321212232232112321112313313322231122222331222323221113211121121322211223212133111332111112133213213323112112232223222333223312312123211122223333332321112322311132311113133233332132322332113121223323313232331211121333111123132321322331132131211331322222212113213321322111233311212131121322231132313221112122113213313312121331322131131112113311112232212222232112222213213111231231311111333233113122321113133323113231112121211113231232313233233333221333333311221131223111213122213112332311331211211113231212132322133211212121211312333331332322211213331311312223233212223312112121311323232122333221232213323122322122313332121212313221332233211222113222232223212233211313311132313212213312112111121332231231131232321313122332311312232321121233332131122131113212331223211322333232221321311133332231312122311321322222132232323123311133133122332313122231131111323133331233221121111111331122323111133331112323122123113213122332122222113113321132222312223131323123323222131323321231211312222213131333123132333133321323131231212311133222232133321333111212231331133131312333231333213321321212311123131232211123212123231122122321111132323321113131331233321323122232313311332111112321211232132112313132322111313121112231312333131212221322122123331322123212121333311111332312222132321333133323211113321113111333232333312231212123232322223122332233133222211112113121322113231212323322132331111133231131312223212123222121121323212123232221331113112321322212323323331231311321233331331331221322323231221313111132121331123221131211112211212323221322113323112333213323232333313321232123332231232223323331133222232122222112112123323212131133121331233311222121112231313111332322112122232133122111323123133123112233323121113133223132223333333332332211331321111212323212121113232313322123131321312132113321323233311123222121333232322321121332322133323123332322112111131233212111131122113332133222113221122222133112333123121322323331232113133322312222233113223312123112332211132322213313231313133111321113321131222122331311331312131322111323111113123322112122312223333113133112322231323123213231231312323311331112111122212312312131332333223221112222311232131333211232323233112221123111132232332111321313323231312212113232331212211232121213233221211231312132131312231222122131213233321211132312311321323211323223311323223311313313131311121312122322121211123113231123212133231322122321232221131212311323221323233332122133213111311122133323312122123112332332313132322313233312233322221111133212112231333222221233312311223211311331213121133231212233211132122331332223222322223122233232112211233123312222231131232232113113221212333133131311332313321321331122232221322123233323211232323212312211312132321321123123333131132332331133131122132332112333123323232211122232213333112232223312332112222223313132122212131233322131113132322312233113232311231323211332231233223312233221232323332311133322322112133122133211312233321123232212332132222213333233212213313133333223232333121322212321213321333212321213223323133113213131222232233212322331232331231223222331112111322312222113133112321231331213121211122332232322133321123133111312132133122132111322312232332213322233121121331312221121213231222131223231113311321331123333122111211332231312313321213221331223123323112112222232132132123212112221212122313311322122232223112331233111131221321121132333221323323123123332133311223123121231222322231121122211121111132121222322311323231212322211211133111221313122133332132323321211112121331113322232231323133323121221111111323233213232212312331133123323133132331112213122111313222312333332333111212123311323231132222332333323233132133213223131133332221223212112323121221212331131322223232123132323232131111312233221122112122213112232321312132112323221322111332232123132312231111232132221121221212222323311232223123111123233322211121111221223222223331213132123321212222212113212121132233312332132131311231311232233322323312221211211122121323131121323221313122232131121313312123321212311213131133223332131122213331311333221312323232223223331113331233312112112111111233321231133121122123132222312121211322333213233313222123123113332331131223231332232123312232132222233223312233322331231232112323321312132211133311321313132221312113212333322132313321132313111213122313132111321222333333322211322122312233111323123121333321222311332223311232212222132312231313131132223133113312312311322321311113131233333213321312223322213132213222113221221221213231312321313223323233122311323121212113311321221221313131113211222332213213133123311213323122223313321313132313322211123123221223312113311211112123223313321322323233212121213121113132323113233332233211132112121212221313332311332123211231211321331233131133311221213311121323311111313112213232312312212311112333113331333121123123313111323331121213323323223111221331211211131111331233233122223321112123321231212321232221122122333313223211222333113212111221121113221221111133323111323121211311132113121221233322312232221333333212111131233321122312213311233332321123131321113221131323223323312113133231311132221113112132132113123232132321112232213222221213122123133212321222131132131123133133122232323122233213123311131121213221311222311332211113312211221212131112113312132233231222121213132323121212232321112333221333311231311223322321111232232112323233233322213213123111111122313212232113331233111311311131122212311131121133122112222331212332321312133333131313313223231123232213322131211233212112332331123132232132132222211123122111213232332223212213223112111332221121113211111113111311133322213132223312232113321132221232221123131311231313113122111211122122322333231121113131323113232122113232111121213222311131231112122113333123321322223233323212322331233332112132333111211112212322313312132211231122222113322133323132311212332231211312333123121132122233212312123311111311331222131123323111122321112213212111322121131111123312332122211213133211312211132223212323133121212323113322131123212322233231323122113322213222332332133212313313312211213232131222311132321332232223212112222212113212211131223323332212322323222233332311322132113322231333231333121122312122313322113221212221231333133112133222112113111332113331122312123331332131113111213333122331111332122313123231331221223131131233132113122312212212321121222121123113333131123321232313113212313111322322133221333223221333213212333312233212231113331111133212312311111122232322233231332313223113331233112223123313123221113211213331331221121222323111213322231232133333233332132223133121323213122232312333323221322211121331122312123223122132122232233322322231112223333113213113112322213212132112122212121233121212123332321312322211222222321122231222312312231213213123132232333213113213323313311123133322312231231123232213133222221233212111111221313332113131333223223222132132333213221131132131323132233323221331132221111222211322321223213132221311323332132223223212323313221222232211311222321223321333331323221232133121321213121111113212112211331132122321333322232211321313113311221133312322212211111222133233322332123111113212112233133111331121322223223231212133223333332211231232331331212132133323222133133131322123323232221122123133331113222132133333211131133112211333323112121233311323112222331311212113111232113221213122333133213231333111213223222133113321112122322211131322212112211323333323332213331112132121132123231112223131222313331331313232232322213311113223331122232121311221121231131323321211133212332112121332223211321311312232111123322113121323333212222213111333311133322221311112333313222222231311331223113323212312211211323223113211223323113113131331213132313323231322313123111221221131123121221211112133112131332331211113313322322321322132111312331311131132313123312231111333133211122233212232311223131332213133223331232113122112122221231232221112332221223312223322332221223211222223332112311312313331122221211211132322231312331311222322132331233133113323133322331322221223331332211233222332113313233332123121112211121131131321222233223312233312122213133232123321232333232233213331123132313113221133322233213123113131212321213113322323133231321211323311123232312132311212322122233121" ] mask = np.zeros((100, 100), dtype=np.uint8) - colours: dt.MaskTypes.ColoursDict = dict() + colours: dt.MaskTypes.ColoursDict = {} categories: dt.MaskTypes.CategoryList = [] annotations: List[dt.AnnotationLike] = [ dt.Annotation( @@ -516,7 +514,7 @@ def test_render_raster() -> None: annotation_file = dt.AnnotationFile( Path("path"), annotations=annotations, - annotation_classes=set([c.annotation_class for c in annotations]), + annotation_classes={c.annotation_class for c in annotations}, filename="test.txt", ) @@ -556,17 +554,16 @@ def test_render_raster() -> None: GREEN = [0, 255, 0] BLUE = [0, 0, 255] BLACK = [0, 0, 0] -colours_for_test: Callable[[], dt.MaskTypes.RgbColors] = lambda: [ - *BLACK, - *RED, - *GREEN, - *BLUE, -] -colour_list_for_test: Callable[[], dt.MaskTypes.ColoursDict] = lambda: { - "mask1": 0, - "mask2": 1, - "mask3": 2, -} + + +def colours_for_test() -> dt.MaskTypes.RgbColors: + return [*BLACK, *RED, *GREEN, *BLUE] + + +def colour_list_for_test() -> dt.MaskTypes.ColoursDict: + return {"mask1": 0, "mask2": 1, "mask3": 2} + + data_path = (Path(__file__).parent / ".." / ".." / "data").resolve() @@ -857,7 +854,7 @@ def test_class_mappings_preserved_on_large_export(tmpdir) -> None: ] # Pixel sizes of polygons, for used in asserting the correct colour is mapped to the correct class sizes = {"cat1": 8, "cat2": 9, "cat3": 16, "cat4": 10} - sizes["__background__"] = height * width - sum([x for x in sizes.values()]) + sizes["__background__"] = height * width - sum(list(sizes.values())) annotation_files = [ dt.AnnotationFile( Path(f"test{x}"), diff --git a/tests/darwin/exporter/formats/export_nifti_test.py b/tests/darwin/exporter/formats/export_nifti_test.py index d987d7779..a27ded009 100644 --- a/tests/darwin/exporter/formats/export_nifti_test.py +++ b/tests/darwin/exporter/formats/export_nifti_test.py @@ -73,9 +73,9 @@ def test_video_annotation_nifti_export_mpr(team_slug: str): ) nifti.export(video_annotations, output_dir=tmpdir) export_im = nib.load( - annotations_dir / f"hippocampus_001_mpr_1_test_hippo.nii.gz" + annotations_dir / "hippocampus_001_mpr_1_test_hippo.nii.gz" ).get_fdata() expected_im = nib.load( - annotations_dir / f"hippocampus_001_mpr_1_test_hippo.nii.gz" + annotations_dir / "hippocampus_001_mpr_1_test_hippo.nii.gz" ).get_fdata() assert np.allclose(export_im, expected_im) diff --git a/tests/darwin/importer/formats/import_nifti_test.py b/tests/darwin/importer/formats/import_nifti_test.py index 52cbe8a46..20ee505bc 100644 --- a/tests/darwin/importer/formats/import_nifti_test.py +++ b/tests/darwin/importer/formats/import_nifti_test.py @@ -147,7 +147,7 @@ def test_image_annotation_nifti_import_incorrect_number_slot(team_slug: str): json.dumps(input_dict, indent=4, sort_keys=True, default=str) ) with pytest.raises(Exception): - annotation_files = parse_path(path=upload_json) + parse_path(path=upload_json) def serialise_annotation_file( diff --git a/tests/darwin/importer/importer_test.py b/tests/darwin/importer/importer_test.py index 240ffb28a..9659e995b 100644 --- a/tests/darwin/importer/importer_test.py +++ b/tests/darwin/importer/importer_test.py @@ -1,5 +1,5 @@ from typing import List, Tuple -from unittest.mock import MagicMock, Mock, _patch, patch +from unittest.mock import Mock, _patch, patch import pytest from rich.theme import Theme @@ -182,9 +182,7 @@ def test__get_annotation_data() -> None: video_annotation_class = dt.AnnotationClass("video_class", "video") annotation = dt.Annotation(annotation_class, {}, [], []) - video_annotation = dt.VideoAnnotation( - video_annotation_class, dict(), dict(), [], False - ) + video_annotation = dt.VideoAnnotation(video_annotation_class, {}, {}, [], False) annotation.data = "TEST DATA" diff --git a/tests/darwin/torch/utils_test.py b/tests/darwin/torch/utils_test.py index 28fc2c280..c3388d766 100644 --- a/tests/darwin/torch/utils_test.py +++ b/tests/darwin/torch/utils_test.py @@ -40,8 +40,8 @@ def test_should_raise_with_incorrect_shaped_inputs( ) -> None: masks, _ = basic_masks_with_cats cats = [0] - with pytest.raises(AssertionError) as error: - flattened = flatten_masks_by_category(masks, cats) + with pytest.raises(AssertionError): + flatten_masks_by_category(masks, cats) def test_should_correctly_set_overlap(self, basic_masks_with_cats: Tuple) -> None: masks, cats = basic_masks_with_cats diff --git a/tests/darwin/utils/find_files_test.py b/tests/darwin/utils/find_files_test.py index 184ae9925..64c4dadce 100644 --- a/tests/darwin/utils/find_files_test.py +++ b/tests/darwin/utils/find_files_test.py @@ -1,8 +1,8 @@ from dataclasses import dataclass -from pathlib import Path, PosixPath -from typing import Any, Callable, Dict, List, Optional -from unittest import TestCase, skip -from unittest.mock import MagicMock, patch +from pathlib import Path +from typing import Callable, Optional +from unittest import TestCase +from unittest.mock import patch from darwin.exceptions import UnsupportedFileType from darwin.utils import ( @@ -10,7 +10,6 @@ SUPPORTED_IMAGE_EXTENSIONS, SUPPORTED_VIDEO_EXTENSIONS, find_files, - is_extension_allowed, ) diff --git a/tests/darwin/utils_test.py b/tests/darwin/utils_test.py index aef7be2f3..b8cfaa3b9 100644 --- a/tests/darwin/utils_test.py +++ b/tests/darwin/utils_test.py @@ -1,7 +1,6 @@ from unittest.mock import MagicMock, patch import pytest -from jsonschema.exceptions import ValidationError from requests import Response import darwin.datatypes as dt @@ -148,7 +147,7 @@ def test_parses_darwin_images_correctly(self, tmp_path): assert annotation_file.path == import_file assert annotation_file.filename == "P49-RediPad-ProPlayLEFTY_442.jpg" - assert annotation_file.dataset_name == None + assert annotation_file.dataset_name is None assert annotation_file.version == dt.AnnotationFileVersion( major=1, minor=0, suffix="" ) @@ -237,7 +236,7 @@ def test_parses_darwin_videos_correctly(self, tmp_path): assert annotation_file.path == import_file assert annotation_file.filename == "above tractor.mp4" - assert annotation_file.dataset_name == None + assert annotation_file.dataset_name is None assert annotation_file.version == dt.AnnotationFileVersion( major=1, minor=0, suffix="" ) @@ -849,7 +848,7 @@ def test_parses_a_raster_annotation( assert annotation.annotation_class.name == "my_raster_annotation" assert annotation.annotation_class.annotation_type == "mask" - assert annotation.data["sparse_rle"] == None + assert annotation.data["sparse_rle"] is None # Sad paths @pytest.mark.parametrize("parameter_name", ["id", "name", "mask", "slot_names"]) diff --git a/tests/e2e_test_internals/test_run_cli_command.py b/tests/e2e_test_internals/test_run_cli_command.py index 4fae8aa54..9504fc0b4 100644 --- a/tests/e2e_test_internals/test_run_cli_command.py +++ b/tests/e2e_test_internals/test_run_cli_command.py @@ -1,5 +1,4 @@ from collections import namedtuple -from http import server from unittest import mock import pytest From 3a4785adc0a17096dd889e2e67a13424a3139e14 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Mon, 20 Nov 2023 10:44:33 +0100 Subject: [PATCH 40/71] removed try catch in stacked targets --- darwin/torch/dataset.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/darwin/torch/dataset.py b/darwin/torch/dataset.py index c0af1fb72..84231ab55 100644 --- a/darwin/torch/dataset.py +++ b/darwin/torch/dataset.py @@ -639,16 +639,12 @@ def get_target(self, index: int) -> Dict[str, Tensor]: targets.append(ann) # following https://pytorch.org/tutorials/intermediate/torchvision_tutorial.html - try: - stacked_targets = { - "boxes": torch.stack([v["bbox"] for v in targets]), - "area": torch.stack([v["area"] for v in targets]), - "labels": torch.stack([v["label"] for v in targets]), - "image_id": torch.tensor([index]), - } - except Exception as e: - print(target) - raise e + stacked_targets = { + "boxes": torch.stack([v["bbox"] for v in targets]), + "area": torch.stack([v["area"] for v in targets]), + "labels": torch.stack([v["label"] for v in targets]), + "image_id": torch.tensor([index]), + } stacked_targets["iscrowd"] = torch.zeros_like(stacked_targets["labels"]) From ec61777c2e46507cbede275b4042a7795e5260e4 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Mon, 20 Nov 2023 10:47:21 +0100 Subject: [PATCH 41/71] removed print --- tests/darwin/importer/formats/import_labelbox_test.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/darwin/importer/formats/import_labelbox_test.py b/tests/darwin/importer/formats/import_labelbox_test.py index c29ae918e..29a6bde7c 100644 --- a/tests/darwin/importer/formats/import_labelbox_test.py +++ b/tests/darwin/importer/formats/import_labelbox_test.py @@ -382,8 +382,6 @@ def test_it_imports_polygon_images(self, file_path: Path): Annotation, annotation_file.annotations.pop() ) - print(polygon_annotation) - assert_polygon( polygon_annotation, [ From 36ca721102507048319c8ffcf9f9373137653460 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Mon, 20 Nov 2023 13:38:50 +0100 Subject: [PATCH 42/71] Reverted specific files to state in commit 09b105eed12f8d0a3d21beaf6211da85725473d5 --- e2e_tests/conftest.py | 2 +- e2e_tests/helpers.py | 10 +++++++++- e2e_tests/test_darwin.py | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/e2e_tests/conftest.py b/e2e_tests/conftest.py index d26b4fbac..b44f786da 100644 --- a/e2e_tests/conftest.py +++ b/e2e_tests/conftest.py @@ -9,7 +9,7 @@ from darwin.future.data_objects.typing import UnknownType from e2e_tests.exceptions import E2EEnvironmentVariableNotSet -from e2e_tests.objects import ConfigValues +from e2e_tests.objects import ConfigValues, E2EDataset from e2e_tests.setup_tests import setup_tests, teardown_tests diff --git a/e2e_tests/helpers.py b/e2e_tests/helpers.py index 0c14b37f3..8d3b3d393 100644 --- a/e2e_tests/helpers.py +++ b/e2e_tests/helpers.py @@ -1,10 +1,18 @@ +import re +import tempfile +import uuid +from pathlib import Path from subprocess import run from time import sleep -from typing import Optional +from typing import Generator, Optional, Tuple +import pytest from attr import dataclass +from cv2 import exp from darwin.exceptions import DarwinException +from e2e_tests.objects import E2EDataset +from e2e_tests.setup_tests import create_random_image @dataclass diff --git a/e2e_tests/test_darwin.py b/e2e_tests/test_darwin.py index 1f495817d..dd03d8f3c 100644 --- a/e2e_tests/test_darwin.py +++ b/e2e_tests/test_darwin.py @@ -4,6 +4,7 @@ import tempfile import uuid from pathlib import Path +from time import sleep from typing import Generator import pytest From 6130abe4829172b0980e2d8c14dbe5c4d60763e4 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Tue, 21 Nov 2023 19:59:44 +0100 Subject: [PATCH 43/71] converting complex and regular polygon to import format --- darwin/importer/importer.py | 51 ++++++++++++++++++++++++++++++---- tests/darwin/datatypes_test.py | 23 --------------- 2 files changed, 45 insertions(+), 29 deletions(-) diff --git a/darwin/importer/importer.py b/darwin/importer/importer.py index 7bc12edc6..095e48276 100644 --- a/darwin/importer/importer.py +++ b/darwin/importer/importer.py @@ -16,6 +16,7 @@ Union, ) +import darwin.datatypes as dt from darwin.datatypes import AnnotationFile from darwin.item import DatasetItem @@ -653,16 +654,36 @@ def _handle_subs( return data +def _to_complex_polygon(paths : list[str]): + return { + "path": paths[0], + "additional_paths": paths[1:], + } + +def _handle_polygon( + annotation: dt.Annotation, data: dt.DictFreeForm + ) -> dt.DictFreeForm: + + polygon = data.get("polygon") + + if polygon is not None: + if "paths" in polygon and len(polygon['paths']) == 1: + data['path'] = annotation.data['paths'][0] + del data['paths'] + + data = _handle_complex_polygon(annotation, data) + + return data + def _handle_complex_polygon( annotation: dt.Annotation, data: dt.DictFreeForm ) -> dt.DictFreeForm: if "complex_polygon" in data: del data["complex_polygon"] - data["polygon"] = { - "path": annotation.data["paths"][0], - "additional_paths": annotation.data["paths"][1:], - } + data["polygon"] = _to_complex_polygon(annotation.data["paths"]) + elif "polygon" in data and "paths" in data['polygon'] and len(data['paths']['polygon']) > 1: + data["polygon"] = _to_complex_polygon(annotation.data["paths"]) return data @@ -693,26 +714,44 @@ def _handle_annotators( ) return [] +def _convert_to_darwin_export_format(data): + + polygon = data.get('polygon') + if polygon is not None: + paths = polygon['paths'] + if paths is not None: + # Darwin v1 polygon format + if len(paths) == 1: + data = _handle_complex_polygon(data) + else: + del data['paths'] + data['polygon']['path'] = paths + + return data def _get_annotation_data( annotation: dt.AnnotationLike, annotation_class_id: str, attributes: dt.DictFreeForm ) -> dt.DictFreeForm: + + # Todo Convert to import Darwin format annotation_class = annotation.annotation_class if isinstance(annotation, dt.VideoAnnotation): data = annotation.get_data( only_keyframes=True, post_processing=lambda annotation, data: _handle_subs( annotation, - _handle_complex_polygon(annotation, data), + _handle_polygon(annotation, data), annotation_class_id, attributes, ), ) else: data = {annotation_class.annotation_type: annotation.data} - data = _handle_complex_polygon(annotation, data) + data = _handle_polygon(annotation, data) data = _handle_subs(annotation, data, annotation_class_id, attributes) + data = _convert_to_darwin_export_format(data) + return data diff --git a/tests/darwin/datatypes_test.py b/tests/darwin/datatypes_test.py index 040a94cd2..6ee1b8aa7 100644 --- a/tests/darwin/datatypes_test.py +++ b/tests/darwin/datatypes_test.py @@ -4,29 +4,6 @@ class TestMakePolygon: - def test_it_returns_annotation_with_default_params_darwin_v1(self): - class_name: str = "class_name" - points: List[Point] = [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 1, "y": 2}] - annotation = make_polygon(class_name, points, darwin_v1=True) - - assert_annotation_class(annotation, class_name, "polygon") - - path = annotation.data.get("path") - assert path == points - - def test_it_returns_annotation_with_bounding_box_darwin_v1(self): - class_name: str = "class_name" - points: List[Point] = [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 1, "y": 2}] - bbox: Dict[str, float] = {"x": 1, "y": 2, "w": 2, "h": 2} - annotation = make_polygon(class_name, points, bounding_box=bbox, darwin_v1=True) - - assert_annotation_class(annotation, class_name, "polygon") - - path = annotation.data.get("path") - assert path == points - - class_bbox = annotation.data.get("bounding_box") - assert class_bbox == bbox def test_it_returns_annotation_with_default_params_darwin_v2(self): class_name: str = "class_name" From 9bf55b9c97f9b96daf0e8a628f9e8864f1fd469b Mon Sep 17 00:00:00 2001 From: Christoffer Date: Tue, 21 Nov 2023 20:11:14 +0100 Subject: [PATCH 44/71] added a potential e2e import fix --- darwin/importer/importer.py | 53 +++++++++++++++------------------- tests/darwin/datatypes_test.py | 1 - 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/darwin/importer/importer.py b/darwin/importer/importer.py index 095e48276..68ea1d9eb 100644 --- a/darwin/importer/importer.py +++ b/darwin/importer/importer.py @@ -654,25 +654,30 @@ def _handle_subs( return data -def _to_complex_polygon(paths : list[str]): + +def _to_complex_polygon(paths: list[str]) -> Dict[str, Any]: return { - "path": paths[0], - "additional_paths": paths[1:], - } + "path": paths[0], + "additional_paths": paths[1:], + } + def _handle_polygon( annotation: dt.Annotation, data: dt.DictFreeForm - ) -> dt.DictFreeForm: - +) -> dt.DictFreeForm: + print(f"data: {data}") + print(f"type : {type(data)}") polygon = data.get("polygon") if polygon is not None: - if "paths" in polygon and len(polygon['paths']) == 1: - data['path'] = annotation.data['paths'][0] - del data['paths'] - + if "paths" in polygon and len(polygon["paths"]) == 1: + data["polygon"]["path"] = annotation.data["paths"][0] + del data["paths"] + data = _handle_complex_polygon(annotation, data) - + + print(f"data out : {data}") + return data @@ -682,8 +687,13 @@ def _handle_complex_polygon( if "complex_polygon" in data: del data["complex_polygon"] data["polygon"] = _to_complex_polygon(annotation.data["paths"]) - elif "polygon" in data and "paths" in data['polygon'] and len(data['paths']['polygon']) > 1: - data["polygon"] = _to_complex_polygon(annotation.data["paths"]) + elif ( + "polygon" in data + and "paths" in data["polygon"] + and len(data["paths"]["polygon"]) > 1 + ): + data["polygon"] = _to_complex_polygon(annotation.data["paths"]) + print(f"complex data : {data}") return data @@ -714,25 +724,10 @@ def _handle_annotators( ) return [] -def _convert_to_darwin_export_format(data): - - polygon = data.get('polygon') - if polygon is not None: - paths = polygon['paths'] - if paths is not None: - # Darwin v1 polygon format - if len(paths) == 1: - data = _handle_complex_polygon(data) - else: - del data['paths'] - data['polygon']['path'] = paths - - return data def _get_annotation_data( annotation: dt.AnnotationLike, annotation_class_id: str, attributes: dt.DictFreeForm ) -> dt.DictFreeForm: - # Todo Convert to import Darwin format annotation_class = annotation.annotation_class if isinstance(annotation, dt.VideoAnnotation): @@ -750,8 +745,6 @@ def _get_annotation_data( data = _handle_polygon(annotation, data) data = _handle_subs(annotation, data, annotation_class_id, attributes) - data = _convert_to_darwin_export_format(data) - return data diff --git a/tests/darwin/datatypes_test.py b/tests/darwin/datatypes_test.py index 6ee1b8aa7..39ca7cb90 100644 --- a/tests/darwin/datatypes_test.py +++ b/tests/darwin/datatypes_test.py @@ -4,7 +4,6 @@ class TestMakePolygon: - def test_it_returns_annotation_with_default_params_darwin_v2(self): class_name: str = "class_name" points: List[Point] = [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 1, "y": 2}] From 6689d9fab6763a12a9e57ac2abe73cb1282124e8 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Tue, 21 Nov 2023 20:11:51 +0100 Subject: [PATCH 45/71] removed debug prints --- darwin/importer/importer.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/darwin/importer/importer.py b/darwin/importer/importer.py index 68ea1d9eb..be9986234 100644 --- a/darwin/importer/importer.py +++ b/darwin/importer/importer.py @@ -665,8 +665,6 @@ def _to_complex_polygon(paths: list[str]) -> Dict[str, Any]: def _handle_polygon( annotation: dt.Annotation, data: dt.DictFreeForm ) -> dt.DictFreeForm: - print(f"data: {data}") - print(f"type : {type(data)}") polygon = data.get("polygon") if polygon is not None: @@ -675,9 +673,6 @@ def _handle_polygon( del data["paths"] data = _handle_complex_polygon(annotation, data) - - print(f"data out : {data}") - return data From d36f7d2856d7dd9aaf18309467279008b6ae6d50 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Wed, 22 Nov 2023 11:26:03 +0100 Subject: [PATCH 46/71] latest sync --- .vscode/settings.json | 4 ++++ darwin/exporter/formats/darwin.py | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 28f99b8d9..24c7a8950 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,4 +20,8 @@ "python.analysis.autoImportCompletions": true, "python.testing.pytestEnabled": true, "python.analysis.typeCheckingMode": "basic", + "python.testing.pytestArgs": [ + "e2e_tests" + ], + "python.testing.unittestEnabled": false, } \ No newline at end of file diff --git a/darwin/exporter/formats/darwin.py b/darwin/exporter/formats/darwin.py index 92748cdba..b47da82d5 100644 --- a/darwin/exporter/formats/darwin.py +++ b/darwin/exporter/formats/darwin.py @@ -57,7 +57,6 @@ def _build_v2_annotation_data(annotation: dt.Annotation) -> Dict[str, Any]: polygon_data = _build_polygon_data(annotation.data) annotation_data["polygon"] = polygon_data annotation_data["bounding_box"] = _build_bounding_box_data(annotation.data) - return annotation_data From d0f7ca8ebbff2b483e81c76f26fed6a07be2b3be Mon Sep 17 00:00:00 2001 From: Christoffer Date: Wed, 22 Nov 2023 13:20:48 +0100 Subject: [PATCH 47/71] updated code to pass e2e tests --- darwin/importer/importer.py | 184 +++++++++--------------------------- 1 file changed, 43 insertions(+), 141 deletions(-) diff --git a/darwin/importer/importer.py b/darwin/importer/importer.py index be9986234..6ec1721b1 100644 --- a/darwin/importer/importer.py +++ b/darwin/importer/importer.py @@ -70,9 +70,7 @@ current_version=__version__, details=DEPRECATION_MESSAGE, ) -def build_main_annotations_lookup_table( - annotation_classes: List[Dict[str, Unknown]] -) -> Dict[str, Unknown]: +def build_main_annotations_lookup_table(annotation_classes: List[Dict[str, Unknown]]) -> Dict[str, Unknown]: MAIN_ANNOTATION_TYPES = [ "bounding_box", "cuboid", @@ -167,10 +165,7 @@ def maybe_console(*args: Union[str, int, float]) -> None: def _get_files_for_parsing(file_paths: List[PathLike]) -> List[Path]: - packed_files = [ - filepath.glob("**/*") if filepath.is_dir() else [filepath] - for filepath in map(Path, file_paths) - ] + packed_files = [filepath.glob("**/*") if filepath.is_dir() else [filepath] for filepath in map(Path, file_paths)] return [file for files in packed_files for file in files] @@ -237,30 +232,18 @@ def _resolve_annotation_classes( local_classes_not_in_team: Set[dt.AnnotationClass] = set() for local_cls in local_annotation_classes: - local_annotation_type = ( - local_cls.annotation_internal_type or local_cls.annotation_type - ) + local_annotation_type = local_cls.annotation_internal_type or local_cls.annotation_type # Only add the new class if it doesn't exist remotely already - if ( - local_annotation_type in classes_in_dataset - and local_cls.name in classes_in_dataset[local_annotation_type] - ): + if local_annotation_type in classes_in_dataset and local_cls.name in classes_in_dataset[local_annotation_type]: continue # Only add the new class if it's not included in the list of the missing classes already - if local_cls.name in [ - missing_class.name for missing_class in local_classes_not_in_dataset - ]: + if local_cls.name in [missing_class.name for missing_class in local_classes_not_in_dataset]: continue - if local_cls.name in [ - missing_class.name for missing_class in local_classes_not_in_team - ]: + if local_cls.name in [missing_class.name for missing_class in local_classes_not_in_team]: continue - if ( - local_annotation_type in classes_in_team - and local_cls.name in classes_in_team[local_annotation_type] - ): + if local_annotation_type in classes_in_team and local_cls.name in classes_in_team[local_annotation_type]: local_classes_not_in_dataset.add(local_cls) else: local_classes_not_in_team.add(local_cls) @@ -339,18 +322,14 @@ def import_annotations( # noqa: C901 "The options 'append' and 'delete_for_empty' cannot be used together. Use only one of them." ) - cpu_limit, use_multi_cpu = _get_multi_cpu_settings( - cpu_limit, cpu_count(), use_multi_cpu - ) + cpu_limit, use_multi_cpu = _get_multi_cpu_settings(cpu_limit, cpu_count(), use_multi_cpu) if use_multi_cpu: console.print(f"Using {cpu_limit} CPUs for parsing...", style="info") else: console.print("Using 1 CPU for parsing...", style="info") if not isinstance(file_paths, list): - raise ValueError( - f"file_paths must be a list of 'Path' or 'str'. Current value: {file_paths}" - ) + raise ValueError(f"file_paths must be a list of 'Path' or 'str'. Current value: {file_paths}") console.print("Fetching remote class list...", style="info") team_classes: List[dt.DictFreeForm] = dataset.fetch_remote_classes(True) @@ -364,18 +343,10 @@ def import_annotations( # noqa: C901 ) classes_in_dataset: dt.DictFreeForm = build_main_annotations_lookup_table( - [ - cls - for cls in team_classes - if cls["available"] or cls["name"] in GLOBAL_CLASSES - ] + [cls for cls in team_classes if cls["available"] or cls["name"] in GLOBAL_CLASSES] ) classes_in_team: dt.DictFreeForm = build_main_annotations_lookup_table( - [ - cls - for cls in team_classes - if not cls["available"] and cls["name"] not in GLOBAL_CLASSES - ] + [cls for cls in team_classes if not cls["available"] and cls["name"] not in GLOBAL_CLASSES] ) attributes = build_attribute_lookup(dataset) @@ -393,9 +364,7 @@ def import_annotations( # noqa: C901 parsed_files: List[AnnotationFile] = flatten_list(list(maybe_parsed_files)) - filenames: List[str] = [ - parsed_file.filename for parsed_file in parsed_files if parsed_file is not None - ] + filenames: List[str] = [parsed_file.filename for parsed_file in parsed_files if parsed_file is not None] console.print("Fetching remote file list...", style="info") # This call will only filter by filename; so can return a superset of matched files across different paths @@ -430,9 +399,7 @@ def import_annotations( # noqa: C901 style="warning", ) for local_file in local_files_missing_remotely: - console.print( - f"\t{local_file.path}: '{local_file.full_path}'", style="warning" - ) + console.print(f"\t{local_file.path}: '{local_file.full_path}'", style="warning") if class_prompt and not secure_continue_request(): return @@ -441,26 +408,18 @@ def import_annotations( # noqa: C901 local_classes_not_in_dataset, local_classes_not_in_team, ) = _resolve_annotation_classes( - [ - annotation_class - for file in local_files - for annotation_class in file.annotation_classes - ], + [annotation_class for file in local_files for annotation_class in file.annotation_classes], classes_in_dataset, classes_in_team, ) - console.print( - f"{len(local_classes_not_in_team)} classes needs to be created.", style="info" - ) + console.print(f"{len(local_classes_not_in_team)} classes needs to be created.", style="info") console.print( f"{len(local_classes_not_in_dataset)} classes needs to be added to {dataset.identifier}", style="info", ) - missing_skeletons: List[dt.AnnotationClass] = list( - filter(_is_skeleton_class, local_classes_not_in_team) - ) + missing_skeletons: List[dt.AnnotationClass] = list(filter(_is_skeleton_class, local_classes_not_in_team)) missing_skeleton_names: str = ", ".join(map(_get_skeleton_name, missing_skeletons)) if missing_skeletons: console.print( @@ -484,9 +443,7 @@ def import_annotations( # noqa: C901 missing_class.annotation_internal_type or missing_class.annotation_type, ) if local_classes_not_in_dataset: - console.print( - f"About to add the following classes to {dataset.identifier}", style="info" - ) + console.print(f"About to add the following classes to {dataset.identifier}", style="info") for cls in local_classes_not_in_dataset: dataset.add_annotation_class(cls) @@ -501,9 +458,7 @@ def import_annotations( # noqa: C901 remote_classes = build_main_annotations_lookup_table(team_classes) if dataset.version == 1: - console.print( - "Importing annotations...\nEmpty annotations will be skipped.", style="info" - ) + console.print("Importing annotations...\nEmpty annotations will be skipped.", style="info") elif dataset.version == 2 and delete_for_empty: console.print( "Importing annotations...\nEmpty annotation file(s) will clear all existing annotations in matching remote files.", @@ -517,9 +472,7 @@ def import_annotations( # noqa: C901 # Need to re parse the files since we didn't save the annotations in memory for local_path in set(local_file.path for local_file in local_files): # noqa: C401 - imported_files: Union[ - List[dt.AnnotationFile], dt.AnnotationFile, None - ] = importer(local_path) + imported_files: Union[List[dt.AnnotationFile], dt.AnnotationFile, None] = importer(local_path) if imported_files is None: parsed_files = [] elif not isinstance(imported_files, List): @@ -528,20 +481,13 @@ def import_annotations( # noqa: C901 parsed_files = imported_files # remove files missing on the server - missing_files = [ - missing_file.full_path for missing_file in local_files_missing_remotely - ] - parsed_files = [ - parsed_file - for parsed_file in parsed_files - if parsed_file.full_path not in missing_files - ] + missing_files = [missing_file.full_path for missing_file in local_files_missing_remotely] + parsed_files = [parsed_file for parsed_file in parsed_files if parsed_file.full_path not in missing_files] files_to_not_track = [ file_to_track for file_to_track in parsed_files - if not file_to_track.annotations - and (not delete_for_empty or dataset.version == 1) + if not file_to_track.annotations and (not delete_for_empty or dataset.version == 1) ] for file in files_to_not_track: @@ -550,9 +496,7 @@ def import_annotations( # noqa: C901 style="warning", ) - files_to_track = [ - file for file in parsed_files if file not in files_to_not_track - ] + files_to_track = [file for file in parsed_files if file not in files_to_not_track] if files_to_track: _warn_unsupported_annotations(files_to_track) for parsed_file in track(files_to_track): @@ -577,16 +521,12 @@ def import_annotations( # noqa: C901 ) if errors: - console.print( - f"Errors importing {parsed_file.filename}", style="error" - ) + console.print(f"Errors importing {parsed_file.filename}", style="error") for error in errors: console.print(f"\t{error}", style="error") -def _get_multi_cpu_settings( - cpu_limit: Optional[int], cpu_count: int, use_multi_cpu: bool -) -> Tuple[int, bool]: +def _get_multi_cpu_settings(cpu_limit: Optional[int], cpu_count: int, use_multi_cpu: bool) -> Tuple[int, bool]: if cpu_limit == 1 or cpu_count == 1 or not use_multi_cpu: return 1, False @@ -604,9 +544,7 @@ def _warn_unsupported_annotations(parsed_files: List[AnnotationFile]) -> None: if annotation.annotation_class.annotation_type in UNSUPPORTED_CLASSES: skipped_annotations.append(annotation) if len(skipped_annotations) > 0: - types = { - c.annotation_class.annotation_type for c in skipped_annotations - } # noqa: C417 + types = {c.annotation_class.annotation_type for c in skipped_annotations} # noqa: C417 console.print( f"Import of annotation class types '{', '.join(types)}' is not yet supported. Skipping {len(skipped_annotations)} " + "annotations from '{parsed_file.full_path}'.\n", @@ -615,9 +553,7 @@ def _warn_unsupported_annotations(parsed_files: List[AnnotationFile]) -> None: def _is_skeleton_class(the_class: dt.AnnotationClass) -> bool: - return ( - the_class.annotation_internal_type or the_class.annotation_type - ) == "skeleton" + return (the_class.annotation_internal_type or the_class.annotation_type) == "skeleton" def _get_skeleton_name(skeleton: dt.AnnotationClass) -> str: @@ -636,15 +572,10 @@ def _handle_subs( elif sub.annotation_type == "attributes": attributes_with_key = [] for attr in sub.data: - if ( - annotation_class_id in attributes - and attr in attributes[annotation_class_id] - ): + if annotation_class_id in attributes and attr in attributes[annotation_class_id]: attributes_with_key.append(attributes[annotation_class_id][attr]) else: - print( - f"The attribute '{attr}' for class '{annotation.annotation_class.name}' was not imported." - ) + print(f"The attribute '{attr}' for class '{annotation.annotation_class.name}' was not imported.") data["attributes"] = {"attributes": attributes_with_key} elif sub.annotation_type == "instance_id": @@ -662,33 +593,25 @@ def _to_complex_polygon(paths: list[str]) -> Dict[str, Any]: } -def _handle_polygon( - annotation: dt.Annotation, data: dt.DictFreeForm -) -> dt.DictFreeForm: +def _handle_polygon(annotation: dt.Annotation, data: dt.DictFreeForm) -> dt.DictFreeForm: polygon = data.get("polygon") if polygon is not None: if "paths" in polygon and len(polygon["paths"]) == 1: data["polygon"]["path"] = annotation.data["paths"][0] - del data["paths"] + if "paths" in data: + del data["paths"] data = _handle_complex_polygon(annotation, data) return data -def _handle_complex_polygon( - annotation: dt.Annotation, data: dt.DictFreeForm -) -> dt.DictFreeForm: +def _handle_complex_polygon(annotation: dt.Annotation, data: dt.DictFreeForm) -> dt.DictFreeForm: if "complex_polygon" in data: del data["complex_polygon"] data["polygon"] = _to_complex_polygon(annotation.data["paths"]) - elif ( - "polygon" in data - and "paths" in data["polygon"] - and len(data["paths"]["polygon"]) > 1 - ): + elif "polygon" in data and "paths" in annotation.data["polygon"] and len(annotation.data["paths"]["polygon"]) > 1: data["polygon"] = _to_complex_polygon(annotation.data["paths"]) - print(f"complex data : {data}") return data @@ -698,25 +621,17 @@ def _annotators_or_reviewers_to_payload( return [{"email": actor.email, "role": role.value} for actor in actors] -def _handle_reviewers( - annotation: dt.Annotation, import_reviewers: bool -) -> List[dt.DictFreeForm]: +def _handle_reviewers(annotation: dt.Annotation, import_reviewers: bool) -> List[dt.DictFreeForm]: if import_reviewers: if annotation.reviewers: - return _annotators_or_reviewers_to_payload( - annotation.reviewers, dt.AnnotationAuthorRole.REVIEWER - ) + return _annotators_or_reviewers_to_payload(annotation.reviewers, dt.AnnotationAuthorRole.REVIEWER) return [] -def _handle_annotators( - annotation: dt.Annotation, import_annotators: bool -) -> List[dt.DictFreeForm]: +def _handle_annotators(annotation: dt.Annotation, import_annotators: bool) -> List[dt.DictFreeForm]: if import_annotators: if annotation.annotators: - return _annotators_or_reviewers_to_payload( - annotation.annotators, dt.AnnotationAuthorRole.ANNOTATOR - ) + return _annotators_or_reviewers_to_payload(annotation.annotators, dt.AnnotationAuthorRole.ANNOTATOR) return [] @@ -739,13 +654,10 @@ def _get_annotation_data( data = {annotation_class.annotation_type: annotation.data} data = _handle_polygon(annotation, data) data = _handle_subs(annotation, data, annotation_class_id, attributes) - return data -def _handle_slot_names( - annotation: dt.Annotation, dataset_version: int, default_slot_name: str -) -> dt.Annotation: +def _handle_slot_names(annotation: dt.Annotation, dataset_version: int, default_slot_name: str) -> dt.Annotation: if not annotation.slot_names and dataset_version > 1: annotation.slot_names.extend([default_slot_name]) @@ -775,28 +687,18 @@ def _import_annotations( serialized_annotations = [] for annotation in annotations: annotation_class = annotation.annotation_class - annotation_type = ( - annotation_class.annotation_internal_type - or annotation_class.annotation_type - ) + annotation_type = annotation_class.annotation_internal_type or annotation_class.annotation_type - if ( - annotation_type not in remote_classes - or annotation_class.name not in remote_classes[annotation_type] - ): + if annotation_type not in remote_classes or annotation_class.name not in remote_classes[annotation_type]: if annotation_type not in remote_classes: logger.warning( f"Annotation type '{annotation_type}' is not in the remote classes, skipping import of annotation '{annotation_class.name}'" ) else: - logger.warning( - f"Annotation '{annotation_class.name}' is not in the remote classes, skipping import" - ) + logger.warning(f"Annotation '{annotation_class.name}' is not in the remote classes, skipping import") continue - annotation_class_id: str = remote_classes[annotation_type][ - annotation_class.name - ] + annotation_class_id: str = remote_classes[annotation_type][annotation_class.name] data = _get_annotation_data(annotation, annotation_class_id, attributes) From 4371111b10d56c5e3967f11c2df1c840a7a29dce Mon Sep 17 00:00:00 2001 From: Christoffer Date: Wed, 22 Nov 2023 13:21:23 +0100 Subject: [PATCH 48/71] black and ruff --fix all --- darwin/importer/importer.py | 179 +++++++++++++++++++++++++++--------- e2e_tests/conftest.py | 2 +- e2e_tests/helpers.py | 10 +- e2e_tests/test_darwin.py | 1 - 4 files changed, 140 insertions(+), 52 deletions(-) diff --git a/darwin/importer/importer.py b/darwin/importer/importer.py index 6ec1721b1..721e59e64 100644 --- a/darwin/importer/importer.py +++ b/darwin/importer/importer.py @@ -70,7 +70,9 @@ current_version=__version__, details=DEPRECATION_MESSAGE, ) -def build_main_annotations_lookup_table(annotation_classes: List[Dict[str, Unknown]]) -> Dict[str, Unknown]: +def build_main_annotations_lookup_table( + annotation_classes: List[Dict[str, Unknown]] +) -> Dict[str, Unknown]: MAIN_ANNOTATION_TYPES = [ "bounding_box", "cuboid", @@ -165,7 +167,10 @@ def maybe_console(*args: Union[str, int, float]) -> None: def _get_files_for_parsing(file_paths: List[PathLike]) -> List[Path]: - packed_files = [filepath.glob("**/*") if filepath.is_dir() else [filepath] for filepath in map(Path, file_paths)] + packed_files = [ + filepath.glob("**/*") if filepath.is_dir() else [filepath] + for filepath in map(Path, file_paths) + ] return [file for files in packed_files for file in files] @@ -232,18 +237,30 @@ def _resolve_annotation_classes( local_classes_not_in_team: Set[dt.AnnotationClass] = set() for local_cls in local_annotation_classes: - local_annotation_type = local_cls.annotation_internal_type or local_cls.annotation_type + local_annotation_type = ( + local_cls.annotation_internal_type or local_cls.annotation_type + ) # Only add the new class if it doesn't exist remotely already - if local_annotation_type in classes_in_dataset and local_cls.name in classes_in_dataset[local_annotation_type]: + if ( + local_annotation_type in classes_in_dataset + and local_cls.name in classes_in_dataset[local_annotation_type] + ): continue # Only add the new class if it's not included in the list of the missing classes already - if local_cls.name in [missing_class.name for missing_class in local_classes_not_in_dataset]: + if local_cls.name in [ + missing_class.name for missing_class in local_classes_not_in_dataset + ]: continue - if local_cls.name in [missing_class.name for missing_class in local_classes_not_in_team]: + if local_cls.name in [ + missing_class.name for missing_class in local_classes_not_in_team + ]: continue - if local_annotation_type in classes_in_team and local_cls.name in classes_in_team[local_annotation_type]: + if ( + local_annotation_type in classes_in_team + and local_cls.name in classes_in_team[local_annotation_type] + ): local_classes_not_in_dataset.add(local_cls) else: local_classes_not_in_team.add(local_cls) @@ -322,14 +339,18 @@ def import_annotations( # noqa: C901 "The options 'append' and 'delete_for_empty' cannot be used together. Use only one of them." ) - cpu_limit, use_multi_cpu = _get_multi_cpu_settings(cpu_limit, cpu_count(), use_multi_cpu) + cpu_limit, use_multi_cpu = _get_multi_cpu_settings( + cpu_limit, cpu_count(), use_multi_cpu + ) if use_multi_cpu: console.print(f"Using {cpu_limit} CPUs for parsing...", style="info") else: console.print("Using 1 CPU for parsing...", style="info") if not isinstance(file_paths, list): - raise ValueError(f"file_paths must be a list of 'Path' or 'str'. Current value: {file_paths}") + raise ValueError( + f"file_paths must be a list of 'Path' or 'str'. Current value: {file_paths}" + ) console.print("Fetching remote class list...", style="info") team_classes: List[dt.DictFreeForm] = dataset.fetch_remote_classes(True) @@ -343,10 +364,18 @@ def import_annotations( # noqa: C901 ) classes_in_dataset: dt.DictFreeForm = build_main_annotations_lookup_table( - [cls for cls in team_classes if cls["available"] or cls["name"] in GLOBAL_CLASSES] + [ + cls + for cls in team_classes + if cls["available"] or cls["name"] in GLOBAL_CLASSES + ] ) classes_in_team: dt.DictFreeForm = build_main_annotations_lookup_table( - [cls for cls in team_classes if not cls["available"] and cls["name"] not in GLOBAL_CLASSES] + [ + cls + for cls in team_classes + if not cls["available"] and cls["name"] not in GLOBAL_CLASSES + ] ) attributes = build_attribute_lookup(dataset) @@ -364,7 +393,9 @@ def import_annotations( # noqa: C901 parsed_files: List[AnnotationFile] = flatten_list(list(maybe_parsed_files)) - filenames: List[str] = [parsed_file.filename for parsed_file in parsed_files if parsed_file is not None] + filenames: List[str] = [ + parsed_file.filename for parsed_file in parsed_files if parsed_file is not None + ] console.print("Fetching remote file list...", style="info") # This call will only filter by filename; so can return a superset of matched files across different paths @@ -399,7 +430,9 @@ def import_annotations( # noqa: C901 style="warning", ) for local_file in local_files_missing_remotely: - console.print(f"\t{local_file.path}: '{local_file.full_path}'", style="warning") + console.print( + f"\t{local_file.path}: '{local_file.full_path}'", style="warning" + ) if class_prompt and not secure_continue_request(): return @@ -408,18 +441,26 @@ def import_annotations( # noqa: C901 local_classes_not_in_dataset, local_classes_not_in_team, ) = _resolve_annotation_classes( - [annotation_class for file in local_files for annotation_class in file.annotation_classes], + [ + annotation_class + for file in local_files + for annotation_class in file.annotation_classes + ], classes_in_dataset, classes_in_team, ) - console.print(f"{len(local_classes_not_in_team)} classes needs to be created.", style="info") + console.print( + f"{len(local_classes_not_in_team)} classes needs to be created.", style="info" + ) console.print( f"{len(local_classes_not_in_dataset)} classes needs to be added to {dataset.identifier}", style="info", ) - missing_skeletons: List[dt.AnnotationClass] = list(filter(_is_skeleton_class, local_classes_not_in_team)) + missing_skeletons: List[dt.AnnotationClass] = list( + filter(_is_skeleton_class, local_classes_not_in_team) + ) missing_skeleton_names: str = ", ".join(map(_get_skeleton_name, missing_skeletons)) if missing_skeletons: console.print( @@ -443,7 +484,9 @@ def import_annotations( # noqa: C901 missing_class.annotation_internal_type or missing_class.annotation_type, ) if local_classes_not_in_dataset: - console.print(f"About to add the following classes to {dataset.identifier}", style="info") + console.print( + f"About to add the following classes to {dataset.identifier}", style="info" + ) for cls in local_classes_not_in_dataset: dataset.add_annotation_class(cls) @@ -458,7 +501,9 @@ def import_annotations( # noqa: C901 remote_classes = build_main_annotations_lookup_table(team_classes) if dataset.version == 1: - console.print("Importing annotations...\nEmpty annotations will be skipped.", style="info") + console.print( + "Importing annotations...\nEmpty annotations will be skipped.", style="info" + ) elif dataset.version == 2 and delete_for_empty: console.print( "Importing annotations...\nEmpty annotation file(s) will clear all existing annotations in matching remote files.", @@ -472,7 +517,9 @@ def import_annotations( # noqa: C901 # Need to re parse the files since we didn't save the annotations in memory for local_path in set(local_file.path for local_file in local_files): # noqa: C401 - imported_files: Union[List[dt.AnnotationFile], dt.AnnotationFile, None] = importer(local_path) + imported_files: Union[ + List[dt.AnnotationFile], dt.AnnotationFile, None + ] = importer(local_path) if imported_files is None: parsed_files = [] elif not isinstance(imported_files, List): @@ -481,13 +528,20 @@ def import_annotations( # noqa: C901 parsed_files = imported_files # remove files missing on the server - missing_files = [missing_file.full_path for missing_file in local_files_missing_remotely] - parsed_files = [parsed_file for parsed_file in parsed_files if parsed_file.full_path not in missing_files] + missing_files = [ + missing_file.full_path for missing_file in local_files_missing_remotely + ] + parsed_files = [ + parsed_file + for parsed_file in parsed_files + if parsed_file.full_path not in missing_files + ] files_to_not_track = [ file_to_track for file_to_track in parsed_files - if not file_to_track.annotations and (not delete_for_empty or dataset.version == 1) + if not file_to_track.annotations + and (not delete_for_empty or dataset.version == 1) ] for file in files_to_not_track: @@ -496,7 +550,9 @@ def import_annotations( # noqa: C901 style="warning", ) - files_to_track = [file for file in parsed_files if file not in files_to_not_track] + files_to_track = [ + file for file in parsed_files if file not in files_to_not_track + ] if files_to_track: _warn_unsupported_annotations(files_to_track) for parsed_file in track(files_to_track): @@ -521,12 +577,16 @@ def import_annotations( # noqa: C901 ) if errors: - console.print(f"Errors importing {parsed_file.filename}", style="error") + console.print( + f"Errors importing {parsed_file.filename}", style="error" + ) for error in errors: console.print(f"\t{error}", style="error") -def _get_multi_cpu_settings(cpu_limit: Optional[int], cpu_count: int, use_multi_cpu: bool) -> Tuple[int, bool]: +def _get_multi_cpu_settings( + cpu_limit: Optional[int], cpu_count: int, use_multi_cpu: bool +) -> Tuple[int, bool]: if cpu_limit == 1 or cpu_count == 1 or not use_multi_cpu: return 1, False @@ -544,7 +604,9 @@ def _warn_unsupported_annotations(parsed_files: List[AnnotationFile]) -> None: if annotation.annotation_class.annotation_type in UNSUPPORTED_CLASSES: skipped_annotations.append(annotation) if len(skipped_annotations) > 0: - types = {c.annotation_class.annotation_type for c in skipped_annotations} # noqa: C417 + types = { + c.annotation_class.annotation_type for c in skipped_annotations + } # noqa: C417 console.print( f"Import of annotation class types '{', '.join(types)}' is not yet supported. Skipping {len(skipped_annotations)} " + "annotations from '{parsed_file.full_path}'.\n", @@ -553,7 +615,9 @@ def _warn_unsupported_annotations(parsed_files: List[AnnotationFile]) -> None: def _is_skeleton_class(the_class: dt.AnnotationClass) -> bool: - return (the_class.annotation_internal_type or the_class.annotation_type) == "skeleton" + return ( + the_class.annotation_internal_type or the_class.annotation_type + ) == "skeleton" def _get_skeleton_name(skeleton: dt.AnnotationClass) -> str: @@ -572,10 +636,15 @@ def _handle_subs( elif sub.annotation_type == "attributes": attributes_with_key = [] for attr in sub.data: - if annotation_class_id in attributes and attr in attributes[annotation_class_id]: + if ( + annotation_class_id in attributes + and attr in attributes[annotation_class_id] + ): attributes_with_key.append(attributes[annotation_class_id][attr]) else: - print(f"The attribute '{attr}' for class '{annotation.annotation_class.name}' was not imported.") + print( + f"The attribute '{attr}' for class '{annotation.annotation_class.name}' was not imported." + ) data["attributes"] = {"attributes": attributes_with_key} elif sub.annotation_type == "instance_id": @@ -593,7 +662,9 @@ def _to_complex_polygon(paths: list[str]) -> Dict[str, Any]: } -def _handle_polygon(annotation: dt.Annotation, data: dt.DictFreeForm) -> dt.DictFreeForm: +def _handle_polygon( + annotation: dt.Annotation, data: dt.DictFreeForm +) -> dt.DictFreeForm: polygon = data.get("polygon") if polygon is not None: @@ -606,11 +677,17 @@ def _handle_polygon(annotation: dt.Annotation, data: dt.DictFreeForm) -> dt.Dict return data -def _handle_complex_polygon(annotation: dt.Annotation, data: dt.DictFreeForm) -> dt.DictFreeForm: +def _handle_complex_polygon( + annotation: dt.Annotation, data: dt.DictFreeForm +) -> dt.DictFreeForm: if "complex_polygon" in data: del data["complex_polygon"] data["polygon"] = _to_complex_polygon(annotation.data["paths"]) - elif "polygon" in data and "paths" in annotation.data["polygon"] and len(annotation.data["paths"]["polygon"]) > 1: + elif ( + "polygon" in data + and "paths" in annotation.data["polygon"] + and len(annotation.data["paths"]["polygon"]) > 1 + ): data["polygon"] = _to_complex_polygon(annotation.data["paths"]) return data @@ -621,17 +698,25 @@ def _annotators_or_reviewers_to_payload( return [{"email": actor.email, "role": role.value} for actor in actors] -def _handle_reviewers(annotation: dt.Annotation, import_reviewers: bool) -> List[dt.DictFreeForm]: +def _handle_reviewers( + annotation: dt.Annotation, import_reviewers: bool +) -> List[dt.DictFreeForm]: if import_reviewers: if annotation.reviewers: - return _annotators_or_reviewers_to_payload(annotation.reviewers, dt.AnnotationAuthorRole.REVIEWER) + return _annotators_or_reviewers_to_payload( + annotation.reviewers, dt.AnnotationAuthorRole.REVIEWER + ) return [] -def _handle_annotators(annotation: dt.Annotation, import_annotators: bool) -> List[dt.DictFreeForm]: +def _handle_annotators( + annotation: dt.Annotation, import_annotators: bool +) -> List[dt.DictFreeForm]: if import_annotators: if annotation.annotators: - return _annotators_or_reviewers_to_payload(annotation.annotators, dt.AnnotationAuthorRole.ANNOTATOR) + return _annotators_or_reviewers_to_payload( + annotation.annotators, dt.AnnotationAuthorRole.ANNOTATOR + ) return [] @@ -657,7 +742,9 @@ def _get_annotation_data( return data -def _handle_slot_names(annotation: dt.Annotation, dataset_version: int, default_slot_name: str) -> dt.Annotation: +def _handle_slot_names( + annotation: dt.Annotation, dataset_version: int, default_slot_name: str +) -> dt.Annotation: if not annotation.slot_names and dataset_version > 1: annotation.slot_names.extend([default_slot_name]) @@ -687,18 +774,28 @@ def _import_annotations( serialized_annotations = [] for annotation in annotations: annotation_class = annotation.annotation_class - annotation_type = annotation_class.annotation_internal_type or annotation_class.annotation_type + annotation_type = ( + annotation_class.annotation_internal_type + or annotation_class.annotation_type + ) - if annotation_type not in remote_classes or annotation_class.name not in remote_classes[annotation_type]: + if ( + annotation_type not in remote_classes + or annotation_class.name not in remote_classes[annotation_type] + ): if annotation_type not in remote_classes: logger.warning( f"Annotation type '{annotation_type}' is not in the remote classes, skipping import of annotation '{annotation_class.name}'" ) else: - logger.warning(f"Annotation '{annotation_class.name}' is not in the remote classes, skipping import") + logger.warning( + f"Annotation '{annotation_class.name}' is not in the remote classes, skipping import" + ) continue - annotation_class_id: str = remote_classes[annotation_type][annotation_class.name] + annotation_class_id: str = remote_classes[annotation_type][ + annotation_class.name + ] data = _get_annotation_data(annotation, annotation_class_id, attributes) diff --git a/e2e_tests/conftest.py b/e2e_tests/conftest.py index b44f786da..d26b4fbac 100644 --- a/e2e_tests/conftest.py +++ b/e2e_tests/conftest.py @@ -9,7 +9,7 @@ from darwin.future.data_objects.typing import UnknownType from e2e_tests.exceptions import E2EEnvironmentVariableNotSet -from e2e_tests.objects import ConfigValues, E2EDataset +from e2e_tests.objects import ConfigValues from e2e_tests.setup_tests import setup_tests, teardown_tests diff --git a/e2e_tests/helpers.py b/e2e_tests/helpers.py index 8d3b3d393..0c14b37f3 100644 --- a/e2e_tests/helpers.py +++ b/e2e_tests/helpers.py @@ -1,18 +1,10 @@ -import re -import tempfile -import uuid -from pathlib import Path from subprocess import run from time import sleep -from typing import Generator, Optional, Tuple +from typing import Optional -import pytest from attr import dataclass -from cv2 import exp from darwin.exceptions import DarwinException -from e2e_tests.objects import E2EDataset -from e2e_tests.setup_tests import create_random_image @dataclass diff --git a/e2e_tests/test_darwin.py b/e2e_tests/test_darwin.py index dd03d8f3c..1f495817d 100644 --- a/e2e_tests/test_darwin.py +++ b/e2e_tests/test_darwin.py @@ -4,7 +4,6 @@ import tempfile import uuid from pathlib import Path -from time import sleep from typing import Generator import pytest From da8bf83f8cc32abf250f32eebf25e2a85e6e31d4 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Wed, 22 Nov 2023 13:27:21 +0100 Subject: [PATCH 49/71] updated formatting --- darwin/importer/importer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/darwin/importer/importer.py b/darwin/importer/importer.py index 721e59e64..44c3d6115 100644 --- a/darwin/importer/importer.py +++ b/darwin/importer/importer.py @@ -655,7 +655,7 @@ def _handle_subs( return data -def _to_complex_polygon(paths: list[str]) -> Dict[str, Any]: +def _to_complex_polygon(paths: List[str]) -> Dict[str, Any]: return { "path": paths[0], "additional_paths": paths[1:], From 3f4b740e2d4510371f19ac221f1fbf61e74d18aa Mon Sep 17 00:00:00 2001 From: Christoffer Date: Wed, 22 Nov 2023 13:42:15 +0100 Subject: [PATCH 50/71] minor changes to complex polygon --- darwin/importer/importer.py | 179 +++++++++--------------------------- 1 file changed, 41 insertions(+), 138 deletions(-) diff --git a/darwin/importer/importer.py b/darwin/importer/importer.py index 44c3d6115..1e2b3c7a2 100644 --- a/darwin/importer/importer.py +++ b/darwin/importer/importer.py @@ -70,9 +70,7 @@ current_version=__version__, details=DEPRECATION_MESSAGE, ) -def build_main_annotations_lookup_table( - annotation_classes: List[Dict[str, Unknown]] -) -> Dict[str, Unknown]: +def build_main_annotations_lookup_table(annotation_classes: List[Dict[str, Unknown]]) -> Dict[str, Unknown]: MAIN_ANNOTATION_TYPES = [ "bounding_box", "cuboid", @@ -167,10 +165,7 @@ def maybe_console(*args: Union[str, int, float]) -> None: def _get_files_for_parsing(file_paths: List[PathLike]) -> List[Path]: - packed_files = [ - filepath.glob("**/*") if filepath.is_dir() else [filepath] - for filepath in map(Path, file_paths) - ] + packed_files = [filepath.glob("**/*") if filepath.is_dir() else [filepath] for filepath in map(Path, file_paths)] return [file for files in packed_files for file in files] @@ -237,30 +232,18 @@ def _resolve_annotation_classes( local_classes_not_in_team: Set[dt.AnnotationClass] = set() for local_cls in local_annotation_classes: - local_annotation_type = ( - local_cls.annotation_internal_type or local_cls.annotation_type - ) + local_annotation_type = local_cls.annotation_internal_type or local_cls.annotation_type # Only add the new class if it doesn't exist remotely already - if ( - local_annotation_type in classes_in_dataset - and local_cls.name in classes_in_dataset[local_annotation_type] - ): + if local_annotation_type in classes_in_dataset and local_cls.name in classes_in_dataset[local_annotation_type]: continue # Only add the new class if it's not included in the list of the missing classes already - if local_cls.name in [ - missing_class.name for missing_class in local_classes_not_in_dataset - ]: + if local_cls.name in [missing_class.name for missing_class in local_classes_not_in_dataset]: continue - if local_cls.name in [ - missing_class.name for missing_class in local_classes_not_in_team - ]: + if local_cls.name in [missing_class.name for missing_class in local_classes_not_in_team]: continue - if ( - local_annotation_type in classes_in_team - and local_cls.name in classes_in_team[local_annotation_type] - ): + if local_annotation_type in classes_in_team and local_cls.name in classes_in_team[local_annotation_type]: local_classes_not_in_dataset.add(local_cls) else: local_classes_not_in_team.add(local_cls) @@ -339,18 +322,14 @@ def import_annotations( # noqa: C901 "The options 'append' and 'delete_for_empty' cannot be used together. Use only one of them." ) - cpu_limit, use_multi_cpu = _get_multi_cpu_settings( - cpu_limit, cpu_count(), use_multi_cpu - ) + cpu_limit, use_multi_cpu = _get_multi_cpu_settings(cpu_limit, cpu_count(), use_multi_cpu) if use_multi_cpu: console.print(f"Using {cpu_limit} CPUs for parsing...", style="info") else: console.print("Using 1 CPU for parsing...", style="info") if not isinstance(file_paths, list): - raise ValueError( - f"file_paths must be a list of 'Path' or 'str'. Current value: {file_paths}" - ) + raise ValueError(f"file_paths must be a list of 'Path' or 'str'. Current value: {file_paths}") console.print("Fetching remote class list...", style="info") team_classes: List[dt.DictFreeForm] = dataset.fetch_remote_classes(True) @@ -364,18 +343,10 @@ def import_annotations( # noqa: C901 ) classes_in_dataset: dt.DictFreeForm = build_main_annotations_lookup_table( - [ - cls - for cls in team_classes - if cls["available"] or cls["name"] in GLOBAL_CLASSES - ] + [cls for cls in team_classes if cls["available"] or cls["name"] in GLOBAL_CLASSES] ) classes_in_team: dt.DictFreeForm = build_main_annotations_lookup_table( - [ - cls - for cls in team_classes - if not cls["available"] and cls["name"] not in GLOBAL_CLASSES - ] + [cls for cls in team_classes if not cls["available"] and cls["name"] not in GLOBAL_CLASSES] ) attributes = build_attribute_lookup(dataset) @@ -393,9 +364,7 @@ def import_annotations( # noqa: C901 parsed_files: List[AnnotationFile] = flatten_list(list(maybe_parsed_files)) - filenames: List[str] = [ - parsed_file.filename for parsed_file in parsed_files if parsed_file is not None - ] + filenames: List[str] = [parsed_file.filename for parsed_file in parsed_files if parsed_file is not None] console.print("Fetching remote file list...", style="info") # This call will only filter by filename; so can return a superset of matched files across different paths @@ -430,9 +399,7 @@ def import_annotations( # noqa: C901 style="warning", ) for local_file in local_files_missing_remotely: - console.print( - f"\t{local_file.path}: '{local_file.full_path}'", style="warning" - ) + console.print(f"\t{local_file.path}: '{local_file.full_path}'", style="warning") if class_prompt and not secure_continue_request(): return @@ -441,26 +408,18 @@ def import_annotations( # noqa: C901 local_classes_not_in_dataset, local_classes_not_in_team, ) = _resolve_annotation_classes( - [ - annotation_class - for file in local_files - for annotation_class in file.annotation_classes - ], + [annotation_class for file in local_files for annotation_class in file.annotation_classes], classes_in_dataset, classes_in_team, ) - console.print( - f"{len(local_classes_not_in_team)} classes needs to be created.", style="info" - ) + console.print(f"{len(local_classes_not_in_team)} classes needs to be created.", style="info") console.print( f"{len(local_classes_not_in_dataset)} classes needs to be added to {dataset.identifier}", style="info", ) - missing_skeletons: List[dt.AnnotationClass] = list( - filter(_is_skeleton_class, local_classes_not_in_team) - ) + missing_skeletons: List[dt.AnnotationClass] = list(filter(_is_skeleton_class, local_classes_not_in_team)) missing_skeleton_names: str = ", ".join(map(_get_skeleton_name, missing_skeletons)) if missing_skeletons: console.print( @@ -484,9 +443,7 @@ def import_annotations( # noqa: C901 missing_class.annotation_internal_type or missing_class.annotation_type, ) if local_classes_not_in_dataset: - console.print( - f"About to add the following classes to {dataset.identifier}", style="info" - ) + console.print(f"About to add the following classes to {dataset.identifier}", style="info") for cls in local_classes_not_in_dataset: dataset.add_annotation_class(cls) @@ -501,9 +458,7 @@ def import_annotations( # noqa: C901 remote_classes = build_main_annotations_lookup_table(team_classes) if dataset.version == 1: - console.print( - "Importing annotations...\nEmpty annotations will be skipped.", style="info" - ) + console.print("Importing annotations...\nEmpty annotations will be skipped.", style="info") elif dataset.version == 2 and delete_for_empty: console.print( "Importing annotations...\nEmpty annotation file(s) will clear all existing annotations in matching remote files.", @@ -517,9 +472,7 @@ def import_annotations( # noqa: C901 # Need to re parse the files since we didn't save the annotations in memory for local_path in set(local_file.path for local_file in local_files): # noqa: C401 - imported_files: Union[ - List[dt.AnnotationFile], dt.AnnotationFile, None - ] = importer(local_path) + imported_files: Union[List[dt.AnnotationFile], dt.AnnotationFile, None] = importer(local_path) if imported_files is None: parsed_files = [] elif not isinstance(imported_files, List): @@ -528,20 +481,13 @@ def import_annotations( # noqa: C901 parsed_files = imported_files # remove files missing on the server - missing_files = [ - missing_file.full_path for missing_file in local_files_missing_remotely - ] - parsed_files = [ - parsed_file - for parsed_file in parsed_files - if parsed_file.full_path not in missing_files - ] + missing_files = [missing_file.full_path for missing_file in local_files_missing_remotely] + parsed_files = [parsed_file for parsed_file in parsed_files if parsed_file.full_path not in missing_files] files_to_not_track = [ file_to_track for file_to_track in parsed_files - if not file_to_track.annotations - and (not delete_for_empty or dataset.version == 1) + if not file_to_track.annotations and (not delete_for_empty or dataset.version == 1) ] for file in files_to_not_track: @@ -550,9 +496,7 @@ def import_annotations( # noqa: C901 style="warning", ) - files_to_track = [ - file for file in parsed_files if file not in files_to_not_track - ] + files_to_track = [file for file in parsed_files if file not in files_to_not_track] if files_to_track: _warn_unsupported_annotations(files_to_track) for parsed_file in track(files_to_track): @@ -577,16 +521,12 @@ def import_annotations( # noqa: C901 ) if errors: - console.print( - f"Errors importing {parsed_file.filename}", style="error" - ) + console.print(f"Errors importing {parsed_file.filename}", style="error") for error in errors: console.print(f"\t{error}", style="error") -def _get_multi_cpu_settings( - cpu_limit: Optional[int], cpu_count: int, use_multi_cpu: bool -) -> Tuple[int, bool]: +def _get_multi_cpu_settings(cpu_limit: Optional[int], cpu_count: int, use_multi_cpu: bool) -> Tuple[int, bool]: if cpu_limit == 1 or cpu_count == 1 or not use_multi_cpu: return 1, False @@ -604,9 +544,7 @@ def _warn_unsupported_annotations(parsed_files: List[AnnotationFile]) -> None: if annotation.annotation_class.annotation_type in UNSUPPORTED_CLASSES: skipped_annotations.append(annotation) if len(skipped_annotations) > 0: - types = { - c.annotation_class.annotation_type for c in skipped_annotations - } # noqa: C417 + types = {c.annotation_class.annotation_type for c in skipped_annotations} # noqa: C417 console.print( f"Import of annotation class types '{', '.join(types)}' is not yet supported. Skipping {len(skipped_annotations)} " + "annotations from '{parsed_file.full_path}'.\n", @@ -615,9 +553,7 @@ def _warn_unsupported_annotations(parsed_files: List[AnnotationFile]) -> None: def _is_skeleton_class(the_class: dt.AnnotationClass) -> bool: - return ( - the_class.annotation_internal_type or the_class.annotation_type - ) == "skeleton" + return (the_class.annotation_internal_type or the_class.annotation_type) == "skeleton" def _get_skeleton_name(skeleton: dt.AnnotationClass) -> str: @@ -636,15 +572,10 @@ def _handle_subs( elif sub.annotation_type == "attributes": attributes_with_key = [] for attr in sub.data: - if ( - annotation_class_id in attributes - and attr in attributes[annotation_class_id] - ): + if annotation_class_id in attributes and attr in attributes[annotation_class_id]: attributes_with_key.append(attributes[annotation_class_id][attr]) else: - print( - f"The attribute '{attr}' for class '{annotation.annotation_class.name}' was not imported." - ) + print(f"The attribute '{attr}' for class '{annotation.annotation_class.name}' was not imported.") data["attributes"] = {"attributes": attributes_with_key} elif sub.annotation_type == "instance_id": @@ -662,9 +593,7 @@ def _to_complex_polygon(paths: List[str]) -> Dict[str, Any]: } -def _handle_polygon( - annotation: dt.Annotation, data: dt.DictFreeForm -) -> dt.DictFreeForm: +def _handle_polygon(annotation: dt.Annotation, data: dt.DictFreeForm) -> dt.DictFreeForm: polygon = data.get("polygon") if polygon is not None: @@ -677,17 +606,11 @@ def _handle_polygon( return data -def _handle_complex_polygon( - annotation: dt.Annotation, data: dt.DictFreeForm -) -> dt.DictFreeForm: +def _handle_complex_polygon(annotation: dt.Annotation, data: dt.DictFreeForm) -> dt.DictFreeForm: if "complex_polygon" in data: del data["complex_polygon"] data["polygon"] = _to_complex_polygon(annotation.data["paths"]) - elif ( - "polygon" in data - and "paths" in annotation.data["polygon"] - and len(annotation.data["paths"]["polygon"]) > 1 - ): + elif "polygon" in data and "paths" in data["polygon"] and len(data["polygon"]["polygon"]) > 1: data["polygon"] = _to_complex_polygon(annotation.data["paths"]) return data @@ -698,25 +621,17 @@ def _annotators_or_reviewers_to_payload( return [{"email": actor.email, "role": role.value} for actor in actors] -def _handle_reviewers( - annotation: dt.Annotation, import_reviewers: bool -) -> List[dt.DictFreeForm]: +def _handle_reviewers(annotation: dt.Annotation, import_reviewers: bool) -> List[dt.DictFreeForm]: if import_reviewers: if annotation.reviewers: - return _annotators_or_reviewers_to_payload( - annotation.reviewers, dt.AnnotationAuthorRole.REVIEWER - ) + return _annotators_or_reviewers_to_payload(annotation.reviewers, dt.AnnotationAuthorRole.REVIEWER) return [] -def _handle_annotators( - annotation: dt.Annotation, import_annotators: bool -) -> List[dt.DictFreeForm]: +def _handle_annotators(annotation: dt.Annotation, import_annotators: bool) -> List[dt.DictFreeForm]: if import_annotators: if annotation.annotators: - return _annotators_or_reviewers_to_payload( - annotation.annotators, dt.AnnotationAuthorRole.ANNOTATOR - ) + return _annotators_or_reviewers_to_payload(annotation.annotators, dt.AnnotationAuthorRole.ANNOTATOR) return [] @@ -742,9 +657,7 @@ def _get_annotation_data( return data -def _handle_slot_names( - annotation: dt.Annotation, dataset_version: int, default_slot_name: str -) -> dt.Annotation: +def _handle_slot_names(annotation: dt.Annotation, dataset_version: int, default_slot_name: str) -> dt.Annotation: if not annotation.slot_names and dataset_version > 1: annotation.slot_names.extend([default_slot_name]) @@ -774,28 +687,18 @@ def _import_annotations( serialized_annotations = [] for annotation in annotations: annotation_class = annotation.annotation_class - annotation_type = ( - annotation_class.annotation_internal_type - or annotation_class.annotation_type - ) + annotation_type = annotation_class.annotation_internal_type or annotation_class.annotation_type - if ( - annotation_type not in remote_classes - or annotation_class.name not in remote_classes[annotation_type] - ): + if annotation_type not in remote_classes or annotation_class.name not in remote_classes[annotation_type]: if annotation_type not in remote_classes: logger.warning( f"Annotation type '{annotation_type}' is not in the remote classes, skipping import of annotation '{annotation_class.name}'" ) else: - logger.warning( - f"Annotation '{annotation_class.name}' is not in the remote classes, skipping import" - ) + logger.warning(f"Annotation '{annotation_class.name}' is not in the remote classes, skipping import") continue - annotation_class_id: str = remote_classes[annotation_type][ - annotation_class.name - ] + annotation_class_id: str = remote_classes[annotation_type][annotation_class.name] data = _get_annotation_data(annotation, annotation_class_id, attributes) From 28024bc7079decef0e7750b0f4f51daadc691b90 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Wed, 22 Nov 2023 13:55:07 +0100 Subject: [PATCH 51/71] minor fix --- darwin/importer/importer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/darwin/importer/importer.py b/darwin/importer/importer.py index 1e2b3c7a2..2047bd0af 100644 --- a/darwin/importer/importer.py +++ b/darwin/importer/importer.py @@ -610,7 +610,7 @@ def _handle_complex_polygon(annotation: dt.Annotation, data: dt.DictFreeForm) -> if "complex_polygon" in data: del data["complex_polygon"] data["polygon"] = _to_complex_polygon(annotation.data["paths"]) - elif "polygon" in data and "paths" in data["polygon"] and len(data["polygon"]["polygon"]) > 1: + elif "polygon" in data and "paths" in data["polygon"] and len(data["polygon"]["paths"]) > 1: data["polygon"] = _to_complex_polygon(annotation.data["paths"]) return data From 31a831412f60b7d2d96da3da55c8ed612d6b54e0 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Wed, 22 Nov 2023 13:55:33 +0100 Subject: [PATCH 52/71] black --- darwin/importer/importer.py | 179 +++++++++++++++++++++++++++--------- 1 file changed, 138 insertions(+), 41 deletions(-) diff --git a/darwin/importer/importer.py b/darwin/importer/importer.py index 2047bd0af..dd292e4d5 100644 --- a/darwin/importer/importer.py +++ b/darwin/importer/importer.py @@ -70,7 +70,9 @@ current_version=__version__, details=DEPRECATION_MESSAGE, ) -def build_main_annotations_lookup_table(annotation_classes: List[Dict[str, Unknown]]) -> Dict[str, Unknown]: +def build_main_annotations_lookup_table( + annotation_classes: List[Dict[str, Unknown]] +) -> Dict[str, Unknown]: MAIN_ANNOTATION_TYPES = [ "bounding_box", "cuboid", @@ -165,7 +167,10 @@ def maybe_console(*args: Union[str, int, float]) -> None: def _get_files_for_parsing(file_paths: List[PathLike]) -> List[Path]: - packed_files = [filepath.glob("**/*") if filepath.is_dir() else [filepath] for filepath in map(Path, file_paths)] + packed_files = [ + filepath.glob("**/*") if filepath.is_dir() else [filepath] + for filepath in map(Path, file_paths) + ] return [file for files in packed_files for file in files] @@ -232,18 +237,30 @@ def _resolve_annotation_classes( local_classes_not_in_team: Set[dt.AnnotationClass] = set() for local_cls in local_annotation_classes: - local_annotation_type = local_cls.annotation_internal_type or local_cls.annotation_type + local_annotation_type = ( + local_cls.annotation_internal_type or local_cls.annotation_type + ) # Only add the new class if it doesn't exist remotely already - if local_annotation_type in classes_in_dataset and local_cls.name in classes_in_dataset[local_annotation_type]: + if ( + local_annotation_type in classes_in_dataset + and local_cls.name in classes_in_dataset[local_annotation_type] + ): continue # Only add the new class if it's not included in the list of the missing classes already - if local_cls.name in [missing_class.name for missing_class in local_classes_not_in_dataset]: + if local_cls.name in [ + missing_class.name for missing_class in local_classes_not_in_dataset + ]: continue - if local_cls.name in [missing_class.name for missing_class in local_classes_not_in_team]: + if local_cls.name in [ + missing_class.name for missing_class in local_classes_not_in_team + ]: continue - if local_annotation_type in classes_in_team and local_cls.name in classes_in_team[local_annotation_type]: + if ( + local_annotation_type in classes_in_team + and local_cls.name in classes_in_team[local_annotation_type] + ): local_classes_not_in_dataset.add(local_cls) else: local_classes_not_in_team.add(local_cls) @@ -322,14 +339,18 @@ def import_annotations( # noqa: C901 "The options 'append' and 'delete_for_empty' cannot be used together. Use only one of them." ) - cpu_limit, use_multi_cpu = _get_multi_cpu_settings(cpu_limit, cpu_count(), use_multi_cpu) + cpu_limit, use_multi_cpu = _get_multi_cpu_settings( + cpu_limit, cpu_count(), use_multi_cpu + ) if use_multi_cpu: console.print(f"Using {cpu_limit} CPUs for parsing...", style="info") else: console.print("Using 1 CPU for parsing...", style="info") if not isinstance(file_paths, list): - raise ValueError(f"file_paths must be a list of 'Path' or 'str'. Current value: {file_paths}") + raise ValueError( + f"file_paths must be a list of 'Path' or 'str'. Current value: {file_paths}" + ) console.print("Fetching remote class list...", style="info") team_classes: List[dt.DictFreeForm] = dataset.fetch_remote_classes(True) @@ -343,10 +364,18 @@ def import_annotations( # noqa: C901 ) classes_in_dataset: dt.DictFreeForm = build_main_annotations_lookup_table( - [cls for cls in team_classes if cls["available"] or cls["name"] in GLOBAL_CLASSES] + [ + cls + for cls in team_classes + if cls["available"] or cls["name"] in GLOBAL_CLASSES + ] ) classes_in_team: dt.DictFreeForm = build_main_annotations_lookup_table( - [cls for cls in team_classes if not cls["available"] and cls["name"] not in GLOBAL_CLASSES] + [ + cls + for cls in team_classes + if not cls["available"] and cls["name"] not in GLOBAL_CLASSES + ] ) attributes = build_attribute_lookup(dataset) @@ -364,7 +393,9 @@ def import_annotations( # noqa: C901 parsed_files: List[AnnotationFile] = flatten_list(list(maybe_parsed_files)) - filenames: List[str] = [parsed_file.filename for parsed_file in parsed_files if parsed_file is not None] + filenames: List[str] = [ + parsed_file.filename for parsed_file in parsed_files if parsed_file is not None + ] console.print("Fetching remote file list...", style="info") # This call will only filter by filename; so can return a superset of matched files across different paths @@ -399,7 +430,9 @@ def import_annotations( # noqa: C901 style="warning", ) for local_file in local_files_missing_remotely: - console.print(f"\t{local_file.path}: '{local_file.full_path}'", style="warning") + console.print( + f"\t{local_file.path}: '{local_file.full_path}'", style="warning" + ) if class_prompt and not secure_continue_request(): return @@ -408,18 +441,26 @@ def import_annotations( # noqa: C901 local_classes_not_in_dataset, local_classes_not_in_team, ) = _resolve_annotation_classes( - [annotation_class for file in local_files for annotation_class in file.annotation_classes], + [ + annotation_class + for file in local_files + for annotation_class in file.annotation_classes + ], classes_in_dataset, classes_in_team, ) - console.print(f"{len(local_classes_not_in_team)} classes needs to be created.", style="info") + console.print( + f"{len(local_classes_not_in_team)} classes needs to be created.", style="info" + ) console.print( f"{len(local_classes_not_in_dataset)} classes needs to be added to {dataset.identifier}", style="info", ) - missing_skeletons: List[dt.AnnotationClass] = list(filter(_is_skeleton_class, local_classes_not_in_team)) + missing_skeletons: List[dt.AnnotationClass] = list( + filter(_is_skeleton_class, local_classes_not_in_team) + ) missing_skeleton_names: str = ", ".join(map(_get_skeleton_name, missing_skeletons)) if missing_skeletons: console.print( @@ -443,7 +484,9 @@ def import_annotations( # noqa: C901 missing_class.annotation_internal_type or missing_class.annotation_type, ) if local_classes_not_in_dataset: - console.print(f"About to add the following classes to {dataset.identifier}", style="info") + console.print( + f"About to add the following classes to {dataset.identifier}", style="info" + ) for cls in local_classes_not_in_dataset: dataset.add_annotation_class(cls) @@ -458,7 +501,9 @@ def import_annotations( # noqa: C901 remote_classes = build_main_annotations_lookup_table(team_classes) if dataset.version == 1: - console.print("Importing annotations...\nEmpty annotations will be skipped.", style="info") + console.print( + "Importing annotations...\nEmpty annotations will be skipped.", style="info" + ) elif dataset.version == 2 and delete_for_empty: console.print( "Importing annotations...\nEmpty annotation file(s) will clear all existing annotations in matching remote files.", @@ -472,7 +517,9 @@ def import_annotations( # noqa: C901 # Need to re parse the files since we didn't save the annotations in memory for local_path in set(local_file.path for local_file in local_files): # noqa: C401 - imported_files: Union[List[dt.AnnotationFile], dt.AnnotationFile, None] = importer(local_path) + imported_files: Union[ + List[dt.AnnotationFile], dt.AnnotationFile, None + ] = importer(local_path) if imported_files is None: parsed_files = [] elif not isinstance(imported_files, List): @@ -481,13 +528,20 @@ def import_annotations( # noqa: C901 parsed_files = imported_files # remove files missing on the server - missing_files = [missing_file.full_path for missing_file in local_files_missing_remotely] - parsed_files = [parsed_file for parsed_file in parsed_files if parsed_file.full_path not in missing_files] + missing_files = [ + missing_file.full_path for missing_file in local_files_missing_remotely + ] + parsed_files = [ + parsed_file + for parsed_file in parsed_files + if parsed_file.full_path not in missing_files + ] files_to_not_track = [ file_to_track for file_to_track in parsed_files - if not file_to_track.annotations and (not delete_for_empty or dataset.version == 1) + if not file_to_track.annotations + and (not delete_for_empty or dataset.version == 1) ] for file in files_to_not_track: @@ -496,7 +550,9 @@ def import_annotations( # noqa: C901 style="warning", ) - files_to_track = [file for file in parsed_files if file not in files_to_not_track] + files_to_track = [ + file for file in parsed_files if file not in files_to_not_track + ] if files_to_track: _warn_unsupported_annotations(files_to_track) for parsed_file in track(files_to_track): @@ -521,12 +577,16 @@ def import_annotations( # noqa: C901 ) if errors: - console.print(f"Errors importing {parsed_file.filename}", style="error") + console.print( + f"Errors importing {parsed_file.filename}", style="error" + ) for error in errors: console.print(f"\t{error}", style="error") -def _get_multi_cpu_settings(cpu_limit: Optional[int], cpu_count: int, use_multi_cpu: bool) -> Tuple[int, bool]: +def _get_multi_cpu_settings( + cpu_limit: Optional[int], cpu_count: int, use_multi_cpu: bool +) -> Tuple[int, bool]: if cpu_limit == 1 or cpu_count == 1 or not use_multi_cpu: return 1, False @@ -544,7 +604,9 @@ def _warn_unsupported_annotations(parsed_files: List[AnnotationFile]) -> None: if annotation.annotation_class.annotation_type in UNSUPPORTED_CLASSES: skipped_annotations.append(annotation) if len(skipped_annotations) > 0: - types = {c.annotation_class.annotation_type for c in skipped_annotations} # noqa: C417 + types = { + c.annotation_class.annotation_type for c in skipped_annotations + } # noqa: C417 console.print( f"Import of annotation class types '{', '.join(types)}' is not yet supported. Skipping {len(skipped_annotations)} " + "annotations from '{parsed_file.full_path}'.\n", @@ -553,7 +615,9 @@ def _warn_unsupported_annotations(parsed_files: List[AnnotationFile]) -> None: def _is_skeleton_class(the_class: dt.AnnotationClass) -> bool: - return (the_class.annotation_internal_type or the_class.annotation_type) == "skeleton" + return ( + the_class.annotation_internal_type or the_class.annotation_type + ) == "skeleton" def _get_skeleton_name(skeleton: dt.AnnotationClass) -> str: @@ -572,10 +636,15 @@ def _handle_subs( elif sub.annotation_type == "attributes": attributes_with_key = [] for attr in sub.data: - if annotation_class_id in attributes and attr in attributes[annotation_class_id]: + if ( + annotation_class_id in attributes + and attr in attributes[annotation_class_id] + ): attributes_with_key.append(attributes[annotation_class_id][attr]) else: - print(f"The attribute '{attr}' for class '{annotation.annotation_class.name}' was not imported.") + print( + f"The attribute '{attr}' for class '{annotation.annotation_class.name}' was not imported." + ) data["attributes"] = {"attributes": attributes_with_key} elif sub.annotation_type == "instance_id": @@ -593,7 +662,9 @@ def _to_complex_polygon(paths: List[str]) -> Dict[str, Any]: } -def _handle_polygon(annotation: dt.Annotation, data: dt.DictFreeForm) -> dt.DictFreeForm: +def _handle_polygon( + annotation: dt.Annotation, data: dt.DictFreeForm +) -> dt.DictFreeForm: polygon = data.get("polygon") if polygon is not None: @@ -606,11 +677,17 @@ def _handle_polygon(annotation: dt.Annotation, data: dt.DictFreeForm) -> dt.Dict return data -def _handle_complex_polygon(annotation: dt.Annotation, data: dt.DictFreeForm) -> dt.DictFreeForm: +def _handle_complex_polygon( + annotation: dt.Annotation, data: dt.DictFreeForm +) -> dt.DictFreeForm: if "complex_polygon" in data: del data["complex_polygon"] data["polygon"] = _to_complex_polygon(annotation.data["paths"]) - elif "polygon" in data and "paths" in data["polygon"] and len(data["polygon"]["paths"]) > 1: + elif ( + "polygon" in data + and "paths" in data["polygon"] + and len(data["polygon"]["paths"]) > 1 + ): data["polygon"] = _to_complex_polygon(annotation.data["paths"]) return data @@ -621,17 +698,25 @@ def _annotators_or_reviewers_to_payload( return [{"email": actor.email, "role": role.value} for actor in actors] -def _handle_reviewers(annotation: dt.Annotation, import_reviewers: bool) -> List[dt.DictFreeForm]: +def _handle_reviewers( + annotation: dt.Annotation, import_reviewers: bool +) -> List[dt.DictFreeForm]: if import_reviewers: if annotation.reviewers: - return _annotators_or_reviewers_to_payload(annotation.reviewers, dt.AnnotationAuthorRole.REVIEWER) + return _annotators_or_reviewers_to_payload( + annotation.reviewers, dt.AnnotationAuthorRole.REVIEWER + ) return [] -def _handle_annotators(annotation: dt.Annotation, import_annotators: bool) -> List[dt.DictFreeForm]: +def _handle_annotators( + annotation: dt.Annotation, import_annotators: bool +) -> List[dt.DictFreeForm]: if import_annotators: if annotation.annotators: - return _annotators_or_reviewers_to_payload(annotation.annotators, dt.AnnotationAuthorRole.ANNOTATOR) + return _annotators_or_reviewers_to_payload( + annotation.annotators, dt.AnnotationAuthorRole.ANNOTATOR + ) return [] @@ -657,7 +742,9 @@ def _get_annotation_data( return data -def _handle_slot_names(annotation: dt.Annotation, dataset_version: int, default_slot_name: str) -> dt.Annotation: +def _handle_slot_names( + annotation: dt.Annotation, dataset_version: int, default_slot_name: str +) -> dt.Annotation: if not annotation.slot_names and dataset_version > 1: annotation.slot_names.extend([default_slot_name]) @@ -687,18 +774,28 @@ def _import_annotations( serialized_annotations = [] for annotation in annotations: annotation_class = annotation.annotation_class - annotation_type = annotation_class.annotation_internal_type or annotation_class.annotation_type + annotation_type = ( + annotation_class.annotation_internal_type + or annotation_class.annotation_type + ) - if annotation_type not in remote_classes or annotation_class.name not in remote_classes[annotation_type]: + if ( + annotation_type not in remote_classes + or annotation_class.name not in remote_classes[annotation_type] + ): if annotation_type not in remote_classes: logger.warning( f"Annotation type '{annotation_type}' is not in the remote classes, skipping import of annotation '{annotation_class.name}'" ) else: - logger.warning(f"Annotation '{annotation_class.name}' is not in the remote classes, skipping import") + logger.warning( + f"Annotation '{annotation_class.name}' is not in the remote classes, skipping import" + ) continue - annotation_class_id: str = remote_classes[annotation_type][annotation_class.name] + annotation_class_id: str = remote_classes[annotation_type][ + annotation_class.name + ] data = _get_annotation_data(annotation, annotation_class_id, attributes) From 12239c8be6ec92499052f618e2f4856d30f46ca3 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Wed, 22 Nov 2023 17:49:38 +0100 Subject: [PATCH 53/71] minor updates based on comments --- darwin/exporter/formats/coco.py | 118 ++++++--------------- darwin/importer/importer.py | 180 ++++++++------------------------ 2 files changed, 72 insertions(+), 226 deletions(-) diff --git a/darwin/exporter/formats/coco.py b/darwin/exporter/formats/coco.py index 2b9709842..ea052d3ba 100644 --- a/darwin/exporter/formats/coco.py +++ b/darwin/exporter/formats/coco.py @@ -35,9 +35,7 @@ def export(annotation_files: Iterator[dt.AnnotationFile], output_dir: Path) -> N output = _build_json(list(annotation_files)) output_file_path = (output_dir / "output").with_suffix(".json") with open(output_file_path, "w") as f: - op = json.dumps( - output, option=json.OPT_INDENT_2 | json.OPT_SERIALIZE_NUMPY - ).decode("utf-8") + op = json.dumps(output, option=json.OPT_INDENT_2 | json.OPT_SERIALIZE_NUMPY).decode("utf-8") f.write(op) @@ -70,18 +68,12 @@ def calculate_categories(annotation_files: List[dt.AnnotationFile]) -> Dict[str, categories: Dict[str, int] = {} for annotation_file in annotation_files: for annotation_class in annotation_file.annotation_classes: - if ( - annotation_class.name not in categories - and annotation_class.annotation_type - in [ - "polygon", - "complex_polygon", - "bounding_box", - ] - ): - categories[annotation_class.name] = _calculate_category_id( - annotation_class - ) + if annotation_class.name not in categories and annotation_class.annotation_type in [ + "polygon", + "complex_polygon", + "bounding_box", + ]: + categories[annotation_class.name] = _calculate_category_id(annotation_class) return categories @@ -97,13 +89,8 @@ def calculate_tag_categories( categories: Dict[str, int] = {} for annotation_file in annotation_files: for annotation_class in annotation_file.annotation_classes: - if ( - annotation_class.name not in categories - and annotation_class.annotation_type == "tag" - ): - categories[annotation_class.name] = _calculate_category_id( - annotation_class - ) + if annotation_class.name not in categories and annotation_class.annotation_type == "tag": + categories[annotation_class.name] = _calculate_category_id(annotation_class) return categories @@ -142,9 +129,7 @@ def build_licenses() -> List[Dict[str, Any]]: current_version=__version__, details=DEPRECATION_MESSAGE, ) -def build_images( - annotation_files: List[dt.AnnotationFile], tag_categories: Dict[str, int] -) -> List[Dict[str, Any]]: +def build_images(annotation_files: List[dt.AnnotationFile], tag_categories: Dict[str, int]) -> List[Dict[str, Any]]: return [ build_image(annotation_file, tag_categories) for annotation_file in sorted(annotation_files, key=lambda x: x.seq) @@ -157,13 +142,9 @@ def build_images( current_version=__version__, details=DEPRECATION_MESSAGE, ) -def build_image( - annotation_file: dt.AnnotationFile, tag_categories: Dict[str, int] -) -> Dict[str, Any]: +def build_image(annotation_file: dt.AnnotationFile, tag_categories: Dict[str, int]) -> Dict[str, Any]: tags = [ - annotation - for annotation in annotation_file.annotations - if annotation.annotation_class.annotation_type == "tag" + annotation for annotation in annotation_file.annotations if annotation.annotation_class.annotation_type == "tag" ] return { "license": 0, @@ -193,9 +174,7 @@ def build_annotations( for annotation_file in annotation_files: for annotation in annotation_file.annotations: annotation_id += 1 - annotation_data = build_annotation( - annotation_file, annotation_id, annotation, categories - ) + annotation_data = build_annotation(annotation_file, annotation_id, annotation, categories) if annotation_data: yield annotation_data @@ -214,9 +193,7 @@ def build_annotation( ) -> Optional[Dict[str, Any]]: annotation_type = annotation.annotation_class.annotation_type if annotation_type == "polygon": - sequences = convert_polygons_to_sequences( - annotation.data["path"], rounding=False - ) + sequences = convert_polygons_to_sequences(annotation.data["path"], rounding=False) x_coords = [s[0::2] for s in sequences] y_coords = [s[1::2] for s in sequences] min_x = np.min([np.min(x_coord) for x_coord in x_coords]) @@ -226,12 +203,7 @@ def build_annotation( w = max_x - min_x h = max_y - min_y # Compute the area of the polygon - poly_area = np.sum( - [ - polygon_area(x_coord, y_coord) - for x_coord, y_coord in zip(x_coords, y_coords) - ] - ) + poly_area = np.sum([polygon_area(x_coord, y_coord) for x_coord, y_coord in zip(x_coords, y_coords)]) return { "id": annotation_id, @@ -373,18 +345,12 @@ def _calculate_categories(annotation_files: List[dt.AnnotationFile]) -> Dict[str categories: Dict[str, int] = {} for annotation_file in annotation_files: for annotation_class in annotation_file.annotation_classes: - if ( - annotation_class.name not in categories - and annotation_class.annotation_type - in [ - "polygon", - "complex_polygon", - "bounding_box", - ] - ): - categories[annotation_class.name] = _calculate_category_id( - annotation_class - ) + if annotation_class.name not in categories and annotation_class.annotation_type in [ + "polygon", + "complex_polygon", + "bounding_box", + ]: + categories[annotation_class.name] = _calculate_category_id(annotation_class) return categories @@ -394,13 +360,8 @@ def _calculate_tag_categories( categories: Dict[str, int] = {} for annotation_file in annotation_files: for annotation_class in annotation_file.annotation_classes: - if ( - annotation_class.name not in categories - and annotation_class.annotation_type == "tag" - ): - categories[annotation_class.name] = _calculate_category_id( - annotation_class - ) + if annotation_class.name not in categories and annotation_class.annotation_type == "tag": + categories[annotation_class.name] = _calculate_category_id(annotation_class) return categories @@ -425,22 +386,16 @@ def _build_licenses() -> List[Dict[str, Any]]: return [{"url": "n/a", "id": 0, "name": "placeholder license"}] -def _build_images( - annotation_files: List[dt.AnnotationFile], tag_categories: Dict[str, int] -) -> List[Dict[str, Any]]: +def _build_images(annotation_files: List[dt.AnnotationFile], tag_categories: Dict[str, int]) -> List[Dict[str, Any]]: return [ _build_image(annotation_file, tag_categories) for annotation_file in sorted(annotation_files, key=lambda x: x.seq) ] -def _build_image( - annotation_file: dt.AnnotationFile, tag_categories: Dict[str, int] -) -> Dict[str, Any]: +def _build_image(annotation_file: dt.AnnotationFile, tag_categories: Dict[str, int]) -> Dict[str, Any]: tags = [ - annotation - for annotation in annotation_file.annotations - if annotation.annotation_class.annotation_type == "tag" + annotation for annotation in annotation_file.annotations if annotation.annotation_class.annotation_type == "tag" ] return { @@ -465,9 +420,7 @@ def _build_image_id(annotation_file: dt.AnnotationFile) -> int: if annotation_file.seq: return annotation_file.seq else: - full_path = str( - Path(annotation_file.remote_path or "/") / Path(annotation_file.filename) - ) + full_path = str(Path(annotation_file.remote_path or "/") / Path(annotation_file.filename)) return crc32(str.encode(full_path)) @@ -478,9 +431,7 @@ def _build_annotations( for annotation_file in annotation_files: for annotation in annotation_file.annotations: annotation_id += 1 - annotation_data = _build_annotation( - annotation_file, annotation_id, annotation, categories - ) + annotation_data = _build_annotation(annotation_file, annotation_id, annotation, categories) if annotation_data: yield annotation_data @@ -494,9 +445,7 @@ def _build_annotation( annotation_type = annotation.annotation_class.annotation_type if annotation_type == "polygon": - sequences = convert_polygons_to_sequences( - annotation.data["path"], rounding=False - ) + sequences = convert_polygons_to_sequences(annotation.data["path"], rounding=False) x_coords = [s[0::2] for s in sequences] y_coords = [s[1::2] for s in sequences] min_x = np.min([np.min(x_coord) for x_coord in x_coords]) @@ -506,12 +455,7 @@ def _build_annotation( w = max_x - min_x h = max_y - min_y # Compute the area of the polygon - poly_area = np.sum( - [ - _polygon_area(x_coord, y_coord) - for x_coord, y_coord in zip(x_coords, y_coords) - ] - ) + poly_area = np.sum([_polygon_area(x_coord, y_coord) for x_coord, y_coord in zip(x_coords, y_coords)]) return { "id": annotation_id, @@ -562,7 +506,7 @@ def _build_annotation( return _build_annotation( annotation_file, annotation_id, - # TODO Update this to V2 + # TODO Investigate if this should be updated to Darwin v2 or not. dt.make_polygon( annotation.annotation_class.name, [ diff --git a/darwin/importer/importer.py b/darwin/importer/importer.py index dd292e4d5..b2adc77c6 100644 --- a/darwin/importer/importer.py +++ b/darwin/importer/importer.py @@ -70,9 +70,7 @@ current_version=__version__, details=DEPRECATION_MESSAGE, ) -def build_main_annotations_lookup_table( - annotation_classes: List[Dict[str, Unknown]] -) -> Dict[str, Unknown]: +def build_main_annotations_lookup_table(annotation_classes: List[Dict[str, Unknown]]) -> Dict[str, Unknown]: MAIN_ANNOTATION_TYPES = [ "bounding_box", "cuboid", @@ -167,10 +165,7 @@ def maybe_console(*args: Union[str, int, float]) -> None: def _get_files_for_parsing(file_paths: List[PathLike]) -> List[Path]: - packed_files = [ - filepath.glob("**/*") if filepath.is_dir() else [filepath] - for filepath in map(Path, file_paths) - ] + packed_files = [filepath.glob("**/*") if filepath.is_dir() else [filepath] for filepath in map(Path, file_paths)] return [file for files in packed_files for file in files] @@ -237,30 +232,18 @@ def _resolve_annotation_classes( local_classes_not_in_team: Set[dt.AnnotationClass] = set() for local_cls in local_annotation_classes: - local_annotation_type = ( - local_cls.annotation_internal_type or local_cls.annotation_type - ) + local_annotation_type = local_cls.annotation_internal_type or local_cls.annotation_type # Only add the new class if it doesn't exist remotely already - if ( - local_annotation_type in classes_in_dataset - and local_cls.name in classes_in_dataset[local_annotation_type] - ): + if local_annotation_type in classes_in_dataset and local_cls.name in classes_in_dataset[local_annotation_type]: continue # Only add the new class if it's not included in the list of the missing classes already - if local_cls.name in [ - missing_class.name for missing_class in local_classes_not_in_dataset - ]: + if local_cls.name in [missing_class.name for missing_class in local_classes_not_in_dataset]: continue - if local_cls.name in [ - missing_class.name for missing_class in local_classes_not_in_team - ]: + if local_cls.name in [missing_class.name for missing_class in local_classes_not_in_team]: continue - if ( - local_annotation_type in classes_in_team - and local_cls.name in classes_in_team[local_annotation_type] - ): + if local_annotation_type in classes_in_team and local_cls.name in classes_in_team[local_annotation_type]: local_classes_not_in_dataset.add(local_cls) else: local_classes_not_in_team.add(local_cls) @@ -339,18 +322,14 @@ def import_annotations( # noqa: C901 "The options 'append' and 'delete_for_empty' cannot be used together. Use only one of them." ) - cpu_limit, use_multi_cpu = _get_multi_cpu_settings( - cpu_limit, cpu_count(), use_multi_cpu - ) + cpu_limit, use_multi_cpu = _get_multi_cpu_settings(cpu_limit, cpu_count(), use_multi_cpu) if use_multi_cpu: console.print(f"Using {cpu_limit} CPUs for parsing...", style="info") else: console.print("Using 1 CPU for parsing...", style="info") if not isinstance(file_paths, list): - raise ValueError( - f"file_paths must be a list of 'Path' or 'str'. Current value: {file_paths}" - ) + raise ValueError(f"file_paths must be a list of 'Path' or 'str'. Current value: {file_paths}") console.print("Fetching remote class list...", style="info") team_classes: List[dt.DictFreeForm] = dataset.fetch_remote_classes(True) @@ -364,18 +343,10 @@ def import_annotations( # noqa: C901 ) classes_in_dataset: dt.DictFreeForm = build_main_annotations_lookup_table( - [ - cls - for cls in team_classes - if cls["available"] or cls["name"] in GLOBAL_CLASSES - ] + [cls for cls in team_classes if cls["available"] or cls["name"] in GLOBAL_CLASSES] ) classes_in_team: dt.DictFreeForm = build_main_annotations_lookup_table( - [ - cls - for cls in team_classes - if not cls["available"] and cls["name"] not in GLOBAL_CLASSES - ] + [cls for cls in team_classes if not cls["available"] and cls["name"] not in GLOBAL_CLASSES] ) attributes = build_attribute_lookup(dataset) @@ -393,9 +364,7 @@ def import_annotations( # noqa: C901 parsed_files: List[AnnotationFile] = flatten_list(list(maybe_parsed_files)) - filenames: List[str] = [ - parsed_file.filename for parsed_file in parsed_files if parsed_file is not None - ] + filenames: List[str] = [parsed_file.filename for parsed_file in parsed_files if parsed_file is not None] console.print("Fetching remote file list...", style="info") # This call will only filter by filename; so can return a superset of matched files across different paths @@ -430,9 +399,7 @@ def import_annotations( # noqa: C901 style="warning", ) for local_file in local_files_missing_remotely: - console.print( - f"\t{local_file.path}: '{local_file.full_path}'", style="warning" - ) + console.print(f"\t{local_file.path}: '{local_file.full_path}'", style="warning") if class_prompt and not secure_continue_request(): return @@ -441,26 +408,18 @@ def import_annotations( # noqa: C901 local_classes_not_in_dataset, local_classes_not_in_team, ) = _resolve_annotation_classes( - [ - annotation_class - for file in local_files - for annotation_class in file.annotation_classes - ], + [annotation_class for file in local_files for annotation_class in file.annotation_classes], classes_in_dataset, classes_in_team, ) - console.print( - f"{len(local_classes_not_in_team)} classes needs to be created.", style="info" - ) + console.print(f"{len(local_classes_not_in_team)} classes needs to be created.", style="info") console.print( f"{len(local_classes_not_in_dataset)} classes needs to be added to {dataset.identifier}", style="info", ) - missing_skeletons: List[dt.AnnotationClass] = list( - filter(_is_skeleton_class, local_classes_not_in_team) - ) + missing_skeletons: List[dt.AnnotationClass] = list(filter(_is_skeleton_class, local_classes_not_in_team)) missing_skeleton_names: str = ", ".join(map(_get_skeleton_name, missing_skeletons)) if missing_skeletons: console.print( @@ -484,9 +443,7 @@ def import_annotations( # noqa: C901 missing_class.annotation_internal_type or missing_class.annotation_type, ) if local_classes_not_in_dataset: - console.print( - f"About to add the following classes to {dataset.identifier}", style="info" - ) + console.print(f"About to add the following classes to {dataset.identifier}", style="info") for cls in local_classes_not_in_dataset: dataset.add_annotation_class(cls) @@ -501,9 +458,7 @@ def import_annotations( # noqa: C901 remote_classes = build_main_annotations_lookup_table(team_classes) if dataset.version == 1: - console.print( - "Importing annotations...\nEmpty annotations will be skipped.", style="info" - ) + console.print("Importing annotations...\nEmpty annotations will be skipped.", style="info") elif dataset.version == 2 and delete_for_empty: console.print( "Importing annotations...\nEmpty annotation file(s) will clear all existing annotations in matching remote files.", @@ -517,9 +472,7 @@ def import_annotations( # noqa: C901 # Need to re parse the files since we didn't save the annotations in memory for local_path in set(local_file.path for local_file in local_files): # noqa: C401 - imported_files: Union[ - List[dt.AnnotationFile], dt.AnnotationFile, None - ] = importer(local_path) + imported_files: Union[List[dt.AnnotationFile], dt.AnnotationFile, None] = importer(local_path) if imported_files is None: parsed_files = [] elif not isinstance(imported_files, List): @@ -528,20 +481,13 @@ def import_annotations( # noqa: C901 parsed_files = imported_files # remove files missing on the server - missing_files = [ - missing_file.full_path for missing_file in local_files_missing_remotely - ] - parsed_files = [ - parsed_file - for parsed_file in parsed_files - if parsed_file.full_path not in missing_files - ] + missing_files = [missing_file.full_path for missing_file in local_files_missing_remotely] + parsed_files = [parsed_file for parsed_file in parsed_files if parsed_file.full_path not in missing_files] files_to_not_track = [ file_to_track for file_to_track in parsed_files - if not file_to_track.annotations - and (not delete_for_empty or dataset.version == 1) + if not file_to_track.annotations and (not delete_for_empty or dataset.version == 1) ] for file in files_to_not_track: @@ -550,9 +496,7 @@ def import_annotations( # noqa: C901 style="warning", ) - files_to_track = [ - file for file in parsed_files if file not in files_to_not_track - ] + files_to_track = [file for file in parsed_files if file not in files_to_not_track] if files_to_track: _warn_unsupported_annotations(files_to_track) for parsed_file in track(files_to_track): @@ -577,16 +521,12 @@ def import_annotations( # noqa: C901 ) if errors: - console.print( - f"Errors importing {parsed_file.filename}", style="error" - ) + console.print(f"Errors importing {parsed_file.filename}", style="error") for error in errors: console.print(f"\t{error}", style="error") -def _get_multi_cpu_settings( - cpu_limit: Optional[int], cpu_count: int, use_multi_cpu: bool -) -> Tuple[int, bool]: +def _get_multi_cpu_settings(cpu_limit: Optional[int], cpu_count: int, use_multi_cpu: bool) -> Tuple[int, bool]: if cpu_limit == 1 or cpu_count == 1 or not use_multi_cpu: return 1, False @@ -604,9 +544,7 @@ def _warn_unsupported_annotations(parsed_files: List[AnnotationFile]) -> None: if annotation.annotation_class.annotation_type in UNSUPPORTED_CLASSES: skipped_annotations.append(annotation) if len(skipped_annotations) > 0: - types = { - c.annotation_class.annotation_type for c in skipped_annotations - } # noqa: C417 + types = {c.annotation_class.annotation_type for c in skipped_annotations} # noqa: C417 console.print( f"Import of annotation class types '{', '.join(types)}' is not yet supported. Skipping {len(skipped_annotations)} " + "annotations from '{parsed_file.full_path}'.\n", @@ -615,9 +553,7 @@ def _warn_unsupported_annotations(parsed_files: List[AnnotationFile]) -> None: def _is_skeleton_class(the_class: dt.AnnotationClass) -> bool: - return ( - the_class.annotation_internal_type or the_class.annotation_type - ) == "skeleton" + return (the_class.annotation_internal_type or the_class.annotation_type) == "skeleton" def _get_skeleton_name(skeleton: dt.AnnotationClass) -> str: @@ -636,15 +572,10 @@ def _handle_subs( elif sub.annotation_type == "attributes": attributes_with_key = [] for attr in sub.data: - if ( - annotation_class_id in attributes - and attr in attributes[annotation_class_id] - ): + if annotation_class_id in attributes and attr in attributes[annotation_class_id]: attributes_with_key.append(attributes[annotation_class_id][attr]) else: - print( - f"The attribute '{attr}' for class '{annotation.annotation_class.name}' was not imported." - ) + print(f"The attribute '{attr}' for class '{annotation.annotation_class.name}' was not imported.") data["attributes"] = {"attributes": attributes_with_key} elif sub.annotation_type == "instance_id": @@ -662,9 +593,7 @@ def _to_complex_polygon(paths: List[str]) -> Dict[str, Any]: } -def _handle_polygon( - annotation: dt.Annotation, data: dt.DictFreeForm -) -> dt.DictFreeForm: +def _handle_polygon(annotation: dt.Annotation, data: dt.DictFreeForm) -> dt.DictFreeForm: polygon = data.get("polygon") if polygon is not None: @@ -677,17 +606,11 @@ def _handle_polygon( return data -def _handle_complex_polygon( - annotation: dt.Annotation, data: dt.DictFreeForm -) -> dt.DictFreeForm: +def _handle_complex_polygon(annotation: dt.Annotation, data: dt.DictFreeForm) -> dt.DictFreeForm: if "complex_polygon" in data: del data["complex_polygon"] data["polygon"] = _to_complex_polygon(annotation.data["paths"]) - elif ( - "polygon" in data - and "paths" in data["polygon"] - and len(data["polygon"]["paths"]) > 1 - ): + elif "polygon" in data and "paths" in data["polygon"] and len(data["polygon"]["paths"]) > 1: data["polygon"] = _to_complex_polygon(annotation.data["paths"]) return data @@ -698,32 +621,23 @@ def _annotators_or_reviewers_to_payload( return [{"email": actor.email, "role": role.value} for actor in actors] -def _handle_reviewers( - annotation: dt.Annotation, import_reviewers: bool -) -> List[dt.DictFreeForm]: +def _handle_reviewers(annotation: dt.Annotation, import_reviewers: bool) -> List[dt.DictFreeForm]: if import_reviewers: if annotation.reviewers: - return _annotators_or_reviewers_to_payload( - annotation.reviewers, dt.AnnotationAuthorRole.REVIEWER - ) + return _annotators_or_reviewers_to_payload(annotation.reviewers, dt.AnnotationAuthorRole.REVIEWER) return [] -def _handle_annotators( - annotation: dt.Annotation, import_annotators: bool -) -> List[dt.DictFreeForm]: +def _handle_annotators(annotation: dt.Annotation, import_annotators: bool) -> List[dt.DictFreeForm]: if import_annotators: if annotation.annotators: - return _annotators_or_reviewers_to_payload( - annotation.annotators, dt.AnnotationAuthorRole.ANNOTATOR - ) + return _annotators_or_reviewers_to_payload(annotation.annotators, dt.AnnotationAuthorRole.ANNOTATOR) return [] def _get_annotation_data( annotation: dt.AnnotationLike, annotation_class_id: str, attributes: dt.DictFreeForm ) -> dt.DictFreeForm: - # Todo Convert to import Darwin format annotation_class = annotation.annotation_class if isinstance(annotation, dt.VideoAnnotation): data = annotation.get_data( @@ -742,9 +656,7 @@ def _get_annotation_data( return data -def _handle_slot_names( - annotation: dt.Annotation, dataset_version: int, default_slot_name: str -) -> dt.Annotation: +def _handle_slot_names(annotation: dt.Annotation, dataset_version: int, default_slot_name: str) -> dt.Annotation: if not annotation.slot_names and dataset_version > 1: annotation.slot_names.extend([default_slot_name]) @@ -774,28 +686,18 @@ def _import_annotations( serialized_annotations = [] for annotation in annotations: annotation_class = annotation.annotation_class - annotation_type = ( - annotation_class.annotation_internal_type - or annotation_class.annotation_type - ) + annotation_type = annotation_class.annotation_internal_type or annotation_class.annotation_type - if ( - annotation_type not in remote_classes - or annotation_class.name not in remote_classes[annotation_type] - ): + if annotation_type not in remote_classes or annotation_class.name not in remote_classes[annotation_type]: if annotation_type not in remote_classes: logger.warning( f"Annotation type '{annotation_type}' is not in the remote classes, skipping import of annotation '{annotation_class.name}'" ) else: - logger.warning( - f"Annotation '{annotation_class.name}' is not in the remote classes, skipping import" - ) + logger.warning(f"Annotation '{annotation_class.name}' is not in the remote classes, skipping import") continue - annotation_class_id: str = remote_classes[annotation_type][ - annotation_class.name - ] + annotation_class_id: str = remote_classes[annotation_type][annotation_class.name] data = _get_annotation_data(annotation, annotation_class_id, attributes) From c942a817914da1df8bdea89d9f2161ce9a8aa20b Mon Sep 17 00:00:00 2001 From: Christoffer Date: Mon, 27 Nov 2023 11:15:00 +0100 Subject: [PATCH 54/71] added PolygonPath and PolygonPaths definitions --- darwin/datatypes.py | 26 ++++++++------------------ darwin/exporter/formats/darwin.py | 13 ++++--------- 2 files changed, 12 insertions(+), 27 deletions(-) diff --git a/darwin/datatypes.py b/darwin/datatypes.py index b0a28d1c8..50d2081c6 100644 --- a/darwin/datatypes.py +++ b/darwin/datatypes.py @@ -24,9 +24,7 @@ # Utility types -NumberLike = Union[ - int, float -] # Used for functions that can take either an int or a float +NumberLike = Union[int, float] # Used for functions that can take either an int or a float # Used for functions that _genuinely_ don't know what type they're dealing with, such as those that test if something is of a certain type. UnknownType = Any # type:ignore @@ -50,6 +48,8 @@ class Success(Enum): EllipseData = Dict[str, Union[float, Point]] CuboidData = Dict[str, Dict[str, float]] Segment = List[int] +PolygonPath = List[Dict[str, float]] +PolygonPaths = List[Path] DarwinVersionNumber = Tuple[int, int, int] @@ -270,9 +270,7 @@ class VideoAnnotation: def get_data( self, only_keyframes: bool = True, - post_processing: Optional[ - Callable[[Annotation, UnknownType], UnknownType] - ] = None, + post_processing: Optional[Callable[[Annotation, UnknownType], UnknownType]] = None, ) -> Dict: """ Return the post-processed frames and the additional information from this @@ -306,9 +304,7 @@ def get_data( """ if not post_processing: - def post_processing( - annotation: Annotation, data: UnknownType - ) -> UnknownType: + def post_processing(annotation: Annotation, data: UnknownType) -> UnknownType: return data # type: ignore output = { @@ -526,9 +522,7 @@ def make_tag( Annotation A tag ``Annotation``. """ - return Annotation( - AnnotationClass(class_name, "tag"), {}, subs or [], slot_names=slot_names or [] - ) + return Annotation(AnnotationClass(class_name, "tag"), {}, subs or [], slot_names=slot_names or []) def make_polygon( @@ -1026,9 +1020,7 @@ def make_mask( Annotation A mask ``Annotation``. """ - return Annotation( - AnnotationClass(class_name, "mask"), {}, subs or [], slot_names=slot_names or [] - ) + return Annotation(AnnotationClass(class_name, "mask"), {}, subs or [], slot_names=slot_names or []) def make_raster_layer( @@ -1226,9 +1218,7 @@ def make_video_annotation( ) -def _maybe_add_bounding_box_data( - data: Dict[str, UnknownType], bounding_box: Optional[Dict] -) -> Dict[str, UnknownType]: +def _maybe_add_bounding_box_data(data: Dict[str, UnknownType], bounding_box: Optional[Dict]) -> Dict[str, UnknownType]: if bounding_box: data["bounding_box"] = { "x": bounding_box["x"], diff --git a/darwin/exporter/formats/darwin.py b/darwin/exporter/formats/darwin.py index b47da82d5..c043fe513 100644 --- a/darwin/exporter/formats/darwin.py +++ b/darwin/exporter/formats/darwin.py @@ -3,6 +3,7 @@ import deprecation import darwin.datatypes as dt +from darwin.datatypes import PolygonPath, PolygonPaths from darwin.version import __version__ DEPRECATION_MESSAGE = """ @@ -71,9 +72,7 @@ def _build_bounding_box_data(data: Dict[str, Any]) -> Dict[str, Any]: } -def _build_polygon_data( - data: Dict[str, Any] -) -> Dict[str, List[List[Dict[str, float]]]]: +def _build_polygon_data(data: Dict[str, Any]) -> Dict[str, PolygonPaths]: """ Builds the polygon data for Darwin v2 format. @@ -165,9 +164,7 @@ def build_annotation_data(annotation: dt.Annotation) -> Dict[str, Any]: return {"path": annotation.data["paths"]} if annotation.annotation_class.annotation_type == "polygon": - return dict( - filter(lambda item: item[0] != "bounding_box", annotation.data.items()) - ) + return dict(filter(lambda item: item[0] != "bounding_box", annotation.data.items())) return dict(annotation.data) @@ -177,8 +174,6 @@ def _build_annotation_data(annotation: dt.Annotation) -> Dict[str, Any]: return {"path": annotation.data["paths"]} if annotation.annotation_class.annotation_type == "polygon": - return dict( - filter(lambda item: item[0] != "bounding_box", annotation.data.items()) - ) + return dict(filter(lambda item: item[0] != "bounding_box", annotation.data.items())) return dict(annotation.data) From d75dcdd54187a3346732309b1f2ccbfa9b443342 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Mon, 27 Nov 2023 14:36:19 +0100 Subject: [PATCH 55/71] merge --- .../formats/export_darwin_1_0_test.py | 85 +++++-------------- 1 file changed, 19 insertions(+), 66 deletions(-) diff --git a/tests/darwin/exporter/formats/export_darwin_1_0_test.py b/tests/darwin/exporter/formats/export_darwin_1_0_test.py index 4bca09c28..b3c449f04 100644 --- a/tests/darwin/exporter/formats/export_darwin_1_0_test.py +++ b/tests/darwin/exporter/formats/export_darwin_1_0_test.py @@ -1,13 +1,14 @@ from pathlib import Path - import darwin.datatypes as dt from darwin.exporter.formats.darwin_1_0 import _build_json class TestBuildJson: def test_empty_annotation_file(self): - annotation_file = dt.AnnotationFile(path=Path("test.json"), filename="test.json", annotation_classes=[], annotations=[]) + annotation_file = dt.AnnotationFile( + path=Path("test.json"), filename="test.json", annotation_classes=[], annotations=[] + ) assert _build_json(annotation_file) == { "image": { @@ -137,7 +138,9 @@ def test_polygon_annotation_file_with_bbox(self): bounding_box = {"x": 557.66, "y": 428.98, "w": 160.76, "h": 315.3} annotation_class = dt.AnnotationClass(name="test", annotation_type="polygon") - annotation = dt.Annotation(annotation_class=annotation_class, data={"path": polygon_path, "bounding_box": bounding_box}, subs=[]) + annotation = dt.Annotation( + annotation_class=annotation_class, data={"path": polygon_path, "bounding_box": bounding_box}, subs=[] + ) annotation_file = dt.AnnotationFile( path=Path("test.json"), @@ -161,7 +164,9 @@ def test_polygon_annotation_file_with_bbox(self): "path": None, "workview_url": None, }, - "annotations": [{"polygon": {"path": polygon_path}, "name": "test", "slot_names": [], "bounding_box": bounding_box}], + "annotations": [ + {"polygon": {"path": polygon_path}, "name": "test", "slot_names": [], "bounding_box": bounding_box} + ], "dataset": "None", } @@ -180,39 +185,10 @@ def test_complex_polygon_with_bbox(self): bounding_box = {"x": 557.66, "y": 428.98, "w": 160.76, "h": 315.3} annotation_class = dt.AnnotationClass(name="test", annotation_type="complex_polygon") - annotation = dt.Annotation(annotation_class=annotation_class, data={"paths": polygon_path, "bounding_box": bounding_box}, subs=[]) - - annotation_file = dt.AnnotationFile( - path=Path("test.json"), - filename="test.json", - annotation_classes=[annotation_class], - annotations=[annotation], - image_height=1080, - image_width=1920, - image_url="https://darwin.v7labs.com/image.jpg", + annotation = dt.Annotation( + annotation_class=annotation_class, data={"paths": polygon_path, "bounding_box": bounding_box}, subs=[] ) - assert _build_json(annotation_file) == { - "image": { - "seq": None, - "width": 1920, - "height": 1080, - "filename": "test.json", - "original_filename": "test.json", - "url": "https://darwin.v7labs.com/image.jpg", - "thumbnail_url": None, - "path": None, - "workview_url": None, - }, - "annotations": [{"complex_polygon": {"path": polygon_path}, "name": "test", "slot_names": [], "bounding_box": bounding_box}], - "dataset": "None", - } - - def test_bounding_box(self): - bounding_box_data = {"x": 100, "y": 150, "w": 50, "h": 30} - annotation_class = dt.AnnotationClass(name="bbox_test", annotation_type="bounding_box") - annotation = dt.Annotation(annotation_class=annotation_class, data=bounding_box_data, subs=[]) - annotation_file = dt.AnnotationFile( path=Path("test.json"), filename="test.json", @@ -235,36 +211,13 @@ def test_bounding_box(self): "path": None, "workview_url": None, }, - "annotations": [{"bounding_box": bounding_box_data, "name": "bbox_test", "slot_names": []}], - "dataset": "None", - } - - def test_tags(self): - tag_data = "sample_tag" - annotation_class = dt.AnnotationClass(name="tag_test", annotation_type="tag") - annotation = dt.Annotation(annotation_class=annotation_class, data=tag_data, subs=[]) - - annotation_file = dt.AnnotationFile( - path=Path("test.json"), - filename="test.json", - annotation_classes=[annotation_class], - annotations=[annotation], - image_height=1080, - image_width=1920, - image_url="https://darwin.v7labs.com/image.jpg", - ) - assert _build_json(annotation_file) == { - "image": { - "seq": None, - "width": 1920, - "height": 1080, - "filename": "test.json", - "original_filename": "test.json", - "url": "https://darwin.v7labs.com/image.jpg", - "thumbnail_url": None, - "path": None, - "workview_url": None, - }, - "annotations": [{"tag": {}, "name": "tag_test", "slot_names": []}], + "annotations": [ + { + "complex_polygon": {"path": polygon_path}, + "name": "test", + "slot_names": [], + "bounding_box": bounding_box, + } + ], "dataset": "None", } From 3bac7e176444a6eddf46d0152e969b6b57110ca6 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Mon, 16 Oct 2023 18:01:11 +0200 Subject: [PATCH 56/71] extended convertion tests --- .../formats/export_darwin_1_0_test.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/darwin/exporter/formats/export_darwin_1_0_test.py b/tests/darwin/exporter/formats/export_darwin_1_0_test.py index b3c449f04..30a2f2f30 100644 --- a/tests/darwin/exporter/formats/export_darwin_1_0_test.py +++ b/tests/darwin/exporter/formats/export_darwin_1_0_test.py @@ -221,3 +221,64 @@ def test_complex_polygon_with_bbox(self): ], "dataset": "None", } + + def test_bounding_box(self): + bounding_box_data = {"x": 100, "y": 150, "w": 50, "h": 30} + annotation_class = dt.AnnotationClass(name="bbox_test", annotation_type="bounding_box") + annotation = dt.Annotation(annotation_class=annotation_class, data=bounding_box_data, subs=[]) + + annotation_file = dt.AnnotationFile( + path=Path("test.json"), + filename="test.json", + annotation_classes=[annotation_class], + annotations=[annotation], + image_height=1080, + image_width=1920, + image_url="https://darwin.v7labs.com/image.jpg", + ) + + assert _build_json(annotation_file) == { + "image": { + "seq": None, + "width": 1920, + "height": 1080, + "filename": "test.json", + "original_filename": "test.json", + "url": "https://darwin.v7labs.com/image.jpg", + "thumbnail_url": None, + "path": None, + "workview_url": None, + }, + "annotations": [{"bounding_box": bounding_box_data, "name": "bbox_test", "slot_names": []}], + "dataset": "None", + } + + def test_tags(self): + tag_data = "sample_tag" + annotation_class = dt.AnnotationClass(name="tag_test", annotation_type="tag") + annotation = dt.Annotation(annotation_class=annotation_class, data=tag_data, subs=[]) + + annotation_file = dt.AnnotationFile( + path=Path("test.json"), + filename="test.json", + annotation_classes=[annotation_class], + annotations=[annotation], + image_height=1080, + image_width=1920, + image_url="https://darwin.v7labs.com/image.jpg", + ) + assert _build_json(annotation_file) == { + "image": { + "seq": None, + "width": 1920, + "height": 1080, + "filename": "test.json", + "original_filename": "test.json", + "url": "https://darwin.v7labs.com/image.jpg", + "thumbnail_url": None, + "path": None, + "workview_url": None, + }, + "annotations": [{"tag": {}, "name": "tag_test", "slot_names": []}], + "dataset": "None", + } From 05ed7c88908c20a8079ec7293e88e35dc8c54ca2 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Mon, 27 Nov 2023 14:45:27 +0100 Subject: [PATCH 57/71] local changes --- darwin/utils/utils.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/darwin/utils/utils.py b/darwin/utils/utils.py index 575c50843..03a0a1001 100644 --- a/darwin/utils/utils.py +++ b/darwin/utils/utils.py @@ -615,6 +615,7 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati main_annotation = dt.make_polygon(name, paths[0], bounding_box, slot_names=slot_names) # Darwin JSON 1.0 representation of complex and simple polygons elif "polygon" in annotation: + print(f"Polygon {annotation}") bounding_box = annotation.get("bounding_box") if "additional_paths" in annotation["polygon"]: paths = [annotation["polygon"]["path"]] + annotation["polygon"]["additional_paths"] @@ -667,7 +668,11 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati elif "raster_layer" in annotation: raster_layer = annotation["raster_layer"] main_annotation = dt.make_raster_layer( - name, raster_layer["mask_annotation_ids_mapping"], raster_layer["total_pixels"], raster_layer["dense_rle"], slot_names=slot_names + name, + raster_layer["mask_annotation_ids_mapping"], + raster_layer["total_pixels"], + raster_layer["dense_rle"], + slot_names=slot_names, ) if not main_annotation: @@ -923,8 +928,8 @@ def convert_polygons_to_sequences( path: List[Union[int, float]] = [] for point in polygon: # Clip coordinates to the image size - x = max(min(point["x"], width -1) if width else point["x"], 0) - y = max(min(point["y"], height -1) if height else point["y"], 0) + x = max(min(point["x"], width - 1) if width else point["x"], 0) + y = max(min(point["y"], height - 1) if height else point["y"], 0) if rounding: path.append(round(x)) path.append(round(y)) @@ -1098,7 +1103,7 @@ def chunk(items: List[Any], size: int) -> Iterator[Any]: A chunk of the of the given size. """ for i in range(0, len(items), size): - yield items[i:i + size] + yield items[i : i + size] def is_unix_like_os() -> bool: From 41dd767404b4ea2e3874af2dfce87d076cb9618e Mon Sep 17 00:00:00 2001 From: Christoffer Date: Mon, 27 Nov 2023 15:36:21 +0100 Subject: [PATCH 58/71] reverted changes to internal darwin format, convertion to v2 only done when writing to file now --- darwin/dataset/utils.py | 79 ++---- darwin/datatypes.py | 14 +- darwin/exporter/formats/coco.py | 3 - darwin/exporter/formats/darwin.py | 20 +- darwin/importer/importer.py | 73 +----- darwin/utils/utils.py | 228 +++++------------- tests/darwin/datatypes_test.py | 14 +- .../exporter/formats/export_coco_test.py | 10 +- .../importer/formats/import_labelbox_test.py | 80 +++--- .../importer/formats/import_nifti_test.py | 112 ++------- .../formats/import_superannotate_test.py | 127 +++------- 11 files changed, 196 insertions(+), 564 deletions(-) diff --git a/darwin/dataset/utils.py b/darwin/dataset/utils.py index 7aa3fa1bc..eec81709d 100644 --- a/darwin/dataset/utils.py +++ b/darwin/dataset/utils.py @@ -102,10 +102,7 @@ def extract_classes( continue for annotation in annotation_file.annotations: - if ( - annotation.annotation_class.annotation_type - not in annotation_types_to_load - ): + if annotation.annotation_class.annotation_type not in annotation_types_to_load: continue class_name = annotation.annotation_class.name @@ -194,11 +191,7 @@ def get_classes( classes_file_path = release_path / f"lists/classes_{atype}.txt" class_per_annotations = get_classes_from_file(classes_file_path) - if ( - remove_background - and class_per_annotations - and class_per_annotations[0] == "__background__" - ): + if remove_background and class_per_annotations and class_per_annotations[0] == "__background__": class_per_annotations = class_per_annotations[1:] for cls in class_per_annotations: @@ -321,9 +314,7 @@ def get_coco_format_record( objs = [] for obj in data.annotations: if annotation_type != obj.annotation_class.annotation_type: - if ( - annotation_type not in obj.data - ): # Allows training object detection with bboxes + if annotation_type not in obj.data: # Allows training object detection with bboxes continue if annotation_type == "polygon": @@ -366,9 +357,7 @@ def create_polygon_object(obj, box_mode, classes=None): "segmentation": segmentation, "bbox": [np.min(all_px), np.min(all_py), np.max(all_px), np.max(all_py)], "bbox_mode": box_mode, - "category_id": classes.index(obj.annotation_class.name) - if classes - else obj.annotation_class.name, + "category_id": classes.index(obj.annotation_class.name) if classes else obj.annotation_class.name, "iscrowd": 0, } @@ -380,9 +369,7 @@ def create_bbox_object(obj, box_mode, classes=None): new_obj = { "bbox": [bbox["x"], bbox["y"], bbox["x"] + bbox["w"], bbox["y"] + bbox["h"]], "bbox_mode": box_mode, - "category_id": classes.index(obj.annotation_class.name) - if classes - else obj.annotation_class.name, + "category_id": classes.index(obj.annotation_class.name) if classes else obj.annotation_class.name, "iscrowd": 0, } @@ -459,9 +446,7 @@ def get_annotations( ) if partition: - stems = _get_stems_from_split( - release_path, split, split_type, annotation_type, partition - ) + stems = _get_stems_from_split(release_path, split, split_type, annotation_type, partition) else: stems = (e.stem for e in annotations_dir.glob("**/*.json")) @@ -469,19 +454,14 @@ def get_annotations( images_paths, annotations_paths, invalid_annotation_paths, - ) = _map_annotations_to_images( - stems, annotations_dir, images_dir, ignore_inconsistent_examples - ) + ) = _map_annotations_to_images(stems, annotations_dir, images_dir, ignore_inconsistent_examples) print(f"Found {len(invalid_annotation_paths)} invalid annotations") for p in invalid_annotation_paths: print(p) if len(images_paths) == 0: - raise ValueError( - f"Could not find any {SUPPORTED_EXTENSIONS} file" - f" in {dataset_path / 'images'}" - ) + raise ValueError(f"Could not find any {SUPPORTED_EXTENSIONS} file" f" in {dataset_path / 'images'}") assert len(images_paths) == len(annotations_paths) @@ -507,9 +487,7 @@ def _validate_inputs(partition, split_type, annotation_type): if split_type not in ["random", "stratified", None]: raise ValueError("split_type should be either 'random', 'stratified', or None") if annotation_type not in ["tag", "polygon", "bounding_box"]: - raise ValueError( - "annotation_type should be either 'tag', 'bounding_box', or 'polygon'" - ) + raise ValueError("annotation_type should be either 'tag', 'bounding_box', or 'polygon'") def _get_stems_from_split(release_path, split, split_type, annotation_type, partition): @@ -550,9 +528,7 @@ def _get_stems_from_split(release_path, split, split_type, annotation_type, part ) -def _map_annotations_to_images( - stems, annotations_dir, images_dir, ignore_inconsistent_examples -): +def _map_annotations_to_images(stems, annotations_dir, images_dir, ignore_inconsistent_examples): """ Maps annotations to their corresponding images based on the file stems. @@ -583,16 +559,12 @@ def _map_annotations_to_images( invalid_annotation_paths.append(annotation_path) continue else: - raise ValueError( - f"Annotation ({annotation_path}) does not have a corresponding image" - ) + raise ValueError(f"Annotation ({annotation_path}) does not have a corresponding image") return images_paths, annotations_paths, invalid_annotation_paths -def _load_and_format_annotations( - images_paths, annotations_paths, annotation_format, annotation_type, classes -): +def _load_and_format_annotations(images_paths, annotations_paths, annotation_format, annotation_type, classes): """ Loads and formats annotations based on the specified format and type. @@ -611,13 +583,9 @@ def _load_and_format_annotations( """ if annotation_format == "coco": images_ids = list(range(len(images_paths))) - for annotation_path, image_path, image_id in zip( - annotations_paths, images_paths, images_ids - ): + for annotation_path, image_path, image_id in zip(annotations_paths, images_paths, images_ids): if image_path.suffix.lower() in SUPPORTED_VIDEO_EXTENSIONS: - print( - f"[WARNING] Cannot load video annotation into COCO format. Skipping {image_path}" - ) + print(f"[WARNING] Cannot load video annotation into COCO format. Skipping {image_path}") continue yield get_coco_format_record( annotation_path=annotation_path, @@ -755,34 +723,25 @@ def compute_distributions( - instance_distribution: count of all instances of a given class exist for each partition """ - class_distribution: AnnotationDistribution = { - partition: Counter() for partition in partitions - } - instance_distribution: AnnotationDistribution = { - partition: Counter() for partition in partitions - } + class_distribution: AnnotationDistribution = {partition: Counter() for partition in partitions} + instance_distribution: AnnotationDistribution = {partition: Counter() for partition in partitions} for partition in partitions: for annotation_type in annotation_types: - split_file: Path = ( - split_path / f"stratified_{annotation_type}_{partition}.txt" - ) + split_file: Path = split_path / f"stratified_{annotation_type}_{partition}.txt" if not split_file.exists(): split_file = split_path / f"random_{partition}.txt" stems: List[str] = [e.rstrip("\n\r") for e in split_file.open()] for stem in stems: annotation_path: Path = annotations_dir / f"{stem}.json" - annotation_file: Optional[dt.AnnotationFile] = parse_path( - annotation_path - ) + annotation_file: Optional[dt.AnnotationFile] = parse_path(annotation_path) if annotation_file is None: continue annotation_class_names: List[str] = [ - annotation.annotation_class.name - for annotation in annotation_file.annotations + annotation.annotation_class.name for annotation in annotation_file.annotations ] class_distribution[partition] += Counter(set(annotation_class_names)) diff --git a/darwin/datatypes.py b/darwin/datatypes.py index 50d2081c6..cbe108a17 100644 --- a/darwin/datatypes.py +++ b/darwin/datatypes.py @@ -48,8 +48,6 @@ class Success(Enum): EllipseData = Dict[str, Union[float, Point]] CuboidData = Dict[str, Dict[str, float]] Segment = List[int] -PolygonPath = List[Dict[str, float]] -PolygonPaths = List[Path] DarwinVersionNumber = Tuple[int, int, int] @@ -531,7 +529,6 @@ def make_polygon( bounding_box: Optional[Dict] = None, subs: Optional[List[SubAnnotation]] = None, slot_names: Optional[List[str]] = None, - darwin_v1: bool = False, ) -> Annotation: """ Creates and returns a polygon annotation. @@ -560,18 +557,9 @@ def make_polygon( Annotation A polygon ``Annotation``. """ - - if darwin_v1: - polygon_data = {"path": point_path} - else: - # Lets handle darwin V2 datasets - if not isinstance(point_path[0], list): - point_path = [point_path] - polygon_data = {"paths": point_path} - return Annotation( AnnotationClass(class_name, "polygon"), - _maybe_add_bounding_box_data(polygon_data, bounding_box), + _maybe_add_bounding_box_data({"path": point_path}, bounding_box), subs or [], slot_names=slot_names or [], ) diff --git a/darwin/exporter/formats/coco.py b/darwin/exporter/formats/coco.py index ea052d3ba..2f7342f07 100644 --- a/darwin/exporter/formats/coco.py +++ b/darwin/exporter/formats/coco.py @@ -443,7 +443,6 @@ def _build_annotation( categories: Dict[str, int], ) -> Optional[Dict[str, Any]]: annotation_type = annotation.annotation_class.annotation_type - if annotation_type == "polygon": sequences = convert_polygons_to_sequences(annotation.data["path"], rounding=False) x_coords = [s[0::2] for s in sequences] @@ -506,7 +505,6 @@ def _build_annotation( return _build_annotation( annotation_file, annotation_id, - # TODO Investigate if this should be updated to Darwin v2 or not. dt.make_polygon( annotation.annotation_class.name, [ @@ -517,7 +515,6 @@ def _build_annotation( ], None, annotation.subs, - darwin_v1=True, ), categories, ) diff --git a/darwin/exporter/formats/darwin.py b/darwin/exporter/formats/darwin.py index c043fe513..45828acaa 100644 --- a/darwin/exporter/formats/darwin.py +++ b/darwin/exporter/formats/darwin.py @@ -3,7 +3,8 @@ import deprecation import darwin.datatypes as dt -from darwin.datatypes import PolygonPath, PolygonPaths + +# from darwin.datatypes import PolygonPath, PolygonPaths from darwin.version import __version__ DEPRECATION_MESSAGE = """ @@ -50,14 +51,19 @@ def build_image_annotation(annotation_file: dt.AnnotationFile) -> Dict[str, Any] def _build_v2_annotation_data(annotation: dt.Annotation) -> Dict[str, Any]: annotation_data = {"id": annotation.id, "name": annotation.annotation_class.name} + if annotation.annotation_class.annotation_type == "bounding_box": annotation_data["bounding_box"] = _build_bounding_box_data(annotation.data) elif annotation.annotation_class.annotation_type == "tag": annotation_data["tag"] = {} - elif annotation.annotation_class.annotation_type == "polygon": + elif ( + annotation.annotation_class.annotation_type == "polygon" + or annotation.annotation_class.annotation_type == "complex_polygon" + ): polygon_data = _build_polygon_data(annotation.data) annotation_data["polygon"] = polygon_data annotation_data["bounding_box"] = _build_bounding_box_data(annotation.data) + return annotation_data @@ -72,9 +78,9 @@ def _build_bounding_box_data(data: Dict[str, Any]) -> Dict[str, Any]: } -def _build_polygon_data(data: Dict[str, Any]) -> Dict[str, PolygonPaths]: +def _build_polygon_data(data: Dict[str, Any]) -> Dict[str, Any]: """ - Builds the polygon data for Darwin v2 format. + Builds the polygon data for Darwin V2 format from Darwin internal format (looks like V1). Parameters ---------- @@ -87,7 +93,11 @@ def _build_polygon_data(data: Dict[str, Any]) -> Dict[str, PolygonPaths]: The polygon data in the format required for Darwin v2 annotations. """ - return {"paths": data.get("paths", [])} + # Complex polygon + if "paths" in data: + return {"paths": data["paths"]} + else: + return {"paths": [data["path"]]} def _build_item_data(annotation_file: dt.AnnotationFile) -> Dict[str, Any]: diff --git a/darwin/importer/importer.py b/darwin/importer/importer.py index b2adc77c6..997b024c3 100644 --- a/darwin/importer/importer.py +++ b/darwin/importer/importer.py @@ -16,7 +16,6 @@ Union, ) -import darwin.datatypes as dt from darwin.datatypes import AnnotationFile from darwin.item import DatasetItem @@ -389,25 +388,16 @@ def import_annotations( # noqa: C901 else: local_files.append(parsed_file) - console.print( - f"{len(local_files) + len(local_files_missing_remotely)} annotation file(s) found.", - style="info", - ) + console.print(f"{len(local_files) + len(local_files_missing_remotely)} annotation file(s) found.", style="info") if local_files_missing_remotely: - console.print( - f"{len(local_files_missing_remotely)} file(s) are missing from the dataset", - style="warning", - ) + console.print(f"{len(local_files_missing_remotely)} file(s) are missing from the dataset", style="warning") for local_file in local_files_missing_remotely: console.print(f"\t{local_file.path}: '{local_file.full_path}'", style="warning") if class_prompt and not secure_continue_request(): return - ( - local_classes_not_in_dataset, - local_classes_not_in_team, - ) = _resolve_annotation_classes( + local_classes_not_in_dataset, local_classes_not_in_team = _resolve_annotation_classes( [annotation_class for file in local_files for annotation_class in file.annotation_classes], classes_in_dataset, classes_in_team, @@ -415,8 +405,7 @@ def import_annotations( # noqa: C901 console.print(f"{len(local_classes_not_in_team)} classes needs to be created.", style="info") console.print( - f"{len(local_classes_not_in_dataset)} classes needs to be added to {dataset.identifier}", - style="info", + f"{len(local_classes_not_in_dataset)} classes needs to be added to {dataset.identifier}", style="info" ) missing_skeletons: List[dt.AnnotationClass] = list(filter(_is_skeleton_class, local_classes_not_in_team)) @@ -439,8 +428,7 @@ def import_annotations( # noqa: C901 return for missing_class in local_classes_not_in_team: dataset.create_annotation_class( - missing_class.name, - missing_class.annotation_internal_type or missing_class.annotation_type, + missing_class.name, missing_class.annotation_internal_type or missing_class.annotation_type ) if local_classes_not_in_dataset: console.print(f"About to add the following classes to {dataset.identifier}", style="info") @@ -491,10 +479,7 @@ def import_annotations( # noqa: C901 ] for file in files_to_not_track: - console.print( - f"{file.filename} has no annotations. Skipping upload...", - style="warning", - ) + console.print(f"{file.filename} has no annotations. Skipping upload...", style="warning") files_to_track = [file for file in parsed_files if file not in files_to_not_track] if files_to_track: @@ -544,7 +529,7 @@ def _warn_unsupported_annotations(parsed_files: List[AnnotationFile]) -> None: if annotation.annotation_class.annotation_type in UNSUPPORTED_CLASSES: skipped_annotations.append(annotation) if len(skipped_annotations) > 0: - types = {c.annotation_class.annotation_type for c in skipped_annotations} # noqa: C417 + types = set(map(lambda c: c.annotation_class.annotation_type, skipped_annotations)) # noqa: C417 console.print( f"Import of annotation class types '{', '.join(types)}' is not yet supported. Skipping {len(skipped_annotations)} " + "annotations from '{parsed_file.full_path}'.\n", @@ -561,10 +546,7 @@ def _get_skeleton_name(skeleton: dt.AnnotationClass) -> str: def _handle_subs( - annotation: dt.Annotation, - data: dt.DictFreeForm, - annotation_class_id: str, - attributes: Dict[str, dt.UnknownType], + annotation: dt.Annotation, data: dt.DictFreeForm, annotation_class_id: str, attributes: Dict[str, dt.UnknownType] ) -> dt.DictFreeForm: for sub in annotation.subs: if sub.annotation_type == "text": @@ -586,32 +568,10 @@ def _handle_subs( return data -def _to_complex_polygon(paths: List[str]) -> Dict[str, Any]: - return { - "path": paths[0], - "additional_paths": paths[1:], - } - - -def _handle_polygon(annotation: dt.Annotation, data: dt.DictFreeForm) -> dt.DictFreeForm: - polygon = data.get("polygon") - - if polygon is not None: - if "paths" in polygon and len(polygon["paths"]) == 1: - data["polygon"]["path"] = annotation.data["paths"][0] - if "paths" in data: - del data["paths"] - - data = _handle_complex_polygon(annotation, data) - return data - - def _handle_complex_polygon(annotation: dt.Annotation, data: dt.DictFreeForm) -> dt.DictFreeForm: if "complex_polygon" in data: del data["complex_polygon"] - data["polygon"] = _to_complex_polygon(annotation.data["paths"]) - elif "polygon" in data and "paths" in data["polygon"] and len(data["polygon"]["paths"]) > 1: - data["polygon"] = _to_complex_polygon(annotation.data["paths"]) + data["polygon"] = {"path": annotation.data["paths"][0], "additional_paths": annotation.data["paths"][1:]} return data @@ -643,16 +603,14 @@ def _get_annotation_data( data = annotation.get_data( only_keyframes=True, post_processing=lambda annotation, data: _handle_subs( - annotation, - _handle_polygon(annotation, data), - annotation_class_id, - attributes, + annotation, _handle_complex_polygon(annotation, data), annotation_class_id, attributes ), ) else: data = {annotation_class.annotation_type: annotation.data} - data = _handle_polygon(annotation, data) + data = _handle_complex_polygon(annotation, data) data = _handle_subs(annotation, data, annotation_class_id, attributes) + return data @@ -737,10 +695,5 @@ def _import_annotations( # mypy: ignore-errors def _console_theme() -> Theme: return Theme( - { - "success": "bold green", - "warning": "bold yellow", - "error": "bold red", - "info": "bold deep_sky_blue1", - } + {"success": "bold green", "warning": "bold yellow", "error": "bold red", "info": "bold deep_sky_blue1"} ) diff --git a/darwin/utils/utils.py b/darwin/utils/utils.py index b333bb69d..1d2204702 100644 --- a/darwin/utils/utils.py +++ b/darwin/utils/utils.py @@ -1,6 +1,7 @@ """ Contains several unrelated utility functions used across the SDK. """ + import platform import re from pathlib import Path @@ -213,9 +214,7 @@ def is_project_dir(project_path: Path) -> bool: return (project_path / "releases").exists() and (project_path / "images").exists() -def get_progress_bar( - array: List[dt.AnnotationFile], description: Optional[str] = None -) -> Iterable[ProgressType]: +def get_progress_bar(array: List[dt.AnnotationFile], description: Optional[str] = None) -> Iterable[ProgressType]: """ Get a rich a progress bar for the given list of annotation files. @@ -359,9 +358,7 @@ def persist_client_configuration( api_key=team_config.api_key, datasets_dir=team_config.datasets_dir, ) - config.set_global( - api_endpoint=client.url, base_url=client.base_url, default_team=default_team - ) + config.set_global(api_endpoint=client.url, base_url=client.base_url, default_team=default_team) return config @@ -418,9 +415,7 @@ def attempt_decode(path: Path) -> dict: return data except Exception: continue - raise UnrecognizableFileEncoding( - f"Unable to load file {path} with any encodings: {encodings}" - ) + raise UnrecognizableFileEncoding(f"Unable to load file {path} with any encodings: {encodings}") def load_data_from_file(path: Path) -> Tuple[dict, dt.AnnotationFileVersion]: @@ -429,9 +424,7 @@ def load_data_from_file(path: Path) -> Tuple[dict, dt.AnnotationFileVersion]: return data, version -def parse_darwin_json( - path: Path, count: Optional[int] = None -) -> Optional[dt.AnnotationFile]: +def parse_darwin_json(path: Path, count: Optional[int] = None) -> Optional[dt.AnnotationFile]: """ Parses the given JSON file in v7's darwin proprietary format. Works for images, split frame videos (treated as images) and playback videos. @@ -491,9 +484,7 @@ def stream_darwin_json(path: Path) -> PersistentStreamingJSONObject: return json_stream.load(infile, persistent=True) -def get_image_path_from_stream( - darwin_json: PersistentStreamingJSONObject, images_dir: Path -) -> Path: +def get_image_path_from_stream(darwin_json: PersistentStreamingJSONObject, images_dir: Path) -> Path: """ Returns the path to the image file associated with the given darwin json file (V1 or V2). @@ -510,32 +501,17 @@ def get_image_path_from_stream( Path to the image file. """ try: - return ( - images_dir - / (Path(darwin_json["item"]["path"].lstrip("/\\"))) - / Path(darwin_json["item"]["name"]) - ) + return images_dir / (Path(darwin_json["item"]["path"].lstrip("/\\"))) / Path(darwin_json["item"]["name"]) except KeyError: - return ( - images_dir - / (Path(darwin_json["image"]["path"].lstrip("/\\"))) - / Path(darwin_json["image"]["filename"]) - ) + return images_dir / (Path(darwin_json["image"]["path"].lstrip("/\\"))) / Path(darwin_json["image"]["filename"]) def _parse_darwin_v2(path: Path, data: Dict[str, Any]) -> dt.AnnotationFile: item = data["item"] item_source = item.get("source_info", {}) - slots: List[dt.Slot] = list( - filter(None, map(_parse_darwin_slot, item.get("slots", []))) - ) - - annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations( - data - ) - annotation_classes: Set[dt.AnnotationClass] = { - annotation.annotation_class for annotation in annotations - } + slots: List[dt.Slot] = list(filter(None, map(_parse_darwin_slot, item.get("slots", [])))) + annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations(data) + annotation_classes: Set[dt.AnnotationClass] = {annotation.annotation_class for annotation in annotations} if len(slots) == 0: annotation_file = dt.AnnotationFile( @@ -543,9 +519,7 @@ def _parse_darwin_v2(path: Path, data: Dict[str, Any]) -> dt.AnnotationFile: path=path, filename=item["name"], item_id=item.get("source_info", {}).get("item_id", None), - dataset_name=item.get("source_info", {}) - .get("dataset", {}) - .get("name", None), + dataset_name=item.get("source_info", {}).get("dataset", {}).get("name", None), annotation_classes=annotation_classes, annotations=annotations, is_video=False, @@ -566,17 +540,13 @@ def _parse_darwin_v2(path: Path, data: Dict[str, Any]) -> dt.AnnotationFile: path=path, filename=item["name"], item_id=item.get("source_info", {}).get("item_id", None), - dataset_name=item.get("source_info", {}) - .get("dataset", {}) - .get("name", None), + dataset_name=item.get("source_info", {}).get("dataset", {}).get("name", None), annotation_classes=annotation_classes, annotations=annotations, is_video=slot.frame_urls is not None, image_width=slot.width, image_height=slot.height, - image_url=None - if len(slot.source_files or []) == 0 - else slot.source_files[0]["url"], + image_url=None if len(slot.source_files or []) == 0 else slot.source_files[0]["url"], image_thumbnail_url=slot.thumbnail_url, workview_url=item_source.get("workview_url", None), seq=0, @@ -605,15 +575,9 @@ def _parse_darwin_slot(data: Dict[str, Any]) -> dt.Slot: ) -def _parse_darwin_image( - path: Path, data: Dict[str, Any], count: Optional[int] -) -> dt.AnnotationFile: - annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations( - data - ) - annotation_classes: Set[dt.AnnotationClass] = { - annotation.annotation_class for annotation in annotations - } +def _parse_darwin_image(path: Path, data: Dict[str, Any], count: Optional[int]) -> dt.AnnotationFile: + annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations(data) + annotation_classes: Set[dt.AnnotationClass] = {annotation.annotation_class for annotation in annotations} slot = dt.Slot( name=None, @@ -650,20 +614,12 @@ def _parse_darwin_image( return annotation_file -def _parse_darwin_video( - path: Path, data: Dict[str, Any], count: Optional[int] -) -> dt.AnnotationFile: - annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations( - data - ) - annotation_classes: Set[dt.AnnotationClass] = { - annotation.annotation_class for annotation in annotations - } +def _parse_darwin_video(path: Path, data: Dict[str, Any], count: Optional[int]) -> dt.AnnotationFile: + annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations(data) + annotation_classes: Set[dt.AnnotationClass] = {annotation.annotation_class for annotation in annotations} if "width" not in data["image"] or "height" not in data["image"]: - raise OutdatedDarwinJSONFormat( - "Missing width/height in video, please re-export" - ) + raise OutdatedDarwinJSONFormat("Missing width/height in video, please re-export") slot = dt.Slot( name=None, @@ -709,45 +665,23 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati main_annotation: Optional[dt.Annotation] = None # Darwin JSON 2.0 representation of complex polygons - if ( - "polygon" in annotation - and "paths" in annotation["polygon"] - and len(annotation["polygon"]["paths"]) > 1 - ): + if "polygon" in annotation and "paths" in annotation["polygon"] and len(annotation["polygon"]["paths"]) > 1: bounding_box = annotation.get("bounding_box") paths = annotation["polygon"]["paths"] - main_annotation = dt.make_complex_polygon( - name, paths, bounding_box, slot_names=slot_names - ) + main_annotation = dt.make_complex_polygon(name, paths, bounding_box, slot_names=slot_names) # Darwin JSON 2.0 representation of simple polygons - elif ( - "polygon" in annotation - and "paths" in annotation["polygon"] - and len(annotation["polygon"]["paths"]) == 1 - ): + elif "polygon" in annotation and "paths" in annotation["polygon"] and len(annotation["polygon"]["paths"]) == 1: bounding_box = annotation.get("bounding_box") paths = annotation["polygon"]["paths"] - main_annotation = dt.make_polygon( - name, paths[0], bounding_box, slot_names=slot_names - ) + main_annotation = dt.make_polygon(name, paths[0], bounding_box, slot_names=slot_names) # Darwin JSON 1.0 representation of complex and simple polygons elif "polygon" in annotation: bounding_box = annotation.get("bounding_box") if "additional_paths" in annotation["polygon"]: - paths = [annotation["polygon"]["path"]] + annotation["polygon"][ - "additional_paths" - ] - main_annotation = dt.make_complex_polygon( - name, paths, bounding_box, slot_names=slot_names - ) + paths = [annotation["polygon"]["path"]] + annotation["polygon"]["additional_paths"] + main_annotation = dt.make_complex_polygon(name, paths, bounding_box, slot_names=slot_names) else: - main_annotation = dt.make_polygon( - name, - annotation["polygon"]["path"], - bounding_box, - slot_names=slot_names, - darwin_v1=True, - ) + main_annotation = dt.make_polygon(name, annotation["polygon"]["path"], bounding_box, slot_names=slot_names) # Darwin JSON 1.0 representation of complex polygons elif "complex_polygon" in annotation: bounding_box = annotation.get("bounding_box") @@ -759,9 +693,7 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati if "additional_paths" in annotation["complex_polygon"]: paths.extend(annotation["complex_polygon"]["additional_paths"]) - main_annotation = dt.make_complex_polygon( - name, paths, bounding_box, slot_names=slot_names - ) + main_annotation = dt.make_complex_polygon(name, paths, bounding_box, slot_names=slot_names) elif "bounding_box" in annotation: bounding_box = annotation["bounding_box"] main_annotation = dt.make_bounding_box( @@ -775,9 +707,7 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati elif "tag" in annotation: main_annotation = dt.make_tag(name, slot_names=slot_names) elif "line" in annotation: - main_annotation = dt.make_line( - name, annotation["line"]["path"], slot_names=slot_names - ) + main_annotation = dt.make_line(name, annotation["line"]["path"], slot_names=slot_names) elif "keypoint" in annotation: main_annotation = dt.make_keypoint( name, @@ -786,17 +716,11 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati slot_names=slot_names, ) elif "ellipse" in annotation: - main_annotation = dt.make_ellipse( - name, annotation["ellipse"], slot_names=slot_names - ) + main_annotation = dt.make_ellipse(name, annotation["ellipse"], slot_names=slot_names) elif "cuboid" in annotation: - main_annotation = dt.make_cuboid( - name, annotation["cuboid"], slot_names=slot_names - ) + main_annotation = dt.make_cuboid(name, annotation["cuboid"], slot_names=slot_names) elif "skeleton" in annotation: - main_annotation = dt.make_skeleton( - name, annotation["skeleton"]["nodes"], slot_names=slot_names - ) + main_annotation = dt.make_skeleton(name, annotation["skeleton"]["nodes"], slot_names=slot_names) elif "table" in annotation: main_annotation = dt.make_table( name, @@ -805,9 +729,7 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati slot_names=slot_names, ) elif "string" in annotation: - main_annotation = dt.make_string( - name, annotation["string"]["sources"], slot_names=slot_names - ) + main_annotation = dt.make_string(name, annotation["string"]["sources"], slot_names=slot_names) elif "graph" in annotation: main_annotation = dt.make_graph( name, @@ -834,29 +756,19 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati if "id" in annotation: main_annotation.id = annotation["id"] if "instance_id" in annotation: - main_annotation.subs.append( - dt.make_instance_id(annotation["instance_id"]["value"]) - ) + main_annotation.subs.append(dt.make_instance_id(annotation["instance_id"]["value"])) if "attributes" in annotation: main_annotation.subs.append(dt.make_attributes(annotation["attributes"])) if "text" in annotation: main_annotation.subs.append(dt.make_text(annotation["text"]["text"])) if "inference" in annotation: - main_annotation.subs.append( - dt.make_opaque_sub("inference", annotation["inference"]) - ) + main_annotation.subs.append(dt.make_opaque_sub("inference", annotation["inference"])) if "directional_vector" in annotation: - main_annotation.subs.append( - dt.make_opaque_sub("directional_vector", annotation["directional_vector"]) - ) + main_annotation.subs.append(dt.make_opaque_sub("directional_vector", annotation["directional_vector"])) if "measures" in annotation: - main_annotation.subs.append( - dt.make_opaque_sub("measures", annotation["measures"]) - ) + main_annotation.subs.append(dt.make_opaque_sub("measures", annotation["measures"])) if "auto_annotate" in annotation: - main_annotation.subs.append( - dt.make_opaque_sub("auto_annotate", annotation["auto_annotate"]) - ) + main_annotation.subs.append(dt.make_opaque_sub("auto_annotate", annotation["auto_annotate"])) if annotation.get("annotators") is not None: main_annotation.annotators = _parse_annotators(annotation["annotators"]) @@ -910,9 +822,7 @@ def _parse_darwin_raster_annotation(annotation: dict) -> Optional[dt.Annotation] slot_names: Optional[List[str]] = parse_slot_names(annotation) if not id or not name or not raster_layer: - raise ValueError( - "Raster annotation must have an 'id', 'name' and 'raster_layer' field" - ) + raise ValueError("Raster annotation must have an 'id', 'name' and 'raster_layer' field") dense_rle, mask_annotation_ids_mapping, total_pixels = ( raster_layer.get("dense_rle", None), @@ -963,14 +873,9 @@ def _parse_darwin_mask_annotation(annotation: dict) -> Optional[dt.Annotation]: def _parse_annotators(annotators: List[Dict[str, Any]]) -> List[dt.AnnotationAuthor]: if not (hasattr(annotators, "full_name") or not hasattr(annotators, "email")): - raise AttributeError( - "JSON file must contain annotators with 'full_name' and 'email' fields" - ) + raise AttributeError("JSON file must contain annotators with 'full_name' and 'email' fields") - return [ - dt.AnnotationAuthor(annotator["full_name"], annotator["email"]) - for annotator in annotators - ] + return [dt.AnnotationAuthor(annotator["full_name"], annotator["email"]) for annotator in annotators] def split_video_annotation(annotation: dt.AnnotationFile) -> List[dt.AnnotationFile]: @@ -1003,13 +908,9 @@ def split_video_annotation(annotation: dt.AnnotationFile) -> List[dt.AnnotationF frame_annotations = [] for i, frame_url in enumerate(annotation.frame_urls): annotations = [ - a.frames[i] - for a in annotation.annotations - if isinstance(a, dt.VideoAnnotation) and i in a.frames + a.frames[i] for a in annotation.annotations if isinstance(a, dt.VideoAnnotation) and i in a.frames ] - annotation_classes: Set[dt.AnnotationClass] = { - annotation.annotation_class for annotation in annotations - } + annotation_classes: Set[dt.AnnotationClass] = {annotation.annotation_class for annotation in annotations} filename: str = f"{Path(annotation.filename).stem}/{i:07d}.png" frame_annotations.append( dt.AnnotationFile( @@ -1087,16 +988,13 @@ def convert_polygons_to_sequences( raise ValueError("No polygons provided") # If there is a single polygon composing the instance then this is # transformed to polygons = [[{x: x1, y:y1}, ..., {x: xn, y:yn}]] - list_polygons: List[dt.Polygon] = [] if isinstance(polygons[0], list): list_polygons = cast(List[dt.Polygon], polygons) else: list_polygons = cast(List[dt.Polygon], [polygons]) - if not isinstance(list_polygons[0], list) or not isinstance( - list_polygons[0][0], dict - ): + if not isinstance(list_polygons[0], list) or not isinstance(list_polygons[0][0], dict): raise ValueError("Unknown input format") sequences: List[List[Union[int, float]]] = [] @@ -1237,9 +1135,7 @@ def convert_bounding_box_to_xyxy(box: dt.BoundingBox) -> List[float]: return [box["x"], box["y"], x2, y2] -def convert_polygons_to_mask( - polygons: List, height: int, width: int, value: Optional[int] = 1 -) -> np.ndarray: +def convert_polygons_to_mask(polygons: List, height: int, width: int, value: Optional[int] = 1) -> np.ndarray: """ Converts a list of polygons, encoded as a list of dictionaries into an ``nd.array`` mask. @@ -1333,38 +1229,24 @@ def _parse_version(data: dict) -> dt.AnnotationFileVersion: return dt.AnnotationFileVersion(int(major), int(minor), suffix) -def _data_to_annotations( - data: Dict[str, Any] -) -> List[Union[dt.Annotation, dt.VideoAnnotation]]: +def _data_to_annotations(data: Dict[str, Any]) -> List[Union[dt.Annotation, dt.VideoAnnotation]]: raw_image_annotations = filter( lambda annotation: ( - ("frames" not in annotation) - and ("raster_layer" not in annotation) - and ("mask" not in annotation) + ("frames" not in annotation) and ("raster_layer" not in annotation) and ("mask" not in annotation) ), data["annotations"], ) - raw_video_annotations = filter( - lambda annotation: "frames" in annotation, data["annotations"] - ) - raw_raster_annotations = filter( - lambda annotation: "raster_layer" in annotation, data["annotations"] - ) - raw_mask_annotations = filter( - lambda annotation: "mask" in annotation, data["annotations"] - ) - image_annotations: List[dt.Annotation] = list( - filter(None, map(_parse_darwin_annotation, raw_image_annotations)) - ) + raw_video_annotations = filter(lambda annotation: "frames" in annotation, data["annotations"]) + raw_raster_annotations = filter(lambda annotation: "raster_layer" in annotation, data["annotations"]) + raw_mask_annotations = filter(lambda annotation: "mask" in annotation, data["annotations"]) + image_annotations: List[dt.Annotation] = list(filter(None, map(_parse_darwin_annotation, raw_image_annotations))) video_annotations: List[dt.VideoAnnotation] = list( filter(None, map(_parse_darwin_video_annotation, raw_video_annotations)) ) raster_annotations: List[dt.Annotation] = list( filter(None, map(_parse_darwin_raster_annotation, raw_raster_annotations)) ) - mask_annotations: List[dt.Annotation] = list( - filter(None, map(_parse_darwin_mask_annotation, raw_mask_annotations)) - ) + mask_annotations: List[dt.Annotation] = list(filter(None, map(_parse_darwin_mask_annotation, raw_mask_annotations))) return [ *image_annotations, @@ -1385,6 +1267,4 @@ def _supported_schema_versions() -> Dict[Tuple[int, int, str], str]: def _default_schema(version: dt.AnnotationFileVersion) -> Optional[str]: - return _supported_schema_versions().get( - (version.major, version.minor, version.suffix) - ) + return _supported_schema_versions().get((version.major, version.minor, version.suffix)) diff --git a/tests/darwin/datatypes_test.py b/tests/darwin/datatypes_test.py index 39ca7cb90..1311d6867 100644 --- a/tests/darwin/datatypes_test.py +++ b/tests/darwin/datatypes_test.py @@ -4,26 +4,26 @@ class TestMakePolygon: - def test_it_returns_annotation_with_default_params_darwin_v2(self): + def test_it_returns_annotation_with_default_params(self): class_name: str = "class_name" points: List[Point] = [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 1, "y": 2}] annotation = make_polygon(class_name, points) assert_annotation_class(annotation, class_name, "polygon") - path = annotation.data.get("paths") - assert path == [points] + path = annotation.data.get("path") + assert path == points - def test_it_returns_annotation_with_bounding_box_darwin_v2(self): + def test_it_returns_annotation_with_bounding_box(self): class_name: str = "class_name" points: List[Point] = [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 1, "y": 2}] bbox: Dict[str, float] = {"x": 1, "y": 2, "w": 2, "h": 2} - annotation = make_polygon(class_name, points, bounding_box=bbox) + annotation = make_polygon(class_name, points, bbox) assert_annotation_class(annotation, class_name, "polygon") - path = annotation.data.get("paths") - assert path == [points] + path = annotation.data.get("path") + assert path == points class_bbox = annotation.data.get("bounding_box") assert class_bbox == bbox diff --git a/tests/darwin/exporter/formats/export_coco_test.py b/tests/darwin/exporter/formats/export_coco_test.py index 3cdd2050a..f5e181ce9 100644 --- a/tests/darwin/exporter/formats/export_coco_test.py +++ b/tests/darwin/exporter/formats/export_coco_test.py @@ -16,7 +16,7 @@ def annotation_file(self) -> dt.AnnotationFile: annotations=[], ) - def test_polygon_include_extras_darwin(self, annotation_file: dt.AnnotationFile): + def test_polygon_include_extras(self, annotation_file: dt.AnnotationFile): polygon = dt.Annotation( dt.AnnotationClass("polygon_class", "polygon"), {"path": [{"x": 1, "y": 1}, {"x": 2, "y": 2}, {"x": 1, "y": 2}]}, @@ -25,9 +25,7 @@ def test_polygon_include_extras_darwin(self, annotation_file: dt.AnnotationFile) categories = {"polygon_class": 1} - assert coco._build_annotation(annotation_file, "test-id", polygon, categories)[ - "extra" - ] == {"instance_id": 1} + assert coco._build_annotation(annotation_file, "test-id", polygon, categories)["extra"] == {"instance_id": 1} def test_bounding_boxes_include_extras(self, annotation_file: dt.AnnotationFile): bbox = dt.Annotation( @@ -38,6 +36,4 @@ def test_bounding_boxes_include_extras(self, annotation_file: dt.AnnotationFile) categories = {"bbox_class": 1} - assert coco._build_annotation(annotation_file, "test-id", bbox, categories)[ - "extra" - ] == {"instance_id": 1} + assert coco._build_annotation(annotation_file, "test-id", bbox, categories)["extra"] == {"instance_id": 1} diff --git a/tests/darwin/importer/formats/import_labelbox_test.py b/tests/darwin/importer/formats/import_labelbox_test.py index 29a6bde7c..42b97ba7c 100644 --- a/tests/darwin/importer/formats/import_labelbox_test.py +++ b/tests/darwin/importer/formats/import_labelbox_test.py @@ -277,9 +277,7 @@ def test_it_imports_bbox_images(self, file_path: Path): assert annotation_file.remote_path == "/" assert annotation_file.annotations - bbox_annotation: Annotation = cast( - Annotation, annotation_file.annotations.pop() - ) + bbox_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) assert_bbox(bbox_annotation, 145, 3558, 623, 449) annotation_class = bbox_annotation.annotation_class @@ -345,25 +343,25 @@ def test_it_raises_if_polygon_point_has_missing_y(self, file_path: Path): def test_it_imports_polygon_images(self, file_path: Path): json: str = """ - [ - { - "Label":{ - "objects":[ - { - "title":"Fish", - "polygon": [ - {"x": 3665.814, "y": 351.628}, - {"x": 3762.93, "y": 810.419}, - {"x": 3042.93, "y": 914.233} - ] - } - ], - "classifications": [] - }, - "External ID": "demo-image-7.jpg" - } - ] - """ + [ + { + "Label":{ + "objects":[ + { + "title":"Fish", + "polygon": [ + {"x": 3665.814, "y": 351.628}, + {"x": 3762.93, "y": 810.419}, + {"x": 3042.93, "y": 914.233} + ] + } + ], + "classifications": [] + }, + "External ID": "demo-image-7.jpg" + } + ] + """ file_path.write_text(json) @@ -378,10 +376,7 @@ def test_it_imports_polygon_images(self, file_path: Path): assert annotation_file.annotations - polygon_annotation: Annotation = cast( - Annotation, annotation_file.annotations.pop() - ) - + polygon_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) assert_polygon( polygon_annotation, [ @@ -425,9 +420,7 @@ def test_it_imports_point_images(self, file_path: Path): assert annotation_file.annotations - point_annotation: Annotation = cast( - Annotation, annotation_file.annotations.pop() - ) + point_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) assert_point(point_annotation, {"x": 342.93, "y": 914.233}) annotation_class = point_annotation.annotation_class @@ -468,9 +461,7 @@ def test_it_imports_polyline_images(self, file_path: Path): assert annotation_file.annotations - line_annotation: Annotation = cast( - Annotation, annotation_file.annotations.pop() - ) + line_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) assert_line( line_annotation, [ @@ -613,9 +604,7 @@ def test_it_imports_classification_from_radio_buttons(self, file_path: Path): tag_annotation: Annotation = cast(Annotation, annotation_file.annotations[1]) tag_annotation_class = tag_annotation.annotation_class - assert_annotation_class( - tag_annotation_class, "r_c_or_l_side_radiograph:right", "tag" - ) + assert_annotation_class(tag_annotation_class, "r_c_or_l_side_radiograph:right", "tag") def test_it_imports_classification_from_checklist(self, file_path: Path): json: str = """ @@ -659,15 +648,11 @@ def test_it_imports_classification_from_checklist(self, file_path: Path): tag_annotation_1: Annotation = cast(Annotation, annotation_file.annotations[1]) tag_annotation_class_1 = tag_annotation_1.annotation_class - assert_annotation_class( - tag_annotation_class_1, "r_c_or_l_side_radiograph:right", "tag" - ) + assert_annotation_class(tag_annotation_class_1, "r_c_or_l_side_radiograph:right", "tag") tag_annotation_2: Annotation = cast(Annotation, annotation_file.annotations[2]) tag_annotation_class_2 = tag_annotation_2.annotation_class - assert_annotation_class( - tag_annotation_class_2, "r_c_or_l_side_radiograph:left", "tag" - ) + assert_annotation_class(tag_annotation_class_2, "r_c_or_l_side_radiograph:left", "tag") def test_it_imports_classification_from_free_text(self, file_path: Path): json: str = """ @@ -710,9 +695,7 @@ def test_it_imports_classification_from_free_text(self, file_path: Path): assert_annotation_class(point_annotation_class, "Shark", "keypoint") tag_annotation: Annotation = cast(Annotation, annotation_file.annotations[1]) - assert_annotation_class( - tag_annotation.annotation_class, "r_c_or_l_side_radiograph", "tag" - ) + assert_annotation_class(tag_annotation.annotation_class, "r_c_or_l_side_radiograph", "tag") assert_subannotations( tag_annotation.subs, [SubAnnotation(annotation_type="text", data="righ side")], @@ -730,10 +713,7 @@ def assert_bbox(annotation: Annotation, x: float, y: float, h: float, w: float) def assert_polygon(annotation: Annotation, points: List[Point]) -> None: - actual_points = annotation.data.get("paths") - # Assumes Darwin v2 format - if len(actual_points) == 1: - actual_points = actual_points[0] + actual_points = annotation.data.get("path") assert actual_points assert actual_points == points @@ -763,9 +743,7 @@ def assert_annotation_class( assert annotation_class.annotation_internal_type == internal_type -def assert_subannotations( - actual_subs: List[SubAnnotation], expected_subs: List[SubAnnotation] -) -> None: +def assert_subannotations(actual_subs: List[SubAnnotation], expected_subs: List[SubAnnotation]) -> None: assert actual_subs for actual_sub in actual_subs: for expected_sub in expected_subs: diff --git a/tests/darwin/importer/formats/import_nifti_test.py b/tests/darwin/importer/formats/import_nifti_test.py index 20ee505bc..d5a5769ca 100644 --- a/tests/darwin/importer/formats/import_nifti_test.py +++ b/tests/darwin/importer/formats/import_nifti_test.py @@ -16,19 +16,12 @@ from tests.fixtures import * -def test_image_annotation_nifti_import_single_slot(team_slug_darwin_json_v2: str): - print(team_slug_darwin_json_v2) +def test_image_annotation_nifti_import_single_slot(team_slug: str): with tempfile.TemporaryDirectory() as tmpdir: with ZipFile("tests/data.zip") as zfile: zfile.extractall(tmpdir) label_path = ( - Path(tmpdir) - / team_slug_darwin_json_v2 - / "nifti" - / "releases" - / "latest" - / "annotations" - / "vol0_brain.nii.gz" + Path(tmpdir) / team_slug / "nifti" / "releases" / "latest" / "annotations" / "vol0_brain.nii.gz" ) input_dict = { "data": [ @@ -41,45 +34,25 @@ def test_image_annotation_nifti_import_single_slot(team_slug_darwin_json_v2: str ] } upload_json = Path(tmpdir) / "annotations.json" - upload_json.write_text( - json.dumps(input_dict, indent=4, sort_keys=True, default=str) - ) + upload_json.write_text(json.dumps(input_dict, indent=4, sort_keys=True, default=str)) annotation_files = parse_path(path=upload_json) annotation_file = annotation_files[0] - output_json_string = json.loads( - serialise_annotation_file(annotation_file, as_dict=False) - ) - + output_json_string = json.loads(serialise_annotation_file(annotation_file, as_dict=False)) expected_json_string = json.load( open( - Path(tmpdir) - / team_slug_darwin_json_v2 - / "nifti" - / "vol0_annotation_file.json", + Path(tmpdir) / team_slug / "nifti" / "vol0_annotation_file.json", "r", ) ) - - expected_output_frames = expected_json_string["annotations"][0]["frames"] - - assert ( - output_json_string["annotations"][0]["frames"] == expected_output_frames - ) + assert output_json_string["annotations"][0]["frames"] == expected_json_string["annotations"][0]["frames"] -def test_image_annotation_nifti_import_multi_slot(team_slug_darwin_json_v2: str): - print(team_slug_darwin_json_v2) +def test_image_annotation_nifti_import_multi_slot(team_slug: str): with tempfile.TemporaryDirectory() as tmpdir: with ZipFile("tests/data.zip") as zfile: zfile.extractall(tmpdir) label_path = ( - Path(tmpdir) - / team_slug_darwin_json_v2 - / "nifti" - / "releases" - / "latest" - / "annotations" - / "vol0_brain.nii.gz" + Path(tmpdir) / team_slug / "nifti" / "releases" / "latest" / "annotations" / "vol0_brain.nii.gz" ) input_dict = { "data": [ @@ -94,27 +67,17 @@ def test_image_annotation_nifti_import_multi_slot(team_slug_darwin_json_v2: str) ] } upload_json = Path(tmpdir) / "annotations.json" - upload_json.write_text( - json.dumps(input_dict, indent=4, sort_keys=True, default=str) - ) + upload_json.write_text(json.dumps(input_dict, indent=4, sort_keys=True, default=str)) annotation_files = parse_path(path=upload_json) annotation_file = annotation_files[0] - output_json_string = json.loads( - serialise_annotation_file(annotation_file, as_dict=False) - ) + output_json_string = json.loads(serialise_annotation_file(annotation_file, as_dict=False)) expected_json_string = json.load( open( - Path(tmpdir) - / team_slug_darwin_json_v2 - / "nifti" - / "vol0_annotation_file_multi_slot.json", + Path(tmpdir) / team_slug / "nifti" / "vol0_annotation_file_multi_slot.json", "r", ) ) - assert ( - output_json_string["annotations"][0]["frames"] - == expected_json_string["annotations"][0]["frames"] - ) + assert output_json_string["annotations"][0]["frames"] == expected_json_string["annotations"][0]["frames"] def test_image_annotation_nifti_import_incorrect_number_slot(team_slug: str): @@ -122,13 +85,7 @@ def test_image_annotation_nifti_import_incorrect_number_slot(team_slug: str): with ZipFile("tests/data.zip") as zfile: zfile.extractall(tmpdir) label_path = ( - Path(tmpdir) - / team_slug - / "nifti" - / "releases" - / "latest" - / "annotations" - / "vol0_brain.nii.gz" + Path(tmpdir) / team_slug / "nifti" / "releases" / "latest" / "annotations" / "vol0_brain.nii.gz" ) input_dict = { "data": [ @@ -143,16 +100,12 @@ def test_image_annotation_nifti_import_incorrect_number_slot(team_slug: str): ] } upload_json = Path(tmpdir) / "annotations.json" - upload_json.write_text( - json.dumps(input_dict, indent=4, sort_keys=True, default=str) - ) + upload_json.write_text(json.dumps(input_dict, indent=4, sort_keys=True, default=str)) with pytest.raises(Exception): - parse_path(path=upload_json) + annotation_files = parse_path(path=upload_json) -def serialise_annotation_file( - annotation_file: AnnotationFile, as_dict -) -> Union[str, dict]: +def serialise_annotation_file(annotation_file: AnnotationFile, as_dict) -> Union[str, dict]: """ Serialises an ``AnnotationFile`` into a string. @@ -170,12 +123,9 @@ def serialise_annotation_file( "path": str(annotation_file.path), "filename": annotation_file.filename, "annotation_classes": [ - serialise_annotation_class(ac, as_dict=True) - for ac in annotation_file.annotation_classes - ], - "annotations": [ - serialise_annotation(a, as_dict=True) for a in annotation_file.annotations + serialise_annotation_class(ac, as_dict=True) for ac in annotation_file.annotation_classes ], + "annotations": [serialise_annotation(a, as_dict=True) for a in annotation_file.annotations], "is_video": annotation_file.is_video, "image_width": annotation_file.image_width, "image_height": annotation_file.image_height, @@ -194,9 +144,7 @@ def serialise_annotation_file( return output_dict if as_dict else json_string -def serialise_annotation( - annotation: Union[Annotation, VideoAnnotation], as_dict -) -> Union[str, dict]: +def serialise_annotation(annotation: Union[Annotation, VideoAnnotation], as_dict) -> Union[str, dict]: if isinstance(annotation, VideoAnnotation): return serialise_video_annotation(annotation, as_dict=as_dict) elif isinstance(annotation, Annotation): @@ -220,9 +168,7 @@ def serialise_general_annotation(annotation: Annotation, as_dict) -> Union[str, return output_dict if as_dict else json_string -def serialise_video_annotation( - video_annotation: VideoAnnotation, as_dict: bool = True -) -> Union[str, dict]: +def serialise_video_annotation(video_annotation: VideoAnnotation, as_dict: bool = True) -> Union[str, dict]: data = video_annotation.get_data() output_dict = { "annotation_class": video_annotation.annotation_class.name, @@ -237,9 +183,7 @@ def serialise_video_annotation( return output_dict if as_dict else json_string -def serialise_annotation_class( - annotation_class: AnnotationClass, as_dict: bool = True -) -> Union[str, dict]: +def serialise_annotation_class(annotation_class: AnnotationClass, as_dict: bool = True) -> Union[str, dict]: output_dict = { "name": annotation_class.name, "annotation_type": annotation_class.annotation_type, @@ -249,9 +193,7 @@ def serialise_annotation_class( return output_dict if as_dict else json_string -def serialise_sub_annotation( - sub_annotation: SubAnnotation, as_dict: bool = True -) -> Union[str, dict]: +def serialise_sub_annotation(sub_annotation: SubAnnotation, as_dict: bool = True) -> Union[str, dict]: output_dict = { "type": sub_annotation.annotation_type, "data": sub_annotation.data, @@ -266,9 +208,7 @@ def serialise_sub_annotation( if __name__ == "__main__": - args = argparse.ArgumentParser( - description="Update the serialisation of AnnotationFile with the current version." - ) + args = argparse.ArgumentParser(description="Update the serialisation of AnnotationFile with the current version.") input_json_string: str = """ { "data": [ @@ -293,11 +233,7 @@ def serialise_sub_annotation( annotation_file = annotation_files[0] output_json_string = serialise_annotation_file(annotation_file, as_dict=False) with open( - Path("tests") - / "v7" - / "v7-darwin-json-v1" - / "nifti" - / "vol0_annotation_file_multi_slot.json", + Path("tests") / "v7" / "v7-darwin-json-v1" / "nifti" / "vol0_annotation_file_multi_slot.json", "w", ) as f: f.write(output_json_string) diff --git a/tests/darwin/importer/formats/import_superannotate_test.py b/tests/darwin/importer/formats/import_superannotate_test.py index bad980965..b65155a41 100644 --- a/tests/darwin/importer/formats/import_superannotate_test.py +++ b/tests/darwin/importer/formats/import_superannotate_test.py @@ -53,9 +53,7 @@ def test_raises_if_folder_has_no_classes_file(self, annotations_file_path: Path) assert "Folder must contain a 'classes.json'" in str(error.value) - def test_returns_empty_file_if_there_are_no_annotations( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_returns_empty_file_if_there_are_no_annotations(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [], @@ -78,9 +76,7 @@ def test_returns_empty_file_if_there_are_no_annotations( remote_path="/", ) - def test_raises_if_annotation_has_no_type( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_raises_if_annotation_has_no_type(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -104,9 +100,7 @@ def test_raises_if_annotation_has_no_type( assert "'type' is a required property" in str(error.value) - def test_raises_if_annotation_has_no_class_id( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_raises_if_annotation_has_no_class_id(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -131,9 +125,7 @@ def test_raises_if_annotation_has_no_class_id( assert "'classId' is a required property" in str(error.value) - def test_raises_if_metadata_is_missing( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_raises_if_metadata_is_missing(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -155,9 +147,7 @@ def test_raises_if_metadata_is_missing( assert "'metadata' is a required property" in str(error.value) - def test_raises_if_metadata_is_missing_name( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_raises_if_metadata_is_missing_name(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -179,9 +169,7 @@ def test_raises_if_metadata_is_missing_name( assert "'name' is a required property" in str(error.value) - def test_raises_if_point_has_missing_coordinate( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_raises_if_point_has_missing_coordinate(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -206,9 +194,7 @@ def test_raises_if_point_has_missing_coordinate( error_str = str(error.value) assert all(["point" in error_str, "ellipse" in error_str]) - def test_imports_point_vectors( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_imports_point_vectors(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -236,17 +222,13 @@ def test_imports_point_vectors( assert annotation_file.annotations - point_annotation: Annotation = cast( - Annotation, annotation_file.annotations.pop() - ) + point_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) assert_point(point_annotation, {"x": 1.93, "y": 0.233}) annotation_class = point_annotation.annotation_class assert_annotation_class(annotation_class, "Person-point", "keypoint") - def test_raises_if_ellipse_has_missing_coordinate( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_raises_if_ellipse_has_missing_coordinate(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -266,9 +248,7 @@ def test_raises_if_ellipse_has_missing_coordinate( error_str = str(error.value) assert all(["ellipse" in error_str, "point" in error_str]) - def test_imports_ellipse_vectors( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_imports_ellipse_vectors(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -305,9 +285,7 @@ def test_imports_ellipse_vectors( assert annotation_file.annotations - ellipse_annotation: Annotation = cast( - Annotation, annotation_file.annotations.pop() - ) + ellipse_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) assert_ellipse( ellipse_annotation, { @@ -320,9 +298,7 @@ def test_imports_ellipse_vectors( annotation_class = ellipse_annotation.annotation_class assert_annotation_class(annotation_class, "Person-ellipse", "ellipse") - def test_raises_if_cuboid_has_missing_point( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_raises_if_cuboid_has_missing_point(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -349,9 +325,7 @@ def test_raises_if_cuboid_has_missing_point( error_str = str(error.value) assert all(["cuboid" in error_str, "point" in error_str]) - def test_imports_cuboid_vectors( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_imports_cuboid_vectors(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -389,9 +363,7 @@ def test_imports_cuboid_vectors( assert annotation_file.annotations - cuboid_annotation: Annotation = cast( - Annotation, annotation_file.annotations.pop() - ) + cuboid_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) assert_cuboid( cuboid_annotation, { @@ -403,9 +375,7 @@ def test_imports_cuboid_vectors( annotation_class = cuboid_annotation.annotation_class assert_annotation_class(annotation_class, "Person-cuboid", "cuboid") - def test_raises_if_polygon_has_missing_points( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_raises_if_polygon_has_missing_points(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -430,9 +400,7 @@ def test_raises_if_polygon_has_missing_points( error_str = str(error.value) assert all(["polygon" in error_str, "point" in error_str]) - def test_imports_polygon_vectors( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_imports_polygon_vectors(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -465,9 +433,7 @@ def test_imports_polygon_vectors( assert annotation_file.annotations - polygon_annotation: Annotation = cast( - Annotation, annotation_file.annotations.pop() - ) + polygon_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) assert_polygon( polygon_annotation, [ @@ -480,9 +446,7 @@ def test_imports_polygon_vectors( annotation_class = polygon_annotation.annotation_class assert_annotation_class(annotation_class, "Person-polygon", "polygon") - def test_raises_if_polyline_has_missing_points( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_raises_if_polyline_has_missing_points(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -507,9 +471,7 @@ def test_raises_if_polyline_has_missing_points( error_str = str(error.value) assert all(["polyline" in error_str, "point" in error_str]) - def test_imports_polyline_vectors( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_imports_polyline_vectors(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -542,9 +504,7 @@ def test_imports_polyline_vectors( assert annotation_file.annotations - line_annotation: Annotation = cast( - Annotation, annotation_file.annotations.pop() - ) + line_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) assert_line( line_annotation, [ @@ -557,9 +517,7 @@ def test_imports_polyline_vectors( annotation_class = line_annotation.annotation_class assert_annotation_class(annotation_class, "Person-polyline", "line") - def test_raises_if_bbox_has_missing_points( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_raises_if_bbox_has_missing_points(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -583,9 +541,7 @@ def test_raises_if_bbox_has_missing_points( error_str = str(error.value) assert all(["bbox" in error_str, "point" in error_str]) - def test_imports_bbox_vectors( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_imports_bbox_vectors(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -618,17 +574,13 @@ def test_imports_bbox_vectors( assert annotation_file.annotations - bbox_annotation: Annotation = cast( - Annotation, annotation_file.annotations.pop() - ) + bbox_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) assert_bbox(bbox_annotation, 1642.9, 516.5, 217.5, 277.1) annotation_class = bbox_annotation.annotation_class assert_annotation_class(annotation_class, "Person-bbox", "bounding_box") - def test_raises_if_an_attributes_is_missing( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_raises_if_an_attributes_is_missing(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -669,9 +621,7 @@ def test_raises_if_an_attributes_is_missing( error_str = str(error.value) assert all(["type" in error_str, "bbox" in error_str]) - def test_raises_if_an_attribute_from_a_group_is_missing( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_raises_if_an_attribute_from_a_group_is_missing(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -710,13 +660,9 @@ def test_raises_if_an_attribute_from_a_group_is_missing( with pytest.raises(ValueError) as error: parse_path(annotations_file_path) - assert "No attribute data found for {'id': 2, 'groupId': 1}." in str( - error.value - ) + assert "No attribute data found for {'id': 2, 'groupId': 1}." in str(error.value) - def test_imports_attributes( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_imports_attributes(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -771,9 +717,7 @@ def test_imports_attributes( assert annotation_file.annotations - bbox_annotation: Annotation = cast( - Annotation, annotation_file.annotations.pop() - ) + bbox_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) assert_bbox(bbox_annotation, 1642.9, 516.5, 217.5, 277.1) annotation_class = bbox_annotation.annotation_class @@ -784,9 +728,7 @@ def test_imports_attributes( [SubAnnotation("attributes", ["Sex:Female", "Emotion:Smiling"])], ) - def test_raises_if_tags_is_missing( - self, annotations_file_path: Path, classes_file_path: Path - ): + def test_raises_if_tags_is_missing(self, annotations_file_path: Path, classes_file_path: Path): annotations_json: str = """ { "instances": [ @@ -891,13 +833,8 @@ def assert_bbox(annotation: Annotation, x: float, y: float, h: float, w: float) def assert_polygon(annotation: Annotation, points: List[Point]) -> None: - actual_points = annotation.data.get("paths") + actual_points = annotation.data.get("path") assert actual_points - - # Drawin v2 uses a list of lists for paths [][] - if len(actual_points) == 1: - actual_points = actual_points[0] - assert actual_points == points @@ -945,9 +882,7 @@ def assert_annotation_class( assert annotation_class.annotation_internal_type == internal_type -def assert_subannotations( - actual_subs: List[SubAnnotation], expected_subs: List[SubAnnotation] -) -> None: +def assert_subannotations(actual_subs: List[SubAnnotation], expected_subs: List[SubAnnotation]) -> None: assert actual_subs for actual_sub in actual_subs: for expected_sub in expected_subs: From f41a324dbc4e9e761e1f4ed460ff950fdab82d25 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Mon, 27 Nov 2023 15:36:42 +0100 Subject: [PATCH 59/71] black --- darwin/dataset/utils.py | 79 +++++-- darwin/datatypes.py | 24 +- darwin/exporter/formats/coco.py | 116 ++++++--- darwin/exporter/formats/darwin.py | 8 +- darwin/importer/importer.py | 217 +++++++++++++---- darwin/utils/utils.py | 221 +++++++++++++----- .../exporter/formats/export_coco_test.py | 8 +- .../importer/formats/import_labelbox_test.py | 36 ++- .../importer/formats/import_nifti_test.py | 96 ++++++-- .../formats/import_superannotate_test.py | 120 +++++++--- 10 files changed, 705 insertions(+), 220 deletions(-) diff --git a/darwin/dataset/utils.py b/darwin/dataset/utils.py index eec81709d..7aa3fa1bc 100644 --- a/darwin/dataset/utils.py +++ b/darwin/dataset/utils.py @@ -102,7 +102,10 @@ def extract_classes( continue for annotation in annotation_file.annotations: - if annotation.annotation_class.annotation_type not in annotation_types_to_load: + if ( + annotation.annotation_class.annotation_type + not in annotation_types_to_load + ): continue class_name = annotation.annotation_class.name @@ -191,7 +194,11 @@ def get_classes( classes_file_path = release_path / f"lists/classes_{atype}.txt" class_per_annotations = get_classes_from_file(classes_file_path) - if remove_background and class_per_annotations and class_per_annotations[0] == "__background__": + if ( + remove_background + and class_per_annotations + and class_per_annotations[0] == "__background__" + ): class_per_annotations = class_per_annotations[1:] for cls in class_per_annotations: @@ -314,7 +321,9 @@ def get_coco_format_record( objs = [] for obj in data.annotations: if annotation_type != obj.annotation_class.annotation_type: - if annotation_type not in obj.data: # Allows training object detection with bboxes + if ( + annotation_type not in obj.data + ): # Allows training object detection with bboxes continue if annotation_type == "polygon": @@ -357,7 +366,9 @@ def create_polygon_object(obj, box_mode, classes=None): "segmentation": segmentation, "bbox": [np.min(all_px), np.min(all_py), np.max(all_px), np.max(all_py)], "bbox_mode": box_mode, - "category_id": classes.index(obj.annotation_class.name) if classes else obj.annotation_class.name, + "category_id": classes.index(obj.annotation_class.name) + if classes + else obj.annotation_class.name, "iscrowd": 0, } @@ -369,7 +380,9 @@ def create_bbox_object(obj, box_mode, classes=None): new_obj = { "bbox": [bbox["x"], bbox["y"], bbox["x"] + bbox["w"], bbox["y"] + bbox["h"]], "bbox_mode": box_mode, - "category_id": classes.index(obj.annotation_class.name) if classes else obj.annotation_class.name, + "category_id": classes.index(obj.annotation_class.name) + if classes + else obj.annotation_class.name, "iscrowd": 0, } @@ -446,7 +459,9 @@ def get_annotations( ) if partition: - stems = _get_stems_from_split(release_path, split, split_type, annotation_type, partition) + stems = _get_stems_from_split( + release_path, split, split_type, annotation_type, partition + ) else: stems = (e.stem for e in annotations_dir.glob("**/*.json")) @@ -454,14 +469,19 @@ def get_annotations( images_paths, annotations_paths, invalid_annotation_paths, - ) = _map_annotations_to_images(stems, annotations_dir, images_dir, ignore_inconsistent_examples) + ) = _map_annotations_to_images( + stems, annotations_dir, images_dir, ignore_inconsistent_examples + ) print(f"Found {len(invalid_annotation_paths)} invalid annotations") for p in invalid_annotation_paths: print(p) if len(images_paths) == 0: - raise ValueError(f"Could not find any {SUPPORTED_EXTENSIONS} file" f" in {dataset_path / 'images'}") + raise ValueError( + f"Could not find any {SUPPORTED_EXTENSIONS} file" + f" in {dataset_path / 'images'}" + ) assert len(images_paths) == len(annotations_paths) @@ -487,7 +507,9 @@ def _validate_inputs(partition, split_type, annotation_type): if split_type not in ["random", "stratified", None]: raise ValueError("split_type should be either 'random', 'stratified', or None") if annotation_type not in ["tag", "polygon", "bounding_box"]: - raise ValueError("annotation_type should be either 'tag', 'bounding_box', or 'polygon'") + raise ValueError( + "annotation_type should be either 'tag', 'bounding_box', or 'polygon'" + ) def _get_stems_from_split(release_path, split, split_type, annotation_type, partition): @@ -528,7 +550,9 @@ def _get_stems_from_split(release_path, split, split_type, annotation_type, part ) -def _map_annotations_to_images(stems, annotations_dir, images_dir, ignore_inconsistent_examples): +def _map_annotations_to_images( + stems, annotations_dir, images_dir, ignore_inconsistent_examples +): """ Maps annotations to their corresponding images based on the file stems. @@ -559,12 +583,16 @@ def _map_annotations_to_images(stems, annotations_dir, images_dir, ignore_incons invalid_annotation_paths.append(annotation_path) continue else: - raise ValueError(f"Annotation ({annotation_path}) does not have a corresponding image") + raise ValueError( + f"Annotation ({annotation_path}) does not have a corresponding image" + ) return images_paths, annotations_paths, invalid_annotation_paths -def _load_and_format_annotations(images_paths, annotations_paths, annotation_format, annotation_type, classes): +def _load_and_format_annotations( + images_paths, annotations_paths, annotation_format, annotation_type, classes +): """ Loads and formats annotations based on the specified format and type. @@ -583,9 +611,13 @@ def _load_and_format_annotations(images_paths, annotations_paths, annotation_for """ if annotation_format == "coco": images_ids = list(range(len(images_paths))) - for annotation_path, image_path, image_id in zip(annotations_paths, images_paths, images_ids): + for annotation_path, image_path, image_id in zip( + annotations_paths, images_paths, images_ids + ): if image_path.suffix.lower() in SUPPORTED_VIDEO_EXTENSIONS: - print(f"[WARNING] Cannot load video annotation into COCO format. Skipping {image_path}") + print( + f"[WARNING] Cannot load video annotation into COCO format. Skipping {image_path}" + ) continue yield get_coco_format_record( annotation_path=annotation_path, @@ -723,25 +755,34 @@ def compute_distributions( - instance_distribution: count of all instances of a given class exist for each partition """ - class_distribution: AnnotationDistribution = {partition: Counter() for partition in partitions} - instance_distribution: AnnotationDistribution = {partition: Counter() for partition in partitions} + class_distribution: AnnotationDistribution = { + partition: Counter() for partition in partitions + } + instance_distribution: AnnotationDistribution = { + partition: Counter() for partition in partitions + } for partition in partitions: for annotation_type in annotation_types: - split_file: Path = split_path / f"stratified_{annotation_type}_{partition}.txt" + split_file: Path = ( + split_path / f"stratified_{annotation_type}_{partition}.txt" + ) if not split_file.exists(): split_file = split_path / f"random_{partition}.txt" stems: List[str] = [e.rstrip("\n\r") for e in split_file.open()] for stem in stems: annotation_path: Path = annotations_dir / f"{stem}.json" - annotation_file: Optional[dt.AnnotationFile] = parse_path(annotation_path) + annotation_file: Optional[dt.AnnotationFile] = parse_path( + annotation_path + ) if annotation_file is None: continue annotation_class_names: List[str] = [ - annotation.annotation_class.name for annotation in annotation_file.annotations + annotation.annotation_class.name + for annotation in annotation_file.annotations ] class_distribution[partition] += Counter(set(annotation_class_names)) diff --git a/darwin/datatypes.py b/darwin/datatypes.py index cbe108a17..854dfbed8 100644 --- a/darwin/datatypes.py +++ b/darwin/datatypes.py @@ -24,7 +24,9 @@ # Utility types -NumberLike = Union[int, float] # Used for functions that can take either an int or a float +NumberLike = Union[ + int, float +] # Used for functions that can take either an int or a float # Used for functions that _genuinely_ don't know what type they're dealing with, such as those that test if something is of a certain type. UnknownType = Any # type:ignore @@ -268,7 +270,9 @@ class VideoAnnotation: def get_data( self, only_keyframes: bool = True, - post_processing: Optional[Callable[[Annotation, UnknownType], UnknownType]] = None, + post_processing: Optional[ + Callable[[Annotation, UnknownType], UnknownType] + ] = None, ) -> Dict: """ Return the post-processed frames and the additional information from this @@ -302,7 +306,9 @@ def get_data( """ if not post_processing: - def post_processing(annotation: Annotation, data: UnknownType) -> UnknownType: + def post_processing( + annotation: Annotation, data: UnknownType + ) -> UnknownType: return data # type: ignore output = { @@ -520,7 +526,9 @@ def make_tag( Annotation A tag ``Annotation``. """ - return Annotation(AnnotationClass(class_name, "tag"), {}, subs or [], slot_names=slot_names or []) + return Annotation( + AnnotationClass(class_name, "tag"), {}, subs or [], slot_names=slot_names or [] + ) def make_polygon( @@ -1008,7 +1016,9 @@ def make_mask( Annotation A mask ``Annotation``. """ - return Annotation(AnnotationClass(class_name, "mask"), {}, subs or [], slot_names=slot_names or []) + return Annotation( + AnnotationClass(class_name, "mask"), {}, subs or [], slot_names=slot_names or [] + ) def make_raster_layer( @@ -1206,7 +1216,9 @@ def make_video_annotation( ) -def _maybe_add_bounding_box_data(data: Dict[str, UnknownType], bounding_box: Optional[Dict]) -> Dict[str, UnknownType]: +def _maybe_add_bounding_box_data( + data: Dict[str, UnknownType], bounding_box: Optional[Dict] +) -> Dict[str, UnknownType]: if bounding_box: data["bounding_box"] = { "x": bounding_box["x"], diff --git a/darwin/exporter/formats/coco.py b/darwin/exporter/formats/coco.py index 2f7342f07..fbba4d504 100644 --- a/darwin/exporter/formats/coco.py +++ b/darwin/exporter/formats/coco.py @@ -35,7 +35,9 @@ def export(annotation_files: Iterator[dt.AnnotationFile], output_dir: Path) -> N output = _build_json(list(annotation_files)) output_file_path = (output_dir / "output").with_suffix(".json") with open(output_file_path, "w") as f: - op = json.dumps(output, option=json.OPT_INDENT_2 | json.OPT_SERIALIZE_NUMPY).decode("utf-8") + op = json.dumps( + output, option=json.OPT_INDENT_2 | json.OPT_SERIALIZE_NUMPY + ).decode("utf-8") f.write(op) @@ -68,12 +70,18 @@ def calculate_categories(annotation_files: List[dt.AnnotationFile]) -> Dict[str, categories: Dict[str, int] = {} for annotation_file in annotation_files: for annotation_class in annotation_file.annotation_classes: - if annotation_class.name not in categories and annotation_class.annotation_type in [ - "polygon", - "complex_polygon", - "bounding_box", - ]: - categories[annotation_class.name] = _calculate_category_id(annotation_class) + if ( + annotation_class.name not in categories + and annotation_class.annotation_type + in [ + "polygon", + "complex_polygon", + "bounding_box", + ] + ): + categories[annotation_class.name] = _calculate_category_id( + annotation_class + ) return categories @@ -89,8 +97,13 @@ def calculate_tag_categories( categories: Dict[str, int] = {} for annotation_file in annotation_files: for annotation_class in annotation_file.annotation_classes: - if annotation_class.name not in categories and annotation_class.annotation_type == "tag": - categories[annotation_class.name] = _calculate_category_id(annotation_class) + if ( + annotation_class.name not in categories + and annotation_class.annotation_type == "tag" + ): + categories[annotation_class.name] = _calculate_category_id( + annotation_class + ) return categories @@ -129,7 +142,9 @@ def build_licenses() -> List[Dict[str, Any]]: current_version=__version__, details=DEPRECATION_MESSAGE, ) -def build_images(annotation_files: List[dt.AnnotationFile], tag_categories: Dict[str, int]) -> List[Dict[str, Any]]: +def build_images( + annotation_files: List[dt.AnnotationFile], tag_categories: Dict[str, int] +) -> List[Dict[str, Any]]: return [ build_image(annotation_file, tag_categories) for annotation_file in sorted(annotation_files, key=lambda x: x.seq) @@ -142,9 +157,13 @@ def build_images(annotation_files: List[dt.AnnotationFile], tag_categories: Dict current_version=__version__, details=DEPRECATION_MESSAGE, ) -def build_image(annotation_file: dt.AnnotationFile, tag_categories: Dict[str, int]) -> Dict[str, Any]: +def build_image( + annotation_file: dt.AnnotationFile, tag_categories: Dict[str, int] +) -> Dict[str, Any]: tags = [ - annotation for annotation in annotation_file.annotations if annotation.annotation_class.annotation_type == "tag" + annotation + for annotation in annotation_file.annotations + if annotation.annotation_class.annotation_type == "tag" ] return { "license": 0, @@ -174,7 +193,9 @@ def build_annotations( for annotation_file in annotation_files: for annotation in annotation_file.annotations: annotation_id += 1 - annotation_data = build_annotation(annotation_file, annotation_id, annotation, categories) + annotation_data = build_annotation( + annotation_file, annotation_id, annotation, categories + ) if annotation_data: yield annotation_data @@ -193,7 +214,9 @@ def build_annotation( ) -> Optional[Dict[str, Any]]: annotation_type = annotation.annotation_class.annotation_type if annotation_type == "polygon": - sequences = convert_polygons_to_sequences(annotation.data["path"], rounding=False) + sequences = convert_polygons_to_sequences( + annotation.data["path"], rounding=False + ) x_coords = [s[0::2] for s in sequences] y_coords = [s[1::2] for s in sequences] min_x = np.min([np.min(x_coord) for x_coord in x_coords]) @@ -203,7 +226,12 @@ def build_annotation( w = max_x - min_x h = max_y - min_y # Compute the area of the polygon - poly_area = np.sum([polygon_area(x_coord, y_coord) for x_coord, y_coord in zip(x_coords, y_coords)]) + poly_area = np.sum( + [ + polygon_area(x_coord, y_coord) + for x_coord, y_coord in zip(x_coords, y_coords) + ] + ) return { "id": annotation_id, @@ -345,12 +373,18 @@ def _calculate_categories(annotation_files: List[dt.AnnotationFile]) -> Dict[str categories: Dict[str, int] = {} for annotation_file in annotation_files: for annotation_class in annotation_file.annotation_classes: - if annotation_class.name not in categories and annotation_class.annotation_type in [ - "polygon", - "complex_polygon", - "bounding_box", - ]: - categories[annotation_class.name] = _calculate_category_id(annotation_class) + if ( + annotation_class.name not in categories + and annotation_class.annotation_type + in [ + "polygon", + "complex_polygon", + "bounding_box", + ] + ): + categories[annotation_class.name] = _calculate_category_id( + annotation_class + ) return categories @@ -360,8 +394,13 @@ def _calculate_tag_categories( categories: Dict[str, int] = {} for annotation_file in annotation_files: for annotation_class in annotation_file.annotation_classes: - if annotation_class.name not in categories and annotation_class.annotation_type == "tag": - categories[annotation_class.name] = _calculate_category_id(annotation_class) + if ( + annotation_class.name not in categories + and annotation_class.annotation_type == "tag" + ): + categories[annotation_class.name] = _calculate_category_id( + annotation_class + ) return categories @@ -386,16 +425,22 @@ def _build_licenses() -> List[Dict[str, Any]]: return [{"url": "n/a", "id": 0, "name": "placeholder license"}] -def _build_images(annotation_files: List[dt.AnnotationFile], tag_categories: Dict[str, int]) -> List[Dict[str, Any]]: +def _build_images( + annotation_files: List[dt.AnnotationFile], tag_categories: Dict[str, int] +) -> List[Dict[str, Any]]: return [ _build_image(annotation_file, tag_categories) for annotation_file in sorted(annotation_files, key=lambda x: x.seq) ] -def _build_image(annotation_file: dt.AnnotationFile, tag_categories: Dict[str, int]) -> Dict[str, Any]: +def _build_image( + annotation_file: dt.AnnotationFile, tag_categories: Dict[str, int] +) -> Dict[str, Any]: tags = [ - annotation for annotation in annotation_file.annotations if annotation.annotation_class.annotation_type == "tag" + annotation + for annotation in annotation_file.annotations + if annotation.annotation_class.annotation_type == "tag" ] return { @@ -420,7 +465,9 @@ def _build_image_id(annotation_file: dt.AnnotationFile) -> int: if annotation_file.seq: return annotation_file.seq else: - full_path = str(Path(annotation_file.remote_path or "/") / Path(annotation_file.filename)) + full_path = str( + Path(annotation_file.remote_path or "/") / Path(annotation_file.filename) + ) return crc32(str.encode(full_path)) @@ -431,7 +478,9 @@ def _build_annotations( for annotation_file in annotation_files: for annotation in annotation_file.annotations: annotation_id += 1 - annotation_data = _build_annotation(annotation_file, annotation_id, annotation, categories) + annotation_data = _build_annotation( + annotation_file, annotation_id, annotation, categories + ) if annotation_data: yield annotation_data @@ -444,7 +493,9 @@ def _build_annotation( ) -> Optional[Dict[str, Any]]: annotation_type = annotation.annotation_class.annotation_type if annotation_type == "polygon": - sequences = convert_polygons_to_sequences(annotation.data["path"], rounding=False) + sequences = convert_polygons_to_sequences( + annotation.data["path"], rounding=False + ) x_coords = [s[0::2] for s in sequences] y_coords = [s[1::2] for s in sequences] min_x = np.min([np.min(x_coord) for x_coord in x_coords]) @@ -454,7 +505,12 @@ def _build_annotation( w = max_x - min_x h = max_y - min_y # Compute the area of the polygon - poly_area = np.sum([_polygon_area(x_coord, y_coord) for x_coord, y_coord in zip(x_coords, y_coords)]) + poly_area = np.sum( + [ + _polygon_area(x_coord, y_coord) + for x_coord, y_coord in zip(x_coords, y_coords) + ] + ) return { "id": annotation_id, diff --git a/darwin/exporter/formats/darwin.py b/darwin/exporter/formats/darwin.py index 45828acaa..1c7f3ef88 100644 --- a/darwin/exporter/formats/darwin.py +++ b/darwin/exporter/formats/darwin.py @@ -174,7 +174,9 @@ def build_annotation_data(annotation: dt.Annotation) -> Dict[str, Any]: return {"path": annotation.data["paths"]} if annotation.annotation_class.annotation_type == "polygon": - return dict(filter(lambda item: item[0] != "bounding_box", annotation.data.items())) + return dict( + filter(lambda item: item[0] != "bounding_box", annotation.data.items()) + ) return dict(annotation.data) @@ -184,6 +186,8 @@ def _build_annotation_data(annotation: dt.Annotation) -> Dict[str, Any]: return {"path": annotation.data["paths"]} if annotation.annotation_class.annotation_type == "polygon": - return dict(filter(lambda item: item[0] != "bounding_box", annotation.data.items())) + return dict( + filter(lambda item: item[0] != "bounding_box", annotation.data.items()) + ) return dict(annotation.data) diff --git a/darwin/importer/importer.py b/darwin/importer/importer.py index 997b024c3..06b5f5af7 100644 --- a/darwin/importer/importer.py +++ b/darwin/importer/importer.py @@ -69,7 +69,9 @@ current_version=__version__, details=DEPRECATION_MESSAGE, ) -def build_main_annotations_lookup_table(annotation_classes: List[Dict[str, Unknown]]) -> Dict[str, Unknown]: +def build_main_annotations_lookup_table( + annotation_classes: List[Dict[str, Unknown]] +) -> Dict[str, Unknown]: MAIN_ANNOTATION_TYPES = [ "bounding_box", "cuboid", @@ -164,7 +166,10 @@ def maybe_console(*args: Union[str, int, float]) -> None: def _get_files_for_parsing(file_paths: List[PathLike]) -> List[Path]: - packed_files = [filepath.glob("**/*") if filepath.is_dir() else [filepath] for filepath in map(Path, file_paths)] + packed_files = [ + filepath.glob("**/*") if filepath.is_dir() else [filepath] + for filepath in map(Path, file_paths) + ] return [file for files in packed_files for file in files] @@ -231,18 +236,30 @@ def _resolve_annotation_classes( local_classes_not_in_team: Set[dt.AnnotationClass] = set() for local_cls in local_annotation_classes: - local_annotation_type = local_cls.annotation_internal_type or local_cls.annotation_type + local_annotation_type = ( + local_cls.annotation_internal_type or local_cls.annotation_type + ) # Only add the new class if it doesn't exist remotely already - if local_annotation_type in classes_in_dataset and local_cls.name in classes_in_dataset[local_annotation_type]: + if ( + local_annotation_type in classes_in_dataset + and local_cls.name in classes_in_dataset[local_annotation_type] + ): continue # Only add the new class if it's not included in the list of the missing classes already - if local_cls.name in [missing_class.name for missing_class in local_classes_not_in_dataset]: + if local_cls.name in [ + missing_class.name for missing_class in local_classes_not_in_dataset + ]: continue - if local_cls.name in [missing_class.name for missing_class in local_classes_not_in_team]: + if local_cls.name in [ + missing_class.name for missing_class in local_classes_not_in_team + ]: continue - if local_annotation_type in classes_in_team and local_cls.name in classes_in_team[local_annotation_type]: + if ( + local_annotation_type in classes_in_team + and local_cls.name in classes_in_team[local_annotation_type] + ): local_classes_not_in_dataset.add(local_cls) else: local_classes_not_in_team.add(local_cls) @@ -321,14 +338,18 @@ def import_annotations( # noqa: C901 "The options 'append' and 'delete_for_empty' cannot be used together. Use only one of them." ) - cpu_limit, use_multi_cpu = _get_multi_cpu_settings(cpu_limit, cpu_count(), use_multi_cpu) + cpu_limit, use_multi_cpu = _get_multi_cpu_settings( + cpu_limit, cpu_count(), use_multi_cpu + ) if use_multi_cpu: console.print(f"Using {cpu_limit} CPUs for parsing...", style="info") else: console.print("Using 1 CPU for parsing...", style="info") if not isinstance(file_paths, list): - raise ValueError(f"file_paths must be a list of 'Path' or 'str'. Current value: {file_paths}") + raise ValueError( + f"file_paths must be a list of 'Path' or 'str'. Current value: {file_paths}" + ) console.print("Fetching remote class list...", style="info") team_classes: List[dt.DictFreeForm] = dataset.fetch_remote_classes(True) @@ -342,10 +363,18 @@ def import_annotations( # noqa: C901 ) classes_in_dataset: dt.DictFreeForm = build_main_annotations_lookup_table( - [cls for cls in team_classes if cls["available"] or cls["name"] in GLOBAL_CLASSES] + [ + cls + for cls in team_classes + if cls["available"] or cls["name"] in GLOBAL_CLASSES + ] ) classes_in_team: dt.DictFreeForm = build_main_annotations_lookup_table( - [cls for cls in team_classes if not cls["available"] and cls["name"] not in GLOBAL_CLASSES] + [ + cls + for cls in team_classes + if not cls["available"] and cls["name"] not in GLOBAL_CLASSES + ] ) attributes = build_attribute_lookup(dataset) @@ -363,7 +392,9 @@ def import_annotations( # noqa: C901 parsed_files: List[AnnotationFile] = flatten_list(list(maybe_parsed_files)) - filenames: List[str] = [parsed_file.filename for parsed_file in parsed_files if parsed_file is not None] + filenames: List[str] = [ + parsed_file.filename for parsed_file in parsed_files if parsed_file is not None + ] console.print("Fetching remote file list...", style="info") # This call will only filter by filename; so can return a superset of matched files across different paths @@ -388,27 +419,47 @@ def import_annotations( # noqa: C901 else: local_files.append(parsed_file) - console.print(f"{len(local_files) + len(local_files_missing_remotely)} annotation file(s) found.", style="info") + console.print( + f"{len(local_files) + len(local_files_missing_remotely)} annotation file(s) found.", + style="info", + ) if local_files_missing_remotely: - console.print(f"{len(local_files_missing_remotely)} file(s) are missing from the dataset", style="warning") + console.print( + f"{len(local_files_missing_remotely)} file(s) are missing from the dataset", + style="warning", + ) for local_file in local_files_missing_remotely: - console.print(f"\t{local_file.path}: '{local_file.full_path}'", style="warning") + console.print( + f"\t{local_file.path}: '{local_file.full_path}'", style="warning" + ) if class_prompt and not secure_continue_request(): return - local_classes_not_in_dataset, local_classes_not_in_team = _resolve_annotation_classes( - [annotation_class for file in local_files for annotation_class in file.annotation_classes], + ( + local_classes_not_in_dataset, + local_classes_not_in_team, + ) = _resolve_annotation_classes( + [ + annotation_class + for file in local_files + for annotation_class in file.annotation_classes + ], classes_in_dataset, classes_in_team, ) - console.print(f"{len(local_classes_not_in_team)} classes needs to be created.", style="info") console.print( - f"{len(local_classes_not_in_dataset)} classes needs to be added to {dataset.identifier}", style="info" + f"{len(local_classes_not_in_team)} classes needs to be created.", style="info" + ) + console.print( + f"{len(local_classes_not_in_dataset)} classes needs to be added to {dataset.identifier}", + style="info", ) - missing_skeletons: List[dt.AnnotationClass] = list(filter(_is_skeleton_class, local_classes_not_in_team)) + missing_skeletons: List[dt.AnnotationClass] = list( + filter(_is_skeleton_class, local_classes_not_in_team) + ) missing_skeleton_names: str = ", ".join(map(_get_skeleton_name, missing_skeletons)) if missing_skeletons: console.print( @@ -428,10 +479,13 @@ def import_annotations( # noqa: C901 return for missing_class in local_classes_not_in_team: dataset.create_annotation_class( - missing_class.name, missing_class.annotation_internal_type or missing_class.annotation_type + missing_class.name, + missing_class.annotation_internal_type or missing_class.annotation_type, ) if local_classes_not_in_dataset: - console.print(f"About to add the following classes to {dataset.identifier}", style="info") + console.print( + f"About to add the following classes to {dataset.identifier}", style="info" + ) for cls in local_classes_not_in_dataset: dataset.add_annotation_class(cls) @@ -446,7 +500,9 @@ def import_annotations( # noqa: C901 remote_classes = build_main_annotations_lookup_table(team_classes) if dataset.version == 1: - console.print("Importing annotations...\nEmpty annotations will be skipped.", style="info") + console.print( + "Importing annotations...\nEmpty annotations will be skipped.", style="info" + ) elif dataset.version == 2 and delete_for_empty: console.print( "Importing annotations...\nEmpty annotation file(s) will clear all existing annotations in matching remote files.", @@ -460,7 +516,9 @@ def import_annotations( # noqa: C901 # Need to re parse the files since we didn't save the annotations in memory for local_path in set(local_file.path for local_file in local_files): # noqa: C401 - imported_files: Union[List[dt.AnnotationFile], dt.AnnotationFile, None] = importer(local_path) + imported_files: Union[ + List[dt.AnnotationFile], dt.AnnotationFile, None + ] = importer(local_path) if imported_files is None: parsed_files = [] elif not isinstance(imported_files, List): @@ -469,19 +527,31 @@ def import_annotations( # noqa: C901 parsed_files = imported_files # remove files missing on the server - missing_files = [missing_file.full_path for missing_file in local_files_missing_remotely] - parsed_files = [parsed_file for parsed_file in parsed_files if parsed_file.full_path not in missing_files] + missing_files = [ + missing_file.full_path for missing_file in local_files_missing_remotely + ] + parsed_files = [ + parsed_file + for parsed_file in parsed_files + if parsed_file.full_path not in missing_files + ] files_to_not_track = [ file_to_track for file_to_track in parsed_files - if not file_to_track.annotations and (not delete_for_empty or dataset.version == 1) + if not file_to_track.annotations + and (not delete_for_empty or dataset.version == 1) ] for file in files_to_not_track: - console.print(f"{file.filename} has no annotations. Skipping upload...", style="warning") + console.print( + f"{file.filename} has no annotations. Skipping upload...", + style="warning", + ) - files_to_track = [file for file in parsed_files if file not in files_to_not_track] + files_to_track = [ + file for file in parsed_files if file not in files_to_not_track + ] if files_to_track: _warn_unsupported_annotations(files_to_track) for parsed_file in track(files_to_track): @@ -506,12 +576,16 @@ def import_annotations( # noqa: C901 ) if errors: - console.print(f"Errors importing {parsed_file.filename}", style="error") + console.print( + f"Errors importing {parsed_file.filename}", style="error" + ) for error in errors: console.print(f"\t{error}", style="error") -def _get_multi_cpu_settings(cpu_limit: Optional[int], cpu_count: int, use_multi_cpu: bool) -> Tuple[int, bool]: +def _get_multi_cpu_settings( + cpu_limit: Optional[int], cpu_count: int, use_multi_cpu: bool +) -> Tuple[int, bool]: if cpu_limit == 1 or cpu_count == 1 or not use_multi_cpu: return 1, False @@ -529,7 +603,9 @@ def _warn_unsupported_annotations(parsed_files: List[AnnotationFile]) -> None: if annotation.annotation_class.annotation_type in UNSUPPORTED_CLASSES: skipped_annotations.append(annotation) if len(skipped_annotations) > 0: - types = set(map(lambda c: c.annotation_class.annotation_type, skipped_annotations)) # noqa: C417 + types = set( + map(lambda c: c.annotation_class.annotation_type, skipped_annotations) + ) # noqa: C417 console.print( f"Import of annotation class types '{', '.join(types)}' is not yet supported. Skipping {len(skipped_annotations)} " + "annotations from '{parsed_file.full_path}'.\n", @@ -538,7 +614,9 @@ def _warn_unsupported_annotations(parsed_files: List[AnnotationFile]) -> None: def _is_skeleton_class(the_class: dt.AnnotationClass) -> bool: - return (the_class.annotation_internal_type or the_class.annotation_type) == "skeleton" + return ( + the_class.annotation_internal_type or the_class.annotation_type + ) == "skeleton" def _get_skeleton_name(skeleton: dt.AnnotationClass) -> str: @@ -546,7 +624,10 @@ def _get_skeleton_name(skeleton: dt.AnnotationClass) -> str: def _handle_subs( - annotation: dt.Annotation, data: dt.DictFreeForm, annotation_class_id: str, attributes: Dict[str, dt.UnknownType] + annotation: dt.Annotation, + data: dt.DictFreeForm, + annotation_class_id: str, + attributes: Dict[str, dt.UnknownType], ) -> dt.DictFreeForm: for sub in annotation.subs: if sub.annotation_type == "text": @@ -554,10 +635,15 @@ def _handle_subs( elif sub.annotation_type == "attributes": attributes_with_key = [] for attr in sub.data: - if annotation_class_id in attributes and attr in attributes[annotation_class_id]: + if ( + annotation_class_id in attributes + and attr in attributes[annotation_class_id] + ): attributes_with_key.append(attributes[annotation_class_id][attr]) else: - print(f"The attribute '{attr}' for class '{annotation.annotation_class.name}' was not imported.") + print( + f"The attribute '{attr}' for class '{annotation.annotation_class.name}' was not imported." + ) data["attributes"] = {"attributes": attributes_with_key} elif sub.annotation_type == "instance_id": @@ -568,10 +654,15 @@ def _handle_subs( return data -def _handle_complex_polygon(annotation: dt.Annotation, data: dt.DictFreeForm) -> dt.DictFreeForm: +def _handle_complex_polygon( + annotation: dt.Annotation, data: dt.DictFreeForm +) -> dt.DictFreeForm: if "complex_polygon" in data: del data["complex_polygon"] - data["polygon"] = {"path": annotation.data["paths"][0], "additional_paths": annotation.data["paths"][1:]} + data["polygon"] = { + "path": annotation.data["paths"][0], + "additional_paths": annotation.data["paths"][1:], + } return data @@ -581,17 +672,25 @@ def _annotators_or_reviewers_to_payload( return [{"email": actor.email, "role": role.value} for actor in actors] -def _handle_reviewers(annotation: dt.Annotation, import_reviewers: bool) -> List[dt.DictFreeForm]: +def _handle_reviewers( + annotation: dt.Annotation, import_reviewers: bool +) -> List[dt.DictFreeForm]: if import_reviewers: if annotation.reviewers: - return _annotators_or_reviewers_to_payload(annotation.reviewers, dt.AnnotationAuthorRole.REVIEWER) + return _annotators_or_reviewers_to_payload( + annotation.reviewers, dt.AnnotationAuthorRole.REVIEWER + ) return [] -def _handle_annotators(annotation: dt.Annotation, import_annotators: bool) -> List[dt.DictFreeForm]: +def _handle_annotators( + annotation: dt.Annotation, import_annotators: bool +) -> List[dt.DictFreeForm]: if import_annotators: if annotation.annotators: - return _annotators_or_reviewers_to_payload(annotation.annotators, dt.AnnotationAuthorRole.ANNOTATOR) + return _annotators_or_reviewers_to_payload( + annotation.annotators, dt.AnnotationAuthorRole.ANNOTATOR + ) return [] @@ -603,7 +702,10 @@ def _get_annotation_data( data = annotation.get_data( only_keyframes=True, post_processing=lambda annotation, data: _handle_subs( - annotation, _handle_complex_polygon(annotation, data), annotation_class_id, attributes + annotation, + _handle_complex_polygon(annotation, data), + annotation_class_id, + attributes, ), ) else: @@ -614,7 +716,9 @@ def _get_annotation_data( return data -def _handle_slot_names(annotation: dt.Annotation, dataset_version: int, default_slot_name: str) -> dt.Annotation: +def _handle_slot_names( + annotation: dt.Annotation, dataset_version: int, default_slot_name: str +) -> dt.Annotation: if not annotation.slot_names and dataset_version > 1: annotation.slot_names.extend([default_slot_name]) @@ -644,18 +748,28 @@ def _import_annotations( serialized_annotations = [] for annotation in annotations: annotation_class = annotation.annotation_class - annotation_type = annotation_class.annotation_internal_type or annotation_class.annotation_type + annotation_type = ( + annotation_class.annotation_internal_type + or annotation_class.annotation_type + ) - if annotation_type not in remote_classes or annotation_class.name not in remote_classes[annotation_type]: + if ( + annotation_type not in remote_classes + or annotation_class.name not in remote_classes[annotation_type] + ): if annotation_type not in remote_classes: logger.warning( f"Annotation type '{annotation_type}' is not in the remote classes, skipping import of annotation '{annotation_class.name}'" ) else: - logger.warning(f"Annotation '{annotation_class.name}' is not in the remote classes, skipping import") + logger.warning( + f"Annotation '{annotation_class.name}' is not in the remote classes, skipping import" + ) continue - annotation_class_id: str = remote_classes[annotation_type][annotation_class.name] + annotation_class_id: str = remote_classes[annotation_type][ + annotation_class.name + ] data = _get_annotation_data(annotation, annotation_class_id, attributes) @@ -695,5 +809,10 @@ def _import_annotations( # mypy: ignore-errors def _console_theme() -> Theme: return Theme( - {"success": "bold green", "warning": "bold yellow", "error": "bold red", "info": "bold deep_sky_blue1"} + { + "success": "bold green", + "warning": "bold yellow", + "error": "bold red", + "info": "bold deep_sky_blue1", + } ) diff --git a/darwin/utils/utils.py b/darwin/utils/utils.py index 1d2204702..10255a8c0 100644 --- a/darwin/utils/utils.py +++ b/darwin/utils/utils.py @@ -214,7 +214,9 @@ def is_project_dir(project_path: Path) -> bool: return (project_path / "releases").exists() and (project_path / "images").exists() -def get_progress_bar(array: List[dt.AnnotationFile], description: Optional[str] = None) -> Iterable[ProgressType]: +def get_progress_bar( + array: List[dt.AnnotationFile], description: Optional[str] = None +) -> Iterable[ProgressType]: """ Get a rich a progress bar for the given list of annotation files. @@ -358,7 +360,9 @@ def persist_client_configuration( api_key=team_config.api_key, datasets_dir=team_config.datasets_dir, ) - config.set_global(api_endpoint=client.url, base_url=client.base_url, default_team=default_team) + config.set_global( + api_endpoint=client.url, base_url=client.base_url, default_team=default_team + ) return config @@ -415,7 +419,9 @@ def attempt_decode(path: Path) -> dict: return data except Exception: continue - raise UnrecognizableFileEncoding(f"Unable to load file {path} with any encodings: {encodings}") + raise UnrecognizableFileEncoding( + f"Unable to load file {path} with any encodings: {encodings}" + ) def load_data_from_file(path: Path) -> Tuple[dict, dt.AnnotationFileVersion]: @@ -424,7 +430,9 @@ def load_data_from_file(path: Path) -> Tuple[dict, dt.AnnotationFileVersion]: return data, version -def parse_darwin_json(path: Path, count: Optional[int] = None) -> Optional[dt.AnnotationFile]: +def parse_darwin_json( + path: Path, count: Optional[int] = None +) -> Optional[dt.AnnotationFile]: """ Parses the given JSON file in v7's darwin proprietary format. Works for images, split frame videos (treated as images) and playback videos. @@ -484,7 +492,9 @@ def stream_darwin_json(path: Path) -> PersistentStreamingJSONObject: return json_stream.load(infile, persistent=True) -def get_image_path_from_stream(darwin_json: PersistentStreamingJSONObject, images_dir: Path) -> Path: +def get_image_path_from_stream( + darwin_json: PersistentStreamingJSONObject, images_dir: Path +) -> Path: """ Returns the path to the image file associated with the given darwin json file (V1 or V2). @@ -501,17 +511,31 @@ def get_image_path_from_stream(darwin_json: PersistentStreamingJSONObject, image Path to the image file. """ try: - return images_dir / (Path(darwin_json["item"]["path"].lstrip("/\\"))) / Path(darwin_json["item"]["name"]) + return ( + images_dir + / (Path(darwin_json["item"]["path"].lstrip("/\\"))) + / Path(darwin_json["item"]["name"]) + ) except KeyError: - return images_dir / (Path(darwin_json["image"]["path"].lstrip("/\\"))) / Path(darwin_json["image"]["filename"]) + return ( + images_dir + / (Path(darwin_json["image"]["path"].lstrip("/\\"))) + / Path(darwin_json["image"]["filename"]) + ) def _parse_darwin_v2(path: Path, data: Dict[str, Any]) -> dt.AnnotationFile: item = data["item"] item_source = item.get("source_info", {}) - slots: List[dt.Slot] = list(filter(None, map(_parse_darwin_slot, item.get("slots", [])))) - annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations(data) - annotation_classes: Set[dt.AnnotationClass] = {annotation.annotation_class for annotation in annotations} + slots: List[dt.Slot] = list( + filter(None, map(_parse_darwin_slot, item.get("slots", []))) + ) + annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations( + data + ) + annotation_classes: Set[dt.AnnotationClass] = { + annotation.annotation_class for annotation in annotations + } if len(slots) == 0: annotation_file = dt.AnnotationFile( @@ -519,7 +543,9 @@ def _parse_darwin_v2(path: Path, data: Dict[str, Any]) -> dt.AnnotationFile: path=path, filename=item["name"], item_id=item.get("source_info", {}).get("item_id", None), - dataset_name=item.get("source_info", {}).get("dataset", {}).get("name", None), + dataset_name=item.get("source_info", {}) + .get("dataset", {}) + .get("name", None), annotation_classes=annotation_classes, annotations=annotations, is_video=False, @@ -540,13 +566,17 @@ def _parse_darwin_v2(path: Path, data: Dict[str, Any]) -> dt.AnnotationFile: path=path, filename=item["name"], item_id=item.get("source_info", {}).get("item_id", None), - dataset_name=item.get("source_info", {}).get("dataset", {}).get("name", None), + dataset_name=item.get("source_info", {}) + .get("dataset", {}) + .get("name", None), annotation_classes=annotation_classes, annotations=annotations, is_video=slot.frame_urls is not None, image_width=slot.width, image_height=slot.height, - image_url=None if len(slot.source_files or []) == 0 else slot.source_files[0]["url"], + image_url=None + if len(slot.source_files or []) == 0 + else slot.source_files[0]["url"], image_thumbnail_url=slot.thumbnail_url, workview_url=item_source.get("workview_url", None), seq=0, @@ -575,9 +605,15 @@ def _parse_darwin_slot(data: Dict[str, Any]) -> dt.Slot: ) -def _parse_darwin_image(path: Path, data: Dict[str, Any], count: Optional[int]) -> dt.AnnotationFile: - annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations(data) - annotation_classes: Set[dt.AnnotationClass] = {annotation.annotation_class for annotation in annotations} +def _parse_darwin_image( + path: Path, data: Dict[str, Any], count: Optional[int] +) -> dt.AnnotationFile: + annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations( + data + ) + annotation_classes: Set[dt.AnnotationClass] = { + annotation.annotation_class for annotation in annotations + } slot = dt.Slot( name=None, @@ -614,12 +650,20 @@ def _parse_darwin_image(path: Path, data: Dict[str, Any], count: Optional[int]) return annotation_file -def _parse_darwin_video(path: Path, data: Dict[str, Any], count: Optional[int]) -> dt.AnnotationFile: - annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations(data) - annotation_classes: Set[dt.AnnotationClass] = {annotation.annotation_class for annotation in annotations} +def _parse_darwin_video( + path: Path, data: Dict[str, Any], count: Optional[int] +) -> dt.AnnotationFile: + annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations( + data + ) + annotation_classes: Set[dt.AnnotationClass] = { + annotation.annotation_class for annotation in annotations + } if "width" not in data["image"] or "height" not in data["image"]: - raise OutdatedDarwinJSONFormat("Missing width/height in video, please re-export") + raise OutdatedDarwinJSONFormat( + "Missing width/height in video, please re-export" + ) slot = dt.Slot( name=None, @@ -665,23 +709,41 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati main_annotation: Optional[dt.Annotation] = None # Darwin JSON 2.0 representation of complex polygons - if "polygon" in annotation and "paths" in annotation["polygon"] and len(annotation["polygon"]["paths"]) > 1: + if ( + "polygon" in annotation + and "paths" in annotation["polygon"] + and len(annotation["polygon"]["paths"]) > 1 + ): bounding_box = annotation.get("bounding_box") paths = annotation["polygon"]["paths"] - main_annotation = dt.make_complex_polygon(name, paths, bounding_box, slot_names=slot_names) + main_annotation = dt.make_complex_polygon( + name, paths, bounding_box, slot_names=slot_names + ) # Darwin JSON 2.0 representation of simple polygons - elif "polygon" in annotation and "paths" in annotation["polygon"] and len(annotation["polygon"]["paths"]) == 1: + elif ( + "polygon" in annotation + and "paths" in annotation["polygon"] + and len(annotation["polygon"]["paths"]) == 1 + ): bounding_box = annotation.get("bounding_box") paths = annotation["polygon"]["paths"] - main_annotation = dt.make_polygon(name, paths[0], bounding_box, slot_names=slot_names) + main_annotation = dt.make_polygon( + name, paths[0], bounding_box, slot_names=slot_names + ) # Darwin JSON 1.0 representation of complex and simple polygons elif "polygon" in annotation: bounding_box = annotation.get("bounding_box") if "additional_paths" in annotation["polygon"]: - paths = [annotation["polygon"]["path"]] + annotation["polygon"]["additional_paths"] - main_annotation = dt.make_complex_polygon(name, paths, bounding_box, slot_names=slot_names) + paths = [annotation["polygon"]["path"]] + annotation["polygon"][ + "additional_paths" + ] + main_annotation = dt.make_complex_polygon( + name, paths, bounding_box, slot_names=slot_names + ) else: - main_annotation = dt.make_polygon(name, annotation["polygon"]["path"], bounding_box, slot_names=slot_names) + main_annotation = dt.make_polygon( + name, annotation["polygon"]["path"], bounding_box, slot_names=slot_names + ) # Darwin JSON 1.0 representation of complex polygons elif "complex_polygon" in annotation: bounding_box = annotation.get("bounding_box") @@ -693,7 +755,9 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati if "additional_paths" in annotation["complex_polygon"]: paths.extend(annotation["complex_polygon"]["additional_paths"]) - main_annotation = dt.make_complex_polygon(name, paths, bounding_box, slot_names=slot_names) + main_annotation = dt.make_complex_polygon( + name, paths, bounding_box, slot_names=slot_names + ) elif "bounding_box" in annotation: bounding_box = annotation["bounding_box"] main_annotation = dt.make_bounding_box( @@ -707,7 +771,9 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati elif "tag" in annotation: main_annotation = dt.make_tag(name, slot_names=slot_names) elif "line" in annotation: - main_annotation = dt.make_line(name, annotation["line"]["path"], slot_names=slot_names) + main_annotation = dt.make_line( + name, annotation["line"]["path"], slot_names=slot_names + ) elif "keypoint" in annotation: main_annotation = dt.make_keypoint( name, @@ -716,11 +782,17 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati slot_names=slot_names, ) elif "ellipse" in annotation: - main_annotation = dt.make_ellipse(name, annotation["ellipse"], slot_names=slot_names) + main_annotation = dt.make_ellipse( + name, annotation["ellipse"], slot_names=slot_names + ) elif "cuboid" in annotation: - main_annotation = dt.make_cuboid(name, annotation["cuboid"], slot_names=slot_names) + main_annotation = dt.make_cuboid( + name, annotation["cuboid"], slot_names=slot_names + ) elif "skeleton" in annotation: - main_annotation = dt.make_skeleton(name, annotation["skeleton"]["nodes"], slot_names=slot_names) + main_annotation = dt.make_skeleton( + name, annotation["skeleton"]["nodes"], slot_names=slot_names + ) elif "table" in annotation: main_annotation = dt.make_table( name, @@ -729,7 +801,9 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati slot_names=slot_names, ) elif "string" in annotation: - main_annotation = dt.make_string(name, annotation["string"]["sources"], slot_names=slot_names) + main_annotation = dt.make_string( + name, annotation["string"]["sources"], slot_names=slot_names + ) elif "graph" in annotation: main_annotation = dt.make_graph( name, @@ -756,19 +830,29 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati if "id" in annotation: main_annotation.id = annotation["id"] if "instance_id" in annotation: - main_annotation.subs.append(dt.make_instance_id(annotation["instance_id"]["value"])) + main_annotation.subs.append( + dt.make_instance_id(annotation["instance_id"]["value"]) + ) if "attributes" in annotation: main_annotation.subs.append(dt.make_attributes(annotation["attributes"])) if "text" in annotation: main_annotation.subs.append(dt.make_text(annotation["text"]["text"])) if "inference" in annotation: - main_annotation.subs.append(dt.make_opaque_sub("inference", annotation["inference"])) + main_annotation.subs.append( + dt.make_opaque_sub("inference", annotation["inference"]) + ) if "directional_vector" in annotation: - main_annotation.subs.append(dt.make_opaque_sub("directional_vector", annotation["directional_vector"])) + main_annotation.subs.append( + dt.make_opaque_sub("directional_vector", annotation["directional_vector"]) + ) if "measures" in annotation: - main_annotation.subs.append(dt.make_opaque_sub("measures", annotation["measures"])) + main_annotation.subs.append( + dt.make_opaque_sub("measures", annotation["measures"]) + ) if "auto_annotate" in annotation: - main_annotation.subs.append(dt.make_opaque_sub("auto_annotate", annotation["auto_annotate"])) + main_annotation.subs.append( + dt.make_opaque_sub("auto_annotate", annotation["auto_annotate"]) + ) if annotation.get("annotators") is not None: main_annotation.annotators = _parse_annotators(annotation["annotators"]) @@ -822,7 +906,9 @@ def _parse_darwin_raster_annotation(annotation: dict) -> Optional[dt.Annotation] slot_names: Optional[List[str]] = parse_slot_names(annotation) if not id or not name or not raster_layer: - raise ValueError("Raster annotation must have an 'id', 'name' and 'raster_layer' field") + raise ValueError( + "Raster annotation must have an 'id', 'name' and 'raster_layer' field" + ) dense_rle, mask_annotation_ids_mapping, total_pixels = ( raster_layer.get("dense_rle", None), @@ -873,9 +959,14 @@ def _parse_darwin_mask_annotation(annotation: dict) -> Optional[dt.Annotation]: def _parse_annotators(annotators: List[Dict[str, Any]]) -> List[dt.AnnotationAuthor]: if not (hasattr(annotators, "full_name") or not hasattr(annotators, "email")): - raise AttributeError("JSON file must contain annotators with 'full_name' and 'email' fields") + raise AttributeError( + "JSON file must contain annotators with 'full_name' and 'email' fields" + ) - return [dt.AnnotationAuthor(annotator["full_name"], annotator["email"]) for annotator in annotators] + return [ + dt.AnnotationAuthor(annotator["full_name"], annotator["email"]) + for annotator in annotators + ] def split_video_annotation(annotation: dt.AnnotationFile) -> List[dt.AnnotationFile]: @@ -908,9 +999,13 @@ def split_video_annotation(annotation: dt.AnnotationFile) -> List[dt.AnnotationF frame_annotations = [] for i, frame_url in enumerate(annotation.frame_urls): annotations = [ - a.frames[i] for a in annotation.annotations if isinstance(a, dt.VideoAnnotation) and i in a.frames + a.frames[i] + for a in annotation.annotations + if isinstance(a, dt.VideoAnnotation) and i in a.frames ] - annotation_classes: Set[dt.AnnotationClass] = {annotation.annotation_class for annotation in annotations} + annotation_classes: Set[dt.AnnotationClass] = { + annotation.annotation_class for annotation in annotations + } filename: str = f"{Path(annotation.filename).stem}/{i:07d}.png" frame_annotations.append( dt.AnnotationFile( @@ -994,7 +1089,9 @@ def convert_polygons_to_sequences( else: list_polygons = cast(List[dt.Polygon], [polygons]) - if not isinstance(list_polygons[0], list) or not isinstance(list_polygons[0][0], dict): + if not isinstance(list_polygons[0], list) or not isinstance( + list_polygons[0][0], dict + ): raise ValueError("Unknown input format") sequences: List[List[Union[int, float]]] = [] @@ -1135,7 +1232,9 @@ def convert_bounding_box_to_xyxy(box: dt.BoundingBox) -> List[float]: return [box["x"], box["y"], x2, y2] -def convert_polygons_to_mask(polygons: List, height: int, width: int, value: Optional[int] = 1) -> np.ndarray: +def convert_polygons_to_mask( + polygons: List, height: int, width: int, value: Optional[int] = 1 +) -> np.ndarray: """ Converts a list of polygons, encoded as a list of dictionaries into an ``nd.array`` mask. @@ -1229,24 +1328,38 @@ def _parse_version(data: dict) -> dt.AnnotationFileVersion: return dt.AnnotationFileVersion(int(major), int(minor), suffix) -def _data_to_annotations(data: Dict[str, Any]) -> List[Union[dt.Annotation, dt.VideoAnnotation]]: +def _data_to_annotations( + data: Dict[str, Any] +) -> List[Union[dt.Annotation, dt.VideoAnnotation]]: raw_image_annotations = filter( lambda annotation: ( - ("frames" not in annotation) and ("raster_layer" not in annotation) and ("mask" not in annotation) + ("frames" not in annotation) + and ("raster_layer" not in annotation) + and ("mask" not in annotation) ), data["annotations"], ) - raw_video_annotations = filter(lambda annotation: "frames" in annotation, data["annotations"]) - raw_raster_annotations = filter(lambda annotation: "raster_layer" in annotation, data["annotations"]) - raw_mask_annotations = filter(lambda annotation: "mask" in annotation, data["annotations"]) - image_annotations: List[dt.Annotation] = list(filter(None, map(_parse_darwin_annotation, raw_image_annotations))) + raw_video_annotations = filter( + lambda annotation: "frames" in annotation, data["annotations"] + ) + raw_raster_annotations = filter( + lambda annotation: "raster_layer" in annotation, data["annotations"] + ) + raw_mask_annotations = filter( + lambda annotation: "mask" in annotation, data["annotations"] + ) + image_annotations: List[dt.Annotation] = list( + filter(None, map(_parse_darwin_annotation, raw_image_annotations)) + ) video_annotations: List[dt.VideoAnnotation] = list( filter(None, map(_parse_darwin_video_annotation, raw_video_annotations)) ) raster_annotations: List[dt.Annotation] = list( filter(None, map(_parse_darwin_raster_annotation, raw_raster_annotations)) ) - mask_annotations: List[dt.Annotation] = list(filter(None, map(_parse_darwin_mask_annotation, raw_mask_annotations))) + mask_annotations: List[dt.Annotation] = list( + filter(None, map(_parse_darwin_mask_annotation, raw_mask_annotations)) + ) return [ *image_annotations, @@ -1267,4 +1380,6 @@ def _supported_schema_versions() -> Dict[Tuple[int, int, str], str]: def _default_schema(version: dt.AnnotationFileVersion) -> Optional[str]: - return _supported_schema_versions().get((version.major, version.minor, version.suffix)) + return _supported_schema_versions().get( + (version.major, version.minor, version.suffix) + ) diff --git a/tests/darwin/exporter/formats/export_coco_test.py b/tests/darwin/exporter/formats/export_coco_test.py index f5e181ce9..ecdac9aed 100644 --- a/tests/darwin/exporter/formats/export_coco_test.py +++ b/tests/darwin/exporter/formats/export_coco_test.py @@ -25,7 +25,9 @@ def test_polygon_include_extras(self, annotation_file: dt.AnnotationFile): categories = {"polygon_class": 1} - assert coco._build_annotation(annotation_file, "test-id", polygon, categories)["extra"] == {"instance_id": 1} + assert coco._build_annotation(annotation_file, "test-id", polygon, categories)[ + "extra" + ] == {"instance_id": 1} def test_bounding_boxes_include_extras(self, annotation_file: dt.AnnotationFile): bbox = dt.Annotation( @@ -36,4 +38,6 @@ def test_bounding_boxes_include_extras(self, annotation_file: dt.AnnotationFile) categories = {"bbox_class": 1} - assert coco._build_annotation(annotation_file, "test-id", bbox, categories)["extra"] == {"instance_id": 1} + assert coco._build_annotation(annotation_file, "test-id", bbox, categories)[ + "extra" + ] == {"instance_id": 1} diff --git a/tests/darwin/importer/formats/import_labelbox_test.py b/tests/darwin/importer/formats/import_labelbox_test.py index 42b97ba7c..f01f7b320 100644 --- a/tests/darwin/importer/formats/import_labelbox_test.py +++ b/tests/darwin/importer/formats/import_labelbox_test.py @@ -277,7 +277,9 @@ def test_it_imports_bbox_images(self, file_path: Path): assert annotation_file.remote_path == "/" assert annotation_file.annotations - bbox_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) + bbox_annotation: Annotation = cast( + Annotation, annotation_file.annotations.pop() + ) assert_bbox(bbox_annotation, 145, 3558, 623, 449) annotation_class = bbox_annotation.annotation_class @@ -376,7 +378,9 @@ def test_it_imports_polygon_images(self, file_path: Path): assert annotation_file.annotations - polygon_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) + polygon_annotation: Annotation = cast( + Annotation, annotation_file.annotations.pop() + ) assert_polygon( polygon_annotation, [ @@ -420,7 +424,9 @@ def test_it_imports_point_images(self, file_path: Path): assert annotation_file.annotations - point_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) + point_annotation: Annotation = cast( + Annotation, annotation_file.annotations.pop() + ) assert_point(point_annotation, {"x": 342.93, "y": 914.233}) annotation_class = point_annotation.annotation_class @@ -461,7 +467,9 @@ def test_it_imports_polyline_images(self, file_path: Path): assert annotation_file.annotations - line_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) + line_annotation: Annotation = cast( + Annotation, annotation_file.annotations.pop() + ) assert_line( line_annotation, [ @@ -604,7 +612,9 @@ def test_it_imports_classification_from_radio_buttons(self, file_path: Path): tag_annotation: Annotation = cast(Annotation, annotation_file.annotations[1]) tag_annotation_class = tag_annotation.annotation_class - assert_annotation_class(tag_annotation_class, "r_c_or_l_side_radiograph:right", "tag") + assert_annotation_class( + tag_annotation_class, "r_c_or_l_side_radiograph:right", "tag" + ) def test_it_imports_classification_from_checklist(self, file_path: Path): json: str = """ @@ -648,11 +658,15 @@ def test_it_imports_classification_from_checklist(self, file_path: Path): tag_annotation_1: Annotation = cast(Annotation, annotation_file.annotations[1]) tag_annotation_class_1 = tag_annotation_1.annotation_class - assert_annotation_class(tag_annotation_class_1, "r_c_or_l_side_radiograph:right", "tag") + assert_annotation_class( + tag_annotation_class_1, "r_c_or_l_side_radiograph:right", "tag" + ) tag_annotation_2: Annotation = cast(Annotation, annotation_file.annotations[2]) tag_annotation_class_2 = tag_annotation_2.annotation_class - assert_annotation_class(tag_annotation_class_2, "r_c_or_l_side_radiograph:left", "tag") + assert_annotation_class( + tag_annotation_class_2, "r_c_or_l_side_radiograph:left", "tag" + ) def test_it_imports_classification_from_free_text(self, file_path: Path): json: str = """ @@ -695,7 +709,9 @@ def test_it_imports_classification_from_free_text(self, file_path: Path): assert_annotation_class(point_annotation_class, "Shark", "keypoint") tag_annotation: Annotation = cast(Annotation, annotation_file.annotations[1]) - assert_annotation_class(tag_annotation.annotation_class, "r_c_or_l_side_radiograph", "tag") + assert_annotation_class( + tag_annotation.annotation_class, "r_c_or_l_side_radiograph", "tag" + ) assert_subannotations( tag_annotation.subs, [SubAnnotation(annotation_type="text", data="righ side")], @@ -743,7 +759,9 @@ def assert_annotation_class( assert annotation_class.annotation_internal_type == internal_type -def assert_subannotations(actual_subs: List[SubAnnotation], expected_subs: List[SubAnnotation]) -> None: +def assert_subannotations( + actual_subs: List[SubAnnotation], expected_subs: List[SubAnnotation] +) -> None: assert actual_subs for actual_sub in actual_subs: for expected_sub in expected_subs: diff --git a/tests/darwin/importer/formats/import_nifti_test.py b/tests/darwin/importer/formats/import_nifti_test.py index d5a5769ca..8527d153d 100644 --- a/tests/darwin/importer/formats/import_nifti_test.py +++ b/tests/darwin/importer/formats/import_nifti_test.py @@ -21,7 +21,13 @@ def test_image_annotation_nifti_import_single_slot(team_slug: str): with ZipFile("tests/data.zip") as zfile: zfile.extractall(tmpdir) label_path = ( - Path(tmpdir) / team_slug / "nifti" / "releases" / "latest" / "annotations" / "vol0_brain.nii.gz" + Path(tmpdir) + / team_slug + / "nifti" + / "releases" + / "latest" + / "annotations" + / "vol0_brain.nii.gz" ) input_dict = { "data": [ @@ -34,17 +40,24 @@ def test_image_annotation_nifti_import_single_slot(team_slug: str): ] } upload_json = Path(tmpdir) / "annotations.json" - upload_json.write_text(json.dumps(input_dict, indent=4, sort_keys=True, default=str)) + upload_json.write_text( + json.dumps(input_dict, indent=4, sort_keys=True, default=str) + ) annotation_files = parse_path(path=upload_json) annotation_file = annotation_files[0] - output_json_string = json.loads(serialise_annotation_file(annotation_file, as_dict=False)) + output_json_string = json.loads( + serialise_annotation_file(annotation_file, as_dict=False) + ) expected_json_string = json.load( open( Path(tmpdir) / team_slug / "nifti" / "vol0_annotation_file.json", "r", ) ) - assert output_json_string["annotations"][0]["frames"] == expected_json_string["annotations"][0]["frames"] + assert ( + output_json_string["annotations"][0]["frames"] + == expected_json_string["annotations"][0]["frames"] + ) def test_image_annotation_nifti_import_multi_slot(team_slug: str): @@ -52,7 +65,13 @@ def test_image_annotation_nifti_import_multi_slot(team_slug: str): with ZipFile("tests/data.zip") as zfile: zfile.extractall(tmpdir) label_path = ( - Path(tmpdir) / team_slug / "nifti" / "releases" / "latest" / "annotations" / "vol0_brain.nii.gz" + Path(tmpdir) + / team_slug + / "nifti" + / "releases" + / "latest" + / "annotations" + / "vol0_brain.nii.gz" ) input_dict = { "data": [ @@ -67,17 +86,27 @@ def test_image_annotation_nifti_import_multi_slot(team_slug: str): ] } upload_json = Path(tmpdir) / "annotations.json" - upload_json.write_text(json.dumps(input_dict, indent=4, sort_keys=True, default=str)) + upload_json.write_text( + json.dumps(input_dict, indent=4, sort_keys=True, default=str) + ) annotation_files = parse_path(path=upload_json) annotation_file = annotation_files[0] - output_json_string = json.loads(serialise_annotation_file(annotation_file, as_dict=False)) + output_json_string = json.loads( + serialise_annotation_file(annotation_file, as_dict=False) + ) expected_json_string = json.load( open( - Path(tmpdir) / team_slug / "nifti" / "vol0_annotation_file_multi_slot.json", + Path(tmpdir) + / team_slug + / "nifti" + / "vol0_annotation_file_multi_slot.json", "r", ) ) - assert output_json_string["annotations"][0]["frames"] == expected_json_string["annotations"][0]["frames"] + assert ( + output_json_string["annotations"][0]["frames"] + == expected_json_string["annotations"][0]["frames"] + ) def test_image_annotation_nifti_import_incorrect_number_slot(team_slug: str): @@ -85,7 +114,13 @@ def test_image_annotation_nifti_import_incorrect_number_slot(team_slug: str): with ZipFile("tests/data.zip") as zfile: zfile.extractall(tmpdir) label_path = ( - Path(tmpdir) / team_slug / "nifti" / "releases" / "latest" / "annotations" / "vol0_brain.nii.gz" + Path(tmpdir) + / team_slug + / "nifti" + / "releases" + / "latest" + / "annotations" + / "vol0_brain.nii.gz" ) input_dict = { "data": [ @@ -100,12 +135,16 @@ def test_image_annotation_nifti_import_incorrect_number_slot(team_slug: str): ] } upload_json = Path(tmpdir) / "annotations.json" - upload_json.write_text(json.dumps(input_dict, indent=4, sort_keys=True, default=str)) + upload_json.write_text( + json.dumps(input_dict, indent=4, sort_keys=True, default=str) + ) with pytest.raises(Exception): annotation_files = parse_path(path=upload_json) -def serialise_annotation_file(annotation_file: AnnotationFile, as_dict) -> Union[str, dict]: +def serialise_annotation_file( + annotation_file: AnnotationFile, as_dict +) -> Union[str, dict]: """ Serialises an ``AnnotationFile`` into a string. @@ -123,9 +162,12 @@ def serialise_annotation_file(annotation_file: AnnotationFile, as_dict) -> Union "path": str(annotation_file.path), "filename": annotation_file.filename, "annotation_classes": [ - serialise_annotation_class(ac, as_dict=True) for ac in annotation_file.annotation_classes + serialise_annotation_class(ac, as_dict=True) + for ac in annotation_file.annotation_classes + ], + "annotations": [ + serialise_annotation(a, as_dict=True) for a in annotation_file.annotations ], - "annotations": [serialise_annotation(a, as_dict=True) for a in annotation_file.annotations], "is_video": annotation_file.is_video, "image_width": annotation_file.image_width, "image_height": annotation_file.image_height, @@ -144,7 +186,9 @@ def serialise_annotation_file(annotation_file: AnnotationFile, as_dict) -> Union return output_dict if as_dict else json_string -def serialise_annotation(annotation: Union[Annotation, VideoAnnotation], as_dict) -> Union[str, dict]: +def serialise_annotation( + annotation: Union[Annotation, VideoAnnotation], as_dict +) -> Union[str, dict]: if isinstance(annotation, VideoAnnotation): return serialise_video_annotation(annotation, as_dict=as_dict) elif isinstance(annotation, Annotation): @@ -168,7 +212,9 @@ def serialise_general_annotation(annotation: Annotation, as_dict) -> Union[str, return output_dict if as_dict else json_string -def serialise_video_annotation(video_annotation: VideoAnnotation, as_dict: bool = True) -> Union[str, dict]: +def serialise_video_annotation( + video_annotation: VideoAnnotation, as_dict: bool = True +) -> Union[str, dict]: data = video_annotation.get_data() output_dict = { "annotation_class": video_annotation.annotation_class.name, @@ -183,7 +229,9 @@ def serialise_video_annotation(video_annotation: VideoAnnotation, as_dict: bool return output_dict if as_dict else json_string -def serialise_annotation_class(annotation_class: AnnotationClass, as_dict: bool = True) -> Union[str, dict]: +def serialise_annotation_class( + annotation_class: AnnotationClass, as_dict: bool = True +) -> Union[str, dict]: output_dict = { "name": annotation_class.name, "annotation_type": annotation_class.annotation_type, @@ -193,7 +241,9 @@ def serialise_annotation_class(annotation_class: AnnotationClass, as_dict: bool return output_dict if as_dict else json_string -def serialise_sub_annotation(sub_annotation: SubAnnotation, as_dict: bool = True) -> Union[str, dict]: +def serialise_sub_annotation( + sub_annotation: SubAnnotation, as_dict: bool = True +) -> Union[str, dict]: output_dict = { "type": sub_annotation.annotation_type, "data": sub_annotation.data, @@ -208,7 +258,9 @@ def serialise_sub_annotation(sub_annotation: SubAnnotation, as_dict: bool = True if __name__ == "__main__": - args = argparse.ArgumentParser(description="Update the serialisation of AnnotationFile with the current version.") + args = argparse.ArgumentParser( + description="Update the serialisation of AnnotationFile with the current version." + ) input_json_string: str = """ { "data": [ @@ -233,7 +285,11 @@ def serialise_sub_annotation(sub_annotation: SubAnnotation, as_dict: bool = True annotation_file = annotation_files[0] output_json_string = serialise_annotation_file(annotation_file, as_dict=False) with open( - Path("tests") / "v7" / "v7-darwin-json-v1" / "nifti" / "vol0_annotation_file_multi_slot.json", + Path("tests") + / "v7" + / "v7-darwin-json-v1" + / "nifti" + / "vol0_annotation_file_multi_slot.json", "w", ) as f: f.write(output_json_string) diff --git a/tests/darwin/importer/formats/import_superannotate_test.py b/tests/darwin/importer/formats/import_superannotate_test.py index b65155a41..2b28ada40 100644 --- a/tests/darwin/importer/formats/import_superannotate_test.py +++ b/tests/darwin/importer/formats/import_superannotate_test.py @@ -53,7 +53,9 @@ def test_raises_if_folder_has_no_classes_file(self, annotations_file_path: Path) assert "Folder must contain a 'classes.json'" in str(error.value) - def test_returns_empty_file_if_there_are_no_annotations(self, annotations_file_path: Path, classes_file_path: Path): + def test_returns_empty_file_if_there_are_no_annotations( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [], @@ -76,7 +78,9 @@ def test_returns_empty_file_if_there_are_no_annotations(self, annotations_file_p remote_path="/", ) - def test_raises_if_annotation_has_no_type(self, annotations_file_path: Path, classes_file_path: Path): + def test_raises_if_annotation_has_no_type( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -100,7 +104,9 @@ def test_raises_if_annotation_has_no_type(self, annotations_file_path: Path, cla assert "'type' is a required property" in str(error.value) - def test_raises_if_annotation_has_no_class_id(self, annotations_file_path: Path, classes_file_path: Path): + def test_raises_if_annotation_has_no_class_id( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -125,7 +131,9 @@ def test_raises_if_annotation_has_no_class_id(self, annotations_file_path: Path, assert "'classId' is a required property" in str(error.value) - def test_raises_if_metadata_is_missing(self, annotations_file_path: Path, classes_file_path: Path): + def test_raises_if_metadata_is_missing( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -147,7 +155,9 @@ def test_raises_if_metadata_is_missing(self, annotations_file_path: Path, classe assert "'metadata' is a required property" in str(error.value) - def test_raises_if_metadata_is_missing_name(self, annotations_file_path: Path, classes_file_path: Path): + def test_raises_if_metadata_is_missing_name( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -169,7 +179,9 @@ def test_raises_if_metadata_is_missing_name(self, annotations_file_path: Path, c assert "'name' is a required property" in str(error.value) - def test_raises_if_point_has_missing_coordinate(self, annotations_file_path: Path, classes_file_path: Path): + def test_raises_if_point_has_missing_coordinate( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -194,7 +206,9 @@ def test_raises_if_point_has_missing_coordinate(self, annotations_file_path: Pat error_str = str(error.value) assert all(["point" in error_str, "ellipse" in error_str]) - def test_imports_point_vectors(self, annotations_file_path: Path, classes_file_path: Path): + def test_imports_point_vectors( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -222,13 +236,17 @@ def test_imports_point_vectors(self, annotations_file_path: Path, classes_file_p assert annotation_file.annotations - point_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) + point_annotation: Annotation = cast( + Annotation, annotation_file.annotations.pop() + ) assert_point(point_annotation, {"x": 1.93, "y": 0.233}) annotation_class = point_annotation.annotation_class assert_annotation_class(annotation_class, "Person-point", "keypoint") - def test_raises_if_ellipse_has_missing_coordinate(self, annotations_file_path: Path, classes_file_path: Path): + def test_raises_if_ellipse_has_missing_coordinate( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -248,7 +266,9 @@ def test_raises_if_ellipse_has_missing_coordinate(self, annotations_file_path: P error_str = str(error.value) assert all(["ellipse" in error_str, "point" in error_str]) - def test_imports_ellipse_vectors(self, annotations_file_path: Path, classes_file_path: Path): + def test_imports_ellipse_vectors( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -285,7 +305,9 @@ def test_imports_ellipse_vectors(self, annotations_file_path: Path, classes_file assert annotation_file.annotations - ellipse_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) + ellipse_annotation: Annotation = cast( + Annotation, annotation_file.annotations.pop() + ) assert_ellipse( ellipse_annotation, { @@ -298,7 +320,9 @@ def test_imports_ellipse_vectors(self, annotations_file_path: Path, classes_file annotation_class = ellipse_annotation.annotation_class assert_annotation_class(annotation_class, "Person-ellipse", "ellipse") - def test_raises_if_cuboid_has_missing_point(self, annotations_file_path: Path, classes_file_path: Path): + def test_raises_if_cuboid_has_missing_point( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -325,7 +349,9 @@ def test_raises_if_cuboid_has_missing_point(self, annotations_file_path: Path, c error_str = str(error.value) assert all(["cuboid" in error_str, "point" in error_str]) - def test_imports_cuboid_vectors(self, annotations_file_path: Path, classes_file_path: Path): + def test_imports_cuboid_vectors( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -363,7 +389,9 @@ def test_imports_cuboid_vectors(self, annotations_file_path: Path, classes_file_ assert annotation_file.annotations - cuboid_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) + cuboid_annotation: Annotation = cast( + Annotation, annotation_file.annotations.pop() + ) assert_cuboid( cuboid_annotation, { @@ -375,7 +403,9 @@ def test_imports_cuboid_vectors(self, annotations_file_path: Path, classes_file_ annotation_class = cuboid_annotation.annotation_class assert_annotation_class(annotation_class, "Person-cuboid", "cuboid") - def test_raises_if_polygon_has_missing_points(self, annotations_file_path: Path, classes_file_path: Path): + def test_raises_if_polygon_has_missing_points( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -400,7 +430,9 @@ def test_raises_if_polygon_has_missing_points(self, annotations_file_path: Path, error_str = str(error.value) assert all(["polygon" in error_str, "point" in error_str]) - def test_imports_polygon_vectors(self, annotations_file_path: Path, classes_file_path: Path): + def test_imports_polygon_vectors( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -433,7 +465,9 @@ def test_imports_polygon_vectors(self, annotations_file_path: Path, classes_file assert annotation_file.annotations - polygon_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) + polygon_annotation: Annotation = cast( + Annotation, annotation_file.annotations.pop() + ) assert_polygon( polygon_annotation, [ @@ -446,7 +480,9 @@ def test_imports_polygon_vectors(self, annotations_file_path: Path, classes_file annotation_class = polygon_annotation.annotation_class assert_annotation_class(annotation_class, "Person-polygon", "polygon") - def test_raises_if_polyline_has_missing_points(self, annotations_file_path: Path, classes_file_path: Path): + def test_raises_if_polyline_has_missing_points( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -471,7 +507,9 @@ def test_raises_if_polyline_has_missing_points(self, annotations_file_path: Path error_str = str(error.value) assert all(["polyline" in error_str, "point" in error_str]) - def test_imports_polyline_vectors(self, annotations_file_path: Path, classes_file_path: Path): + def test_imports_polyline_vectors( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -504,7 +542,9 @@ def test_imports_polyline_vectors(self, annotations_file_path: Path, classes_fil assert annotation_file.annotations - line_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) + line_annotation: Annotation = cast( + Annotation, annotation_file.annotations.pop() + ) assert_line( line_annotation, [ @@ -517,7 +557,9 @@ def test_imports_polyline_vectors(self, annotations_file_path: Path, classes_fil annotation_class = line_annotation.annotation_class assert_annotation_class(annotation_class, "Person-polyline", "line") - def test_raises_if_bbox_has_missing_points(self, annotations_file_path: Path, classes_file_path: Path): + def test_raises_if_bbox_has_missing_points( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -541,7 +583,9 @@ def test_raises_if_bbox_has_missing_points(self, annotations_file_path: Path, cl error_str = str(error.value) assert all(["bbox" in error_str, "point" in error_str]) - def test_imports_bbox_vectors(self, annotations_file_path: Path, classes_file_path: Path): + def test_imports_bbox_vectors( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -574,13 +618,17 @@ def test_imports_bbox_vectors(self, annotations_file_path: Path, classes_file_pa assert annotation_file.annotations - bbox_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) + bbox_annotation: Annotation = cast( + Annotation, annotation_file.annotations.pop() + ) assert_bbox(bbox_annotation, 1642.9, 516.5, 217.5, 277.1) annotation_class = bbox_annotation.annotation_class assert_annotation_class(annotation_class, "Person-bbox", "bounding_box") - def test_raises_if_an_attributes_is_missing(self, annotations_file_path: Path, classes_file_path: Path): + def test_raises_if_an_attributes_is_missing( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -621,7 +669,9 @@ def test_raises_if_an_attributes_is_missing(self, annotations_file_path: Path, c error_str = str(error.value) assert all(["type" in error_str, "bbox" in error_str]) - def test_raises_if_an_attribute_from_a_group_is_missing(self, annotations_file_path: Path, classes_file_path: Path): + def test_raises_if_an_attribute_from_a_group_is_missing( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -660,9 +710,13 @@ def test_raises_if_an_attribute_from_a_group_is_missing(self, annotations_file_p with pytest.raises(ValueError) as error: parse_path(annotations_file_path) - assert "No attribute data found for {'id': 2, 'groupId': 1}." in str(error.value) + assert "No attribute data found for {'id': 2, 'groupId': 1}." in str( + error.value + ) - def test_imports_attributes(self, annotations_file_path: Path, classes_file_path: Path): + def test_imports_attributes( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -717,7 +771,9 @@ def test_imports_attributes(self, annotations_file_path: Path, classes_file_path assert annotation_file.annotations - bbox_annotation: Annotation = cast(Annotation, annotation_file.annotations.pop()) + bbox_annotation: Annotation = cast( + Annotation, annotation_file.annotations.pop() + ) assert_bbox(bbox_annotation, 1642.9, 516.5, 217.5, 277.1) annotation_class = bbox_annotation.annotation_class @@ -728,7 +784,9 @@ def test_imports_attributes(self, annotations_file_path: Path, classes_file_path [SubAnnotation("attributes", ["Sex:Female", "Emotion:Smiling"])], ) - def test_raises_if_tags_is_missing(self, annotations_file_path: Path, classes_file_path: Path): + def test_raises_if_tags_is_missing( + self, annotations_file_path: Path, classes_file_path: Path + ): annotations_json: str = """ { "instances": [ @@ -882,7 +940,9 @@ def assert_annotation_class( assert annotation_class.annotation_internal_type == internal_type -def assert_subannotations(actual_subs: List[SubAnnotation], expected_subs: List[SubAnnotation]) -> None: +def assert_subannotations( + actual_subs: List[SubAnnotation], expected_subs: List[SubAnnotation] +) -> None: assert actual_subs for actual_sub in actual_subs: for expected_sub in expected_subs: From dc2ac423378717efd135391afdcb9c1e2854f538 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Mon, 27 Nov 2023 15:37:25 +0100 Subject: [PATCH 60/71] ruff and black --- darwin/importer/importer.py | 4 +--- tests/darwin/importer/formats/import_nifti_test.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/darwin/importer/importer.py b/darwin/importer/importer.py index 06b5f5af7..d2f02f807 100644 --- a/darwin/importer/importer.py +++ b/darwin/importer/importer.py @@ -603,9 +603,7 @@ def _warn_unsupported_annotations(parsed_files: List[AnnotationFile]) -> None: if annotation.annotation_class.annotation_type in UNSUPPORTED_CLASSES: skipped_annotations.append(annotation) if len(skipped_annotations) > 0: - types = set( - map(lambda c: c.annotation_class.annotation_type, skipped_annotations) - ) # noqa: C417 + types = {c.annotation_class.annotation_type for c in skipped_annotations} # noqa: C417 console.print( f"Import of annotation class types '{', '.join(types)}' is not yet supported. Skipping {len(skipped_annotations)} " + "annotations from '{parsed_file.full_path}'.\n", diff --git a/tests/darwin/importer/formats/import_nifti_test.py b/tests/darwin/importer/formats/import_nifti_test.py index 8527d153d..666c46878 100644 --- a/tests/darwin/importer/formats/import_nifti_test.py +++ b/tests/darwin/importer/formats/import_nifti_test.py @@ -139,7 +139,7 @@ def test_image_annotation_nifti_import_incorrect_number_slot(team_slug: str): json.dumps(input_dict, indent=4, sort_keys=True, default=str) ) with pytest.raises(Exception): - annotation_files = parse_path(path=upload_json) + parse_path(path=upload_json) def serialise_annotation_file( From 7175c1209d2cf6330f99aa8eaad49764707d92cc Mon Sep 17 00:00:00 2001 From: Christoffer Date: Mon, 27 Nov 2023 15:40:46 +0100 Subject: [PATCH 61/71] removed settings.json changes --- .vscode/settings.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 24c7a8950..28f99b8d9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,8 +20,4 @@ "python.analysis.autoImportCompletions": true, "python.testing.pytestEnabled": true, "python.analysis.typeCheckingMode": "basic", - "python.testing.pytestArgs": [ - "e2e_tests" - ], - "python.testing.unittestEnabled": false, } \ No newline at end of file From f06759cf835e70e21fa722063e99b05a26bee355 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Mon, 27 Nov 2023 15:43:32 +0100 Subject: [PATCH 62/71] reverting to non-v2 data.zip --- tests/data.zip | Bin 487802 -> 334089 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/data.zip b/tests/data.zip index 570916f2aa30c47a499e9f4fdce6b1778bad183d..3fd227be317aeec7095a964e26e0baf5dd07a0b0 100644 GIT binary patch delta 51372 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 H2|fNF+BgH= delta 123427 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 z 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 Date: Mon, 27 Nov 2023 15:45:24 +0100 Subject: [PATCH 63/71] merged darwin_v1_test changes from origin master --- darwin/exporter/formats/darwin_1_0.py | 42 +++---------- .../formats/export_darwin_1_0_test.py | 61 +++---------------- 2 files changed, 19 insertions(+), 84 deletions(-) diff --git a/darwin/exporter/formats/darwin_1_0.py b/darwin/exporter/formats/darwin_1_0.py index 4adc6b3ad..f78af61e8 100644 --- a/darwin/exporter/formats/darwin_1_0.py +++ b/darwin/exporter/formats/darwin_1_0.py @@ -45,23 +45,17 @@ def _export_file(annotation_file: AnnotationFile, _: int, output_dir: Path) -> N try: output: DictFreeForm = _build_json(annotation_file) except Exception as e: - raise ExportException_CouldNotBuildOutput( - f"Could not build output for {annotation_file.path}" - ) from e + raise ExportException_CouldNotBuildOutput(f"Could not build output for {annotation_file.path}") from e try: with open(output_file_path, "w") as f: op = json.dumps( output, - option=json.OPT_INDENT_2 - | json.OPT_SERIALIZE_NUMPY - | json.OPT_NON_STR_KEYS, + option=json.OPT_INDENT_2 | json.OPT_SERIALIZE_NUMPY | json.OPT_NON_STR_KEYS, ).decode("utf-8") f.write(op) except Exception as e: - raise ExportException_CouldNotWriteFile( - f"Could not write output for {annotation_file.path}" - ) from e + raise ExportException_CouldNotWriteFile(f"Could not write output for {annotation_file.path}") from e def _build_json(annotation_file: AnnotationFile) -> DictFreeForm: @@ -136,17 +130,11 @@ def _build_sub_annotation(sub: SubAnnotation) -> DictFreeForm: def _build_authorship(annotation: Union[VideoAnnotation, Annotation]) -> DictFreeForm: annotators = {} if annotation.annotators: - annotators = { - "annotators": [ - _build_author(annotator) for annotator in annotation.annotators - ] - } + annotators = {"annotators": [_build_author(annotator) for annotator in annotation.annotators]} reviewers = {} if annotation.reviewers: - reviewers = { - "annotators": [_build_author(reviewer) for reviewer in annotation.reviewers] - } + reviewers = {"annotators": [_build_author(reviewer) for reviewer in annotation.reviewers]} return {**annotators, **reviewers} @@ -155,9 +143,7 @@ def _build_video_annotation(annotation: VideoAnnotation) -> DictFreeForm: return { **annotation.get_data( only_keyframes=False, - post_processing=lambda annotation, _: _build_image_annotation( - annotation, skip_slots=True - ), + post_processing=lambda annotation, _: _build_image_annotation(annotation, skip_slots=True), ), "name": annotation.annotation_class.name, "slot_names": annotation.slot_names, @@ -165,9 +151,7 @@ def _build_video_annotation(annotation: VideoAnnotation) -> DictFreeForm: } -def _build_image_annotation( - annotation: Annotation, skip_slots: bool = False -) -> DictFreeForm: +def _build_image_annotation(annotation: Annotation, skip_slots: bool = False) -> DictFreeForm: json_subs = {} for sub in annotation.subs: json_subs.update(_build_sub_annotation(sub)) @@ -185,9 +169,7 @@ def _build_image_annotation( return {**base_json, "slot_names": annotation.slot_names} -def _build_legacy_annotation_data( - annotation_class: AnnotationClass, data: DictFreeForm -) -> DictFreeForm: +def _build_legacy_annotation_data(annotation_class: AnnotationClass, data: DictFreeForm) -> DictFreeForm: v1_data = {} polygon_annotation_mappings = {"complex_polygon": "paths", "polygon": "path"} @@ -250,9 +232,7 @@ def build_image_annotation(annotation_file: AnnotationFile) -> Dict[str, Any]: annotations: List[Dict[str, Any]] = [] for annotation in annotation_file.annotations: payload = { - annotation.annotation_class.annotation_type: _build_annotation_data( - annotation - ), + annotation.annotation_class.annotation_type: _build_annotation_data(annotation), "name": annotation.annotation_class.name, } @@ -280,8 +260,6 @@ def _build_annotation_data(annotation: Annotation) -> Dict[str, Any]: return {"path": annotation.data["paths"]} if annotation.annotation_class.annotation_type == "polygon": - return dict( - filter(lambda item: item[0] != "bounding_box", annotation.data.items()) - ) + return dict(filter(lambda item: item[0] != "bounding_box", annotation.data.items())) return dict(annotation.data) diff --git a/tests/darwin/exporter/formats/export_darwin_1_0_test.py b/tests/darwin/exporter/formats/export_darwin_1_0_test.py index 25816d7be..dac3a2806 100644 --- a/tests/darwin/exporter/formats/export_darwin_1_0_test.py +++ b/tests/darwin/exporter/formats/export_darwin_1_0_test.py @@ -119,12 +119,8 @@ def test_complex_polygon(self): ], ] - annotation_class = dt.AnnotationClass( - name="test", annotation_type="complex_polygon" - ) - annotation = dt.Annotation( - annotation_class=annotation_class, data={"paths": polygon_path}, subs=[] - ) + annotation_class = dt.AnnotationClass(name="test", annotation_type="complex_polygon") + annotation = dt.Annotation(annotation_class=annotation_class, data={"paths": polygon_path}, subs=[]) annotation_file = dt.AnnotationFile( path=Path("test.json"), @@ -229,9 +225,7 @@ def test_complex_polygon_with_bbox(self): bounding_box = {"x": 557.66, "y": 428.98, "w": 160.76, "h": 315.3} - annotation_class = dt.AnnotationClass( - name="test", annotation_type="complex_polygon" - ) + annotation_class = dt.AnnotationClass(name="test", annotation_type="complex_polygon") annotation = dt.Annotation( annotation_class=annotation_class, data={"paths": polygon_path, "bounding_box": bounding_box}, @@ -273,12 +267,8 @@ def test_complex_polygon_with_bbox(self): def test_bounding_box(self): bounding_box_data = {"x": 100, "y": 150, "w": 50, "h": 30} - annotation_class = dt.AnnotationClass( - name="bbox_test", annotation_type="bounding_box" - ) - annotation = dt.Annotation( - annotation_class=annotation_class, data=bounding_box_data, subs=[] - ) + annotation_class = dt.AnnotationClass(name="bbox_test", annotation_type="bounding_box") + annotation = dt.Annotation(annotation_class=annotation_class, data=bounding_box_data, subs=[]) annotation_file = dt.AnnotationFile( path=Path("test.json"), @@ -315,9 +305,7 @@ def test_bounding_box(self): def test_tags(self): tag_data = "sample_tag" annotation_class = dt.AnnotationClass(name="tag_test", annotation_type="tag") - annotation = dt.Annotation( - annotation_class=annotation_class, data=tag_data, subs=[] - ) + annotation = dt.Annotation(annotation_class=annotation_class, data=tag_data, subs=[]) annotation_file = dt.AnnotationFile( path=Path("test.json"), @@ -355,13 +343,7 @@ def test_polygon_annotation_file_with_bbox(self): annotation_class = dt.AnnotationClass(name="test", annotation_type="polygon") annotation = dt.Annotation( -<<<<<<< .merge_file_o0MlJA - annotation_class=annotation_class, - data={"path": polygon_path, "bounding_box": bounding_box}, - subs=[], -======= annotation_class=annotation_class, data={"path": polygon_path, "bounding_box": bounding_box}, subs=[] ->>>>>>> .merge_file_SwKf10 ) annotation_file = dt.AnnotationFile( @@ -387,16 +369,7 @@ def test_polygon_annotation_file_with_bbox(self): "workview_url": None, }, "annotations": [ -<<<<<<< .merge_file_o0MlJA - { - "polygon": {"path": polygon_path}, - "name": "test", - "slot_names": [], - "bounding_box": bounding_box, - } -======= {"polygon": {"path": polygon_path}, "name": "test", "slot_names": [], "bounding_box": bounding_box} ->>>>>>> .merge_file_SwKf10 ], "dataset": "None", } @@ -423,19 +396,9 @@ def test_complex_polygon_with_bbox(self): bounding_box = {"x": 557.66, "y": 428.98, "w": 160.76, "h": 315.3} -<<<<<<< .merge_file_o0MlJA - annotation_class = dt.AnnotationClass( - name="test", annotation_type="complex_polygon" - ) - annotation = dt.Annotation( - annotation_class=annotation_class, - data={"paths": polygon_path, "bounding_box": bounding_box}, - subs=[], -======= annotation_class = dt.AnnotationClass(name="test", annotation_type="complex_polygon") annotation = dt.Annotation( annotation_class=annotation_class, data={"paths": polygon_path, "bounding_box": bounding_box}, subs=[] ->>>>>>> .merge_file_SwKf10 ) annotation_file = dt.AnnotationFile( @@ -473,12 +436,8 @@ def test_complex_polygon_with_bbox(self): def test_bounding_box(self): bounding_box_data = {"x": 100, "y": 150, "w": 50, "h": 30} - annotation_class = dt.AnnotationClass( - name="bbox_test", annotation_type="bounding_box" - ) - annotation = dt.Annotation( - annotation_class=annotation_class, data=bounding_box_data, subs=[] - ) + annotation_class = dt.AnnotationClass(name="bbox_test", annotation_type="bounding_box") + annotation = dt.Annotation(annotation_class=annotation_class, data=bounding_box_data, subs=[]) annotation_file = dt.AnnotationFile( path=Path("test.json"), @@ -515,9 +474,7 @@ def test_bounding_box(self): def test_tags(self): tag_data = "sample_tag" annotation_class = dt.AnnotationClass(name="tag_test", annotation_type="tag") - annotation = dt.Annotation( - annotation_class=annotation_class, data=tag_data, subs=[] - ) + annotation = dt.Annotation(annotation_class=annotation_class, data=tag_data, subs=[]) annotation_file = dt.AnnotationFile( path=Path("test.json"), From 0153c78cf67daa9ab9c20ba5f66b6eccd742a99e Mon Sep 17 00:00:00 2001 From: Christoffer Date: Mon, 27 Nov 2023 19:10:41 +0100 Subject: [PATCH 64/71] changed json stream to normal json --- darwin/dataset/local_dataset.py | 57 ++++++++++----------------------- darwin/torch/dataset.py | 32 ++++-------------- 2 files changed, 24 insertions(+), 65 deletions(-) diff --git a/darwin/dataset/local_dataset.py b/darwin/dataset/local_dataset.py index cdec1bf05..967f1bf58 100644 --- a/darwin/dataset/local_dataset.py +++ b/darwin/dataset/local_dataset.py @@ -1,3 +1,4 @@ +import json import multiprocessing as mp from pathlib import Path from typing import Any, Dict, Iterator, List, Optional, Tuple @@ -78,9 +79,7 @@ def __init__( self.original_annotations_path: Optional[List[Path]] = None self.keep_empty_annotations = keep_empty_annotations - release_path, annotations_dir, images_dir = self._initial_setup( - dataset_path, release_name - ) + release_path, annotations_dir, images_dir = self._initial_setup(dataset_path, release_name) self._validate_inputs(partition, split_type, annotation_type) # Get the list of classes @@ -120,9 +119,7 @@ def _validate_inputs(self, partition, split_type, annotation_type): if split_type not in ["random", "stratified"]: raise ValueError("split_type should be either 'random', 'stratified'") if annotation_type not in ["tag", "polygon", "bounding_box"]: - raise ValueError( - "annotation_type should be either 'tag', 'bounding_box', or 'polygon'" - ) + raise ValueError("annotation_type should be either 'tag', 'bounding_box', or 'polygon'") def _setup_annotations_and_images( self, @@ -137,7 +134,10 @@ def _setup_annotations_and_images( ): # Find all the annotations and their corresponding images for annotation_path in sorted(annotations_dir.glob("**/*.json")): - darwin_json = stream_darwin_json(annotation_path) + # darwin_json = stream_darwin_json(annotation_path) + with annotation_path.open() as file: + darwin_json = json.load(file) + image_path = get_image_path_from_stream(darwin_json, images_dir) if image_path.exists(): if not keep_empty_annotations and len(darwin_json["annotations"]) < 1: @@ -146,9 +146,7 @@ def _setup_annotations_and_images( self.annotations_path.append(annotation_path) continue else: - raise ValueError( - f"Annotation ({annotation_path}) does not have a corresponding image" - ) + raise ValueError(f"Annotation ({annotation_path}) does not have a corresponding image") def _initial_setup(self, dataset_path, release_name): assert dataset_path is not None @@ -207,9 +205,7 @@ def get_height_and_width(self, index: int) -> Tuple[float, float]: parsed = parse_darwin_json(self.annotations_path[index], index) return parsed.image_height, parsed.image_width - def extend( - self, dataset: "LocalDataset", extend_classes: bool = False - ) -> "LocalDataset": + def extend(self, dataset: "LocalDataset", extend_classes: bool = False) -> "LocalDataset": """ Extends the current dataset with another one. @@ -304,10 +300,7 @@ def parse_json(self, index: int) -> Dict[str, Any]: # Filter out unused classes and annotations of a different type if self.classes is not None: annotations = [ - a - for a in annotations - if a.annotation_class.name in self.classes - and self.annotation_type_supported(a) + a for a in annotations if a.annotation_class.name in self.classes and self.annotation_type_supported(a) ] return { "image_id": index, @@ -324,20 +317,15 @@ def annotation_type_supported(self, annotation) -> bool: elif self.annotation_type == "bounding_box": is_bounding_box = annotation_type == "bounding_box" is_supported_polygon = ( - annotation_type in ["polygon", "complex_polygon"] - and "bounding_box" in annotation.data + annotation_type in ["polygon", "complex_polygon"] and "bounding_box" in annotation.data ) return is_bounding_box or is_supported_polygon elif self.annotation_type == "polygon": return annotation_type in ["polygon", "complex_polygon"] else: - raise ValueError( - "annotation_type should be either 'tag', 'bounding_box', or 'polygon'" - ) + raise ValueError("annotation_type should be either 'tag', 'bounding_box', or 'polygon'") - def measure_mean_std( - self, multi_threaded: bool = True - ) -> Tuple[np.ndarray, np.ndarray]: + def measure_mean_std(self, multi_threaded: bool = True) -> Tuple[np.ndarray, np.ndarray]: """ Computes mean and std of trained images, given the train loader. @@ -360,9 +348,7 @@ def measure_mean_std( results = pool.map(self._return_mean, self.images_path) mean = np.sum(np.array(results), axis=0) / len(self.images_path) # Online image_classification deviation - results = pool.starmap( - self._return_std, [[item, mean] for item in self.images_path] - ) + results = pool.starmap(self._return_std, [[item, mean] for item in self.images_path]) std_sum = np.sum(np.array([item[0] for item in results]), axis=0) total_pixel_count = np.sum(np.array([item[1] for item in results])) std = np.sqrt(std_sum / total_pixel_count) @@ -408,20 +394,14 @@ def _compute_weights(labels: List[int]) -> np.ndarray: @staticmethod def _return_mean(image_path: Path) -> np.ndarray: img = np.array(load_pil_image(image_path)) - mean = np.array( - [np.mean(img[:, :, 0]), np.mean(img[:, :, 1]), np.mean(img[:, :, 2])] - ) + mean = np.array([np.mean(img[:, :, 0]), np.mean(img[:, :, 1]), np.mean(img[:, :, 2])]) return mean / 255.0 # Loads an image with OpenCV and returns the channel wise std of the image. @staticmethod def _return_std(image_path: Path, mean: np.ndarray) -> Tuple[np.ndarray, float]: img = np.array(load_pil_image(image_path)) / 255.0 - m2 = np.square( - np.array( - [img[:, :, 0] - mean[0], img[:, :, 1] - mean[1], img[:, :, 2] - mean[2]] - ) - ) + m2 = np.square(np.array([img[:, :, 0] - mean[0], img[:, :, 1] - mean[1], img[:, :, 2] - mean[2]])) return np.sum(np.sum(m2, axis=1), 1), m2.size / 3.0 def __getitem__(self, index: int): @@ -491,10 +471,7 @@ def build_stems( """ if partition is None: - return ( - str(e.relative_to(annotations_dir).parent / e.stem) - for e in sorted(annotations_dir.glob("**/*.json")) - ) + return (str(e.relative_to(annotations_dir).parent / e.stem) for e in sorted(annotations_dir.glob("**/*.json"))) if split_type == "random": split_filename = f"{split_type}_{partition}.txt" diff --git a/darwin/torch/dataset.py b/darwin/torch/dataset.py index 84231ab55..c6c3e8673 100644 --- a/darwin/torch/dataset.py +++ b/darwin/torch/dataset.py @@ -99,9 +99,7 @@ class ClassificationDataset(LocalDataset): be composed via torchvision. """ - def __init__( - self, transform: Optional[Union[Callable, List]] = None, **kwargs - ) -> None: + def __init__(self, transform: Optional[Union[Callable, List]] = None, **kwargs) -> None: super().__init__(annotation_type="tag", **kwargs) if transform is not None and isinstance(transform, list): @@ -154,11 +152,7 @@ def get_target(self, index: int) -> Tensor: data = self.parse_json(index) annotations = data.pop("annotations") - tags = [ - a.annotation_class.name - for a in annotations - if a.annotation_class.annotation_type == "tag" - ] + tags = [a.annotation_class.name for a in annotations if a.annotation_class.annotation_type == "tag"] if not self.is_multi_label: # Binary or multiclass must have a label per image @@ -182,11 +176,7 @@ def check_if_multi_label(self) -> None: for idx in range(len(self)): target = self.parse_json(idx) annotations = target.pop("annotations") - tags = [ - a.annotation_class.name - for a in annotations - if a.annotation_class.annotation_type == "tag" - ] + tags = [a.annotation_class.name for a in annotations if a.annotation_class.annotation_type == "tag"] if len(tags) > 1: self.is_multi_label = True @@ -334,9 +324,7 @@ def get_target(self, index: int) -> Dict[str, Any]: path_key = "paths" if path_key not in annotation.data: - print( - f"Warning: missing polygon in annotation {self.annotations_path[index]}" - ) + print(f"Warning: missing polygon in annotation {self.annotations_path[index]}") # Extract the sequences of coordinates from the polygon annotation sequences = convert_polygons_to_sequences( annotation.data[path_key], @@ -365,12 +353,7 @@ def get_target(self, index: int) -> Dict[str, Any]: # Compute the area of the polygon # TODO fix with addictive/subtractive paths in complex polygons - poly_area: float = np.sum( - [ - polygon_area(x_coord, y_coord) - for x_coord, y_coord in zip(x_coords, y_coords) - ] - ) + poly_area: float = np.sum([polygon_area(x_coord, y_coord) for x_coord, y_coord in zip(x_coords, y_coords)]) # Create and append the new entry for this annotation annotations.append( @@ -422,9 +405,7 @@ class SemanticSegmentationDataset(LocalDataset): Object used to convert polygons to semantic masks. """ - def __init__( - self, transform: Optional[Union[List[Callable], Callable]] = None, **kwargs - ): + def __init__(self, transform: Optional[Union[List[Callable], Callable]] = None, **kwargs): super().__init__(annotation_type="polygon", **kwargs) if "__background__" not in self.classes: self.classes.insert(0, "__background__") @@ -616,6 +597,7 @@ def get_target(self, index: int) -> Dict[str, Tensor]: """ target = self.parse_json(index) annotations = target.pop("annotations") + print(f"annotations : {annotations}") targets = [] for annotation in annotations: From 09b00d2a22f8a793f1ac5f1bba2ce274a4c64046 Mon Sep 17 00:00:00 2001 From: saurbhc Date: Tue, 28 Nov 2023 10:29:13 +0000 Subject: [PATCH 65/71] Fix `RecursionError` in `Item` class (#732) * Item missing `__str__` method raises `RecursionError` * update `MetaBase` class - add the missing `__str__` method --- darwin/future/meta/objects/base.py | 3 +++ darwin/future/meta/objects/item.py | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/darwin/future/meta/objects/base.py b/darwin/future/meta/objects/base.py index 77eafcd7e..359f4cad1 100644 --- a/darwin/future/meta/objects/base.py +++ b/darwin/future/meta/objects/base.py @@ -48,3 +48,6 @@ def __init__( def __repr__(self) -> str: return str(self) + + def __str__(self) -> str: + return f"{self.__class__.__name__}({self._element})" diff --git a/darwin/future/meta/objects/item.py b/darwin/future/meta/objects/item.py index f2b40e5f4..7ed564e3f 100644 --- a/darwin/future/meta/objects/item.py +++ b/darwin/future/meta/objects/item.py @@ -6,8 +6,8 @@ from darwin.future.core.items.archive_items import archive_list_of_items from darwin.future.core.items.delete_items import delete_list_of_items from darwin.future.core.items.move_items_to_folder import move_list_of_items_to_folder -from darwin.future.core.items.set_item_priority import set_item_priority from darwin.future.core.items.restore_items import restore_list_of_items +from darwin.future.core.items.set_item_priority import set_item_priority from darwin.future.data_objects.item import ItemCore, ItemLayout, ItemSlot from darwin.future.meta.objects.base import MetaBase @@ -144,3 +144,9 @@ def tags(self) -> Optional[Union[List[str], Dict[str, str]]]: @property def layout(self) -> Optional[ItemLayout]: return self._element.layout + + def __str__(self) -> str: + return f"Item\n\ +- Item Name: {self._element.name}\n\ +- Item Processing Status: {self._element.processing_status}\n\ +- Item ID: {self._element.id}" From 72bc6fc8c20e9f067d60c7de8f6db0774fbdc650 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Tue, 28 Nov 2023 11:58:16 +0100 Subject: [PATCH 66/71] commiting changes --- darwin/dataset/local_dataset.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/darwin/dataset/local_dataset.py b/darwin/dataset/local_dataset.py index 967f1bf58..387a26ad1 100644 --- a/darwin/dataset/local_dataset.py +++ b/darwin/dataset/local_dataset.py @@ -134,9 +134,9 @@ def _setup_annotations_and_images( ): # Find all the annotations and their corresponding images for annotation_path in sorted(annotations_dir.glob("**/*.json")): - # darwin_json = stream_darwin_json(annotation_path) - with annotation_path.open() as file: - darwin_json = json.load(file) + darwin_json = stream_darwin_json(annotation_path) + # with annotation_path.open() as file: + # darwin_json = json.load(file) image_path = get_image_path_from_stream(darwin_json, images_dir) if image_path.exists(): From 0e095c4c0c5dac392714e3fc5b9a909a9454dbf8 Mon Sep 17 00:00:00 2001 From: Christoffer Date: Tue, 28 Nov 2023 12:36:21 +0100 Subject: [PATCH 67/71] fixed json-stream error when checking for non-empty lists --- darwin/dataset/local_dataset.py | 5 ++--- darwin/utils/utils.py | 13 ++++++++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/darwin/dataset/local_dataset.py b/darwin/dataset/local_dataset.py index 387a26ad1..4f9541906 100644 --- a/darwin/dataset/local_dataset.py +++ b/darwin/dataset/local_dataset.py @@ -10,6 +10,7 @@ from darwin.utils import ( SUPPORTED_IMAGE_EXTENSIONS, get_image_path_from_stream, + is_stream_list_empty, parse_darwin_json, stream_darwin_json, ) @@ -135,12 +136,10 @@ def _setup_annotations_and_images( # Find all the annotations and their corresponding images for annotation_path in sorted(annotations_dir.glob("**/*.json")): darwin_json = stream_darwin_json(annotation_path) - # with annotation_path.open() as file: - # darwin_json = json.load(file) image_path = get_image_path_from_stream(darwin_json, images_dir) if image_path.exists(): - if not keep_empty_annotations and len(darwin_json["annotations"]) < 1: + if not keep_empty_annotations and is_stream_list_empty(darwin_json["annotations"]): continue self.images_path.append(image_path) self.annotations_path.append(annotation_path) diff --git a/darwin/utils/utils.py b/darwin/utils/utils.py index ed70de844..2731c8b9e 100644 --- a/darwin/utils/utils.py +++ b/darwin/utils/utils.py @@ -24,7 +24,7 @@ import numpy as np import orjson as json import requests -from json_stream.base import PersistentStreamingJSONObject +from json_stream.base import PersistentStreamingJSONList, PersistentStreamingJSONObject from jsonschema import validators from requests import Response from rich.progress import ProgressType, track @@ -492,6 +492,17 @@ def stream_darwin_json(path: Path) -> PersistentStreamingJSONObject: return json_stream.load(infile, persistent=True) +def is_stream_list_empty(json_list: PersistentStreamingJSONList) -> bool: + + try: + json_list[0] + except IndexError: + return True + + return False + + + def get_image_path_from_stream( darwin_json: PersistentStreamingJSONObject, images_dir: Path ) -> Path: From 28c168c349e0d81c6854e36c741de34d1b405c0e Mon Sep 17 00:00:00 2001 From: Christoffer Date: Tue, 28 Nov 2023 16:00:20 +0100 Subject: [PATCH 68/71] remove unused import --- darwin/dataset/local_dataset.py | 1 - 1 file changed, 1 deletion(-) diff --git a/darwin/dataset/local_dataset.py b/darwin/dataset/local_dataset.py index 4f9541906..d01b9081f 100644 --- a/darwin/dataset/local_dataset.py +++ b/darwin/dataset/local_dataset.py @@ -1,4 +1,3 @@ -import json import multiprocessing as mp from pathlib import Path from typing import Any, Dict, Iterator, List, Optional, Tuple From e991394255180634488ffeee59e6e6685721ecdc Mon Sep 17 00:00:00 2001 From: Christoffer Date: Thu, 30 Nov 2023 12:50:38 +0100 Subject: [PATCH 69/71] fixed video to image convertion bug when folders are used --- darwin/dataset/local_dataset.py | 2 +- darwin/utils/utils.py | 227 ++++++++------------------------ 2 files changed, 57 insertions(+), 172 deletions(-) diff --git a/darwin/dataset/local_dataset.py b/darwin/dataset/local_dataset.py index d01b9081f..4e88cf0cc 100644 --- a/darwin/dataset/local_dataset.py +++ b/darwin/dataset/local_dataset.py @@ -144,7 +144,7 @@ def _setup_annotations_and_images( self.annotations_path.append(annotation_path) continue else: - raise ValueError(f"Annotation ({annotation_path}) does not have a corresponding image") + raise ValueError(f"Annotation ({annotation_path}) does not have a corresponding image {image_path}") def _initial_setup(self, dataset_path, release_name): assert dataset_path is not None diff --git a/darwin/utils/utils.py b/darwin/utils/utils.py index 2731c8b9e..9c60d95ca 100644 --- a/darwin/utils/utils.py +++ b/darwin/utils/utils.py @@ -214,9 +214,7 @@ def is_project_dir(project_path: Path) -> bool: return (project_path / "releases").exists() and (project_path / "images").exists() -def get_progress_bar( - array: List[dt.AnnotationFile], description: Optional[str] = None -) -> Iterable[ProgressType]: +def get_progress_bar(array: List[dt.AnnotationFile], description: Optional[str] = None) -> Iterable[ProgressType]: """ Get a rich a progress bar for the given list of annotation files. @@ -360,9 +358,7 @@ def persist_client_configuration( api_key=team_config.api_key, datasets_dir=team_config.datasets_dir, ) - config.set_global( - api_endpoint=client.url, base_url=client.base_url, default_team=default_team - ) + config.set_global(api_endpoint=client.url, base_url=client.base_url, default_team=default_team) return config @@ -419,9 +415,7 @@ def attempt_decode(path: Path) -> dict: return data except Exception: continue - raise UnrecognizableFileEncoding( - f"Unable to load file {path} with any encodings: {encodings}" - ) + raise UnrecognizableFileEncoding(f"Unable to load file {path} with any encodings: {encodings}") def load_data_from_file(path: Path) -> Tuple[dict, dt.AnnotationFileVersion]: @@ -430,9 +424,7 @@ def load_data_from_file(path: Path) -> Tuple[dict, dt.AnnotationFileVersion]: return data, version -def parse_darwin_json( - path: Path, count: Optional[int] = None -) -> Optional[dt.AnnotationFile]: +def parse_darwin_json(path: Path, count: Optional[int] = None) -> Optional[dt.AnnotationFile]: """ Parses the given JSON file in v7's darwin proprietary format. Works for images, split frame videos (treated as images) and playback videos. @@ -493,19 +485,15 @@ def stream_darwin_json(path: Path) -> PersistentStreamingJSONObject: def is_stream_list_empty(json_list: PersistentStreamingJSONList) -> bool: - try: json_list[0] except IndexError: return True - - return False + return False -def get_image_path_from_stream( - darwin_json: PersistentStreamingJSONObject, images_dir: Path -) -> Path: +def get_image_path_from_stream(darwin_json: PersistentStreamingJSONObject, images_dir: Path) -> Path: """ Returns the path to the image file associated with the given darwin json file (V1 or V2). @@ -522,31 +510,17 @@ def get_image_path_from_stream( Path to the image file. """ try: - return ( - images_dir - / (Path(darwin_json["item"]["path"].lstrip("/\\"))) - / Path(darwin_json["item"]["name"]) - ) + return images_dir / (Path(darwin_json["item"]["path"].lstrip("/\\"))) / Path(darwin_json["item"]["name"]) except KeyError: - return ( - images_dir - / (Path(darwin_json["image"]["path"].lstrip("/\\"))) - / Path(darwin_json["image"]["filename"]) - ) + return images_dir / (Path(darwin_json["image"]["path"].lstrip("/\\"))) / Path(darwin_json["image"]["filename"]) def _parse_darwin_v2(path: Path, data: Dict[str, Any]) -> dt.AnnotationFile: item = data["item"] item_source = item.get("source_info", {}) - slots: List[dt.Slot] = list( - filter(None, map(_parse_darwin_slot, item.get("slots", []))) - ) - annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations( - data - ) - annotation_classes: Set[dt.AnnotationClass] = { - annotation.annotation_class for annotation in annotations - } + slots: List[dt.Slot] = list(filter(None, map(_parse_darwin_slot, item.get("slots", [])))) + annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations(data) + annotation_classes: Set[dt.AnnotationClass] = {annotation.annotation_class for annotation in annotations} if len(slots) == 0: annotation_file = dt.AnnotationFile( @@ -554,9 +528,7 @@ def _parse_darwin_v2(path: Path, data: Dict[str, Any]) -> dt.AnnotationFile: path=path, filename=item["name"], item_id=item.get("source_info", {}).get("item_id", None), - dataset_name=item.get("source_info", {}) - .get("dataset", {}) - .get("name", None), + dataset_name=item.get("source_info", {}).get("dataset", {}).get("name", None), annotation_classes=annotation_classes, annotations=annotations, is_video=False, @@ -577,17 +549,13 @@ def _parse_darwin_v2(path: Path, data: Dict[str, Any]) -> dt.AnnotationFile: path=path, filename=item["name"], item_id=item.get("source_info", {}).get("item_id", None), - dataset_name=item.get("source_info", {}) - .get("dataset", {}) - .get("name", None), + dataset_name=item.get("source_info", {}).get("dataset", {}).get("name", None), annotation_classes=annotation_classes, annotations=annotations, is_video=slot.frame_urls is not None or slot.frame_manifest is not None, image_width=slot.width, image_height=slot.height, - image_url=None - if len(slot.source_files or []) == 0 - else slot.source_files[0]["url"], + image_url=None if len(slot.source_files or []) == 0 else slot.source_files[0]["url"], image_thumbnail_url=slot.thumbnail_url, workview_url=item_source.get("workview_url", None), seq=0, @@ -617,15 +585,9 @@ def _parse_darwin_slot(data: Dict[str, Any]) -> dt.Slot: ) -def _parse_darwin_image( - path: Path, data: Dict[str, Any], count: Optional[int] -) -> dt.AnnotationFile: - annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations( - data - ) - annotation_classes: Set[dt.AnnotationClass] = { - annotation.annotation_class for annotation in annotations - } +def _parse_darwin_image(path: Path, data: Dict[str, Any], count: Optional[int]) -> dt.AnnotationFile: + annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations(data) + annotation_classes: Set[dt.AnnotationClass] = {annotation.annotation_class for annotation in annotations} slot = dt.Slot( name=None, @@ -662,20 +624,12 @@ def _parse_darwin_image( return annotation_file -def _parse_darwin_video( - path: Path, data: Dict[str, Any], count: Optional[int] -) -> dt.AnnotationFile: - annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations( - data - ) - annotation_classes: Set[dt.AnnotationClass] = { - annotation.annotation_class for annotation in annotations - } +def _parse_darwin_video(path: Path, data: Dict[str, Any], count: Optional[int]) -> dt.AnnotationFile: + annotations: List[Union[dt.Annotation, dt.VideoAnnotation]] = _data_to_annotations(data) + annotation_classes: Set[dt.AnnotationClass] = {annotation.annotation_class for annotation in annotations} if "width" not in data["image"] or "height" not in data["image"]: - raise OutdatedDarwinJSONFormat( - "Missing width/height in video, please re-export" - ) + raise OutdatedDarwinJSONFormat("Missing width/height in video, please re-export") slot = dt.Slot( name=None, @@ -721,42 +675,24 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati main_annotation: Optional[dt.Annotation] = None # Darwin JSON 2.0 representation of complex polygons - if ( - "polygon" in annotation - and "paths" in annotation["polygon"] - and len(annotation["polygon"]["paths"]) > 1 - ): + if "polygon" in annotation and "paths" in annotation["polygon"] and len(annotation["polygon"]["paths"]) > 1: bounding_box = annotation.get("bounding_box") paths = annotation["polygon"]["paths"] - main_annotation = dt.make_complex_polygon( - name, paths, bounding_box, slot_names=slot_names - ) + main_annotation = dt.make_complex_polygon(name, paths, bounding_box, slot_names=slot_names) # Darwin JSON 2.0 representation of simple polygons - elif ( - "polygon" in annotation - and "paths" in annotation["polygon"] - and len(annotation["polygon"]["paths"]) == 1 - ): + elif "polygon" in annotation and "paths" in annotation["polygon"] and len(annotation["polygon"]["paths"]) == 1: bounding_box = annotation.get("bounding_box") paths = annotation["polygon"]["paths"] - main_annotation = dt.make_polygon( - name, paths[0], bounding_box, slot_names=slot_names - ) + main_annotation = dt.make_polygon(name, paths[0], bounding_box, slot_names=slot_names) # Darwin JSON 1.0 representation of complex and simple polygons elif "polygon" in annotation: print(f"Polygon {annotation}") bounding_box = annotation.get("bounding_box") if "additional_paths" in annotation["polygon"]: - paths = [annotation["polygon"]["path"]] + annotation["polygon"][ - "additional_paths" - ] - main_annotation = dt.make_complex_polygon( - name, paths, bounding_box, slot_names=slot_names - ) + paths = [annotation["polygon"]["path"]] + annotation["polygon"]["additional_paths"] + main_annotation = dt.make_complex_polygon(name, paths, bounding_box, slot_names=slot_names) else: - main_annotation = dt.make_polygon( - name, annotation["polygon"]["path"], bounding_box, slot_names=slot_names - ) + main_annotation = dt.make_polygon(name, annotation["polygon"]["path"], bounding_box, slot_names=slot_names) # Darwin JSON 1.0 representation of complex polygons elif "complex_polygon" in annotation: bounding_box = annotation.get("bounding_box") @@ -768,9 +704,7 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati if "additional_paths" in annotation["complex_polygon"]: paths.extend(annotation["complex_polygon"]["additional_paths"]) - main_annotation = dt.make_complex_polygon( - name, paths, bounding_box, slot_names=slot_names - ) + main_annotation = dt.make_complex_polygon(name, paths, bounding_box, slot_names=slot_names) elif "bounding_box" in annotation: bounding_box = annotation["bounding_box"] main_annotation = dt.make_bounding_box( @@ -784,9 +718,7 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati elif "tag" in annotation: main_annotation = dt.make_tag(name, slot_names=slot_names) elif "line" in annotation: - main_annotation = dt.make_line( - name, annotation["line"]["path"], slot_names=slot_names - ) + main_annotation = dt.make_line(name, annotation["line"]["path"], slot_names=slot_names) elif "keypoint" in annotation: main_annotation = dt.make_keypoint( name, @@ -795,17 +727,11 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati slot_names=slot_names, ) elif "ellipse" in annotation: - main_annotation = dt.make_ellipse( - name, annotation["ellipse"], slot_names=slot_names - ) + main_annotation = dt.make_ellipse(name, annotation["ellipse"], slot_names=slot_names) elif "cuboid" in annotation: - main_annotation = dt.make_cuboid( - name, annotation["cuboid"], slot_names=slot_names - ) + main_annotation = dt.make_cuboid(name, annotation["cuboid"], slot_names=slot_names) elif "skeleton" in annotation: - main_annotation = dt.make_skeleton( - name, annotation["skeleton"]["nodes"], slot_names=slot_names - ) + main_annotation = dt.make_skeleton(name, annotation["skeleton"]["nodes"], slot_names=slot_names) elif "table" in annotation: main_annotation = dt.make_table( name, @@ -814,9 +740,7 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati slot_names=slot_names, ) elif "string" in annotation: - main_annotation = dt.make_string( - name, annotation["string"]["sources"], slot_names=slot_names - ) + main_annotation = dt.make_string(name, annotation["string"]["sources"], slot_names=slot_names) elif "graph" in annotation: main_annotation = dt.make_graph( name, @@ -843,29 +767,19 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati if "id" in annotation: main_annotation.id = annotation["id"] if "instance_id" in annotation: - main_annotation.subs.append( - dt.make_instance_id(annotation["instance_id"]["value"]) - ) + main_annotation.subs.append(dt.make_instance_id(annotation["instance_id"]["value"])) if "attributes" in annotation: main_annotation.subs.append(dt.make_attributes(annotation["attributes"])) if "text" in annotation: main_annotation.subs.append(dt.make_text(annotation["text"]["text"])) if "inference" in annotation: - main_annotation.subs.append( - dt.make_opaque_sub("inference", annotation["inference"]) - ) + main_annotation.subs.append(dt.make_opaque_sub("inference", annotation["inference"])) if "directional_vector" in annotation: - main_annotation.subs.append( - dt.make_opaque_sub("directional_vector", annotation["directional_vector"]) - ) + main_annotation.subs.append(dt.make_opaque_sub("directional_vector", annotation["directional_vector"])) if "measures" in annotation: - main_annotation.subs.append( - dt.make_opaque_sub("measures", annotation["measures"]) - ) + main_annotation.subs.append(dt.make_opaque_sub("measures", annotation["measures"])) if "auto_annotate" in annotation: - main_annotation.subs.append( - dt.make_opaque_sub("auto_annotate", annotation["auto_annotate"]) - ) + main_annotation.subs.append(dt.make_opaque_sub("auto_annotate", annotation["auto_annotate"])) if annotation.get("annotators") is not None: main_annotation.annotators = _parse_annotators(annotation["annotators"]) @@ -919,9 +833,7 @@ def _parse_darwin_raster_annotation(annotation: dict) -> Optional[dt.Annotation] slot_names: Optional[List[str]] = parse_slot_names(annotation) if not id or not name or not raster_layer: - raise ValueError( - "Raster annotation must have an 'id', 'name' and 'raster_layer' field" - ) + raise ValueError("Raster annotation must have an 'id', 'name' and 'raster_layer' field") dense_rle, mask_annotation_ids_mapping, total_pixels = ( raster_layer.get("dense_rle", None), @@ -972,14 +884,9 @@ def _parse_darwin_mask_annotation(annotation: dict) -> Optional[dt.Annotation]: def _parse_annotators(annotators: List[Dict[str, Any]]) -> List[dt.AnnotationAuthor]: if not (hasattr(annotators, "full_name") or not hasattr(annotators, "email")): - raise AttributeError( - "JSON file must contain annotators with 'full_name' and 'email' fields" - ) + raise AttributeError("JSON file must contain annotators with 'full_name' and 'email' fields") - return [ - dt.AnnotationAuthor(annotator["full_name"], annotator["email"]) - for annotator in annotators - ] + return [dt.AnnotationAuthor(annotator["full_name"], annotator["email"]) for annotator in annotators] def split_video_annotation(annotation: dt.AnnotationFile) -> List[dt.AnnotationFile]: @@ -1015,13 +922,9 @@ def split_video_annotation(annotation: dt.AnnotationFile) -> List[dt.AnnotationF frame_annotations = [] for i, frame_url in enumerate(urls): annotations = [ - a.frames[i] - for a in annotation.annotations - if isinstance(a, dt.VideoAnnotation) and i in a.frames + a.frames[i] for a in annotation.annotations if isinstance(a, dt.VideoAnnotation) and i in a.frames ] - annotation_classes: Set[dt.AnnotationClass] = { - annotation.annotation_class for annotation in annotations - } + annotation_classes: Set[dt.AnnotationClass] = {annotation.annotation_class for annotation in annotations} filename: str = f"{Path(annotation.filename).stem}/{i:07d}.png" frame_annotations.append( dt.AnnotationFile( @@ -1037,8 +940,10 @@ def split_video_annotation(annotation: dt.AnnotationFile) -> List[dt.AnnotationF annotation.seq, item_id=annotation.item_id, slots=annotation.slots, + remote_path=annotation.remote_path, ) ) + return frame_annotations @@ -1105,9 +1010,7 @@ def convert_polygons_to_sequences( else: list_polygons = cast(List[dt.Polygon], [polygons]) - if not isinstance(list_polygons[0], list) or not isinstance( - list_polygons[0][0], dict - ): + if not isinstance(list_polygons[0], list) or not isinstance(list_polygons[0][0], dict): raise ValueError("Unknown input format") sequences: List[List[Union[int, float]]] = [] @@ -1248,9 +1151,7 @@ def convert_bounding_box_to_xyxy(box: dt.BoundingBox) -> List[float]: return [box["x"], box["y"], x2, y2] -def convert_polygons_to_mask( - polygons: List, height: int, width: int, value: Optional[int] = 1 -) -> np.ndarray: +def convert_polygons_to_mask(polygons: List, height: int, width: int, value: Optional[int] = 1) -> np.ndarray: """ Converts a list of polygons, encoded as a list of dictionaries into an ``nd.array`` mask. @@ -1344,38 +1245,24 @@ def _parse_version(data: dict) -> dt.AnnotationFileVersion: return dt.AnnotationFileVersion(int(major), int(minor), suffix) -def _data_to_annotations( - data: Dict[str, Any] -) -> List[Union[dt.Annotation, dt.VideoAnnotation]]: +def _data_to_annotations(data: Dict[str, Any]) -> List[Union[dt.Annotation, dt.VideoAnnotation]]: raw_image_annotations = filter( lambda annotation: ( - ("frames" not in annotation) - and ("raster_layer" not in annotation) - and ("mask" not in annotation) + ("frames" not in annotation) and ("raster_layer" not in annotation) and ("mask" not in annotation) ), data["annotations"], ) - raw_video_annotations = filter( - lambda annotation: "frames" in annotation, data["annotations"] - ) - raw_raster_annotations = filter( - lambda annotation: "raster_layer" in annotation, data["annotations"] - ) - raw_mask_annotations = filter( - lambda annotation: "mask" in annotation, data["annotations"] - ) - image_annotations: List[dt.Annotation] = list( - filter(None, map(_parse_darwin_annotation, raw_image_annotations)) - ) + raw_video_annotations = filter(lambda annotation: "frames" in annotation, data["annotations"]) + raw_raster_annotations = filter(lambda annotation: "raster_layer" in annotation, data["annotations"]) + raw_mask_annotations = filter(lambda annotation: "mask" in annotation, data["annotations"]) + image_annotations: List[dt.Annotation] = list(filter(None, map(_parse_darwin_annotation, raw_image_annotations))) video_annotations: List[dt.VideoAnnotation] = list( filter(None, map(_parse_darwin_video_annotation, raw_video_annotations)) ) raster_annotations: List[dt.Annotation] = list( filter(None, map(_parse_darwin_raster_annotation, raw_raster_annotations)) ) - mask_annotations: List[dt.Annotation] = list( - filter(None, map(_parse_darwin_mask_annotation, raw_mask_annotations)) - ) + mask_annotations: List[dt.Annotation] = list(filter(None, map(_parse_darwin_mask_annotation, raw_mask_annotations))) return [ *image_annotations, @@ -1396,6 +1283,4 @@ def _supported_schema_versions() -> Dict[Tuple[int, int, str], str]: def _default_schema(version: dt.AnnotationFileVersion) -> Optional[str]: - return _supported_schema_versions().get( - (version.major, version.minor, version.suffix) - ) + return _supported_schema_versions().get((version.major, version.minor, version.suffix)) From ec92bda3102aafc3cf110692418c9871c65e5b4c Mon Sep 17 00:00:00 2001 From: Christoffer Date: Thu, 30 Nov 2023 16:36:54 +0100 Subject: [PATCH 70/71] removed debug print --- darwin/torch/dataset.py | 1 - 1 file changed, 1 deletion(-) diff --git a/darwin/torch/dataset.py b/darwin/torch/dataset.py index c6c3e8673..b17a21238 100644 --- a/darwin/torch/dataset.py +++ b/darwin/torch/dataset.py @@ -597,7 +597,6 @@ def get_target(self, index: int) -> Dict[str, Tensor]: """ target = self.parse_json(index) annotations = target.pop("annotations") - print(f"annotations : {annotations}") targets = [] for annotation in annotations: From ce7788c3be43a0ec81e7250bcb98ca4e9c70b12d Mon Sep 17 00:00:00 2001 From: Christoffer Date: Thu, 30 Nov 2023 16:37:49 +0100 Subject: [PATCH 71/71] removed debug print --- darwin/utils/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/darwin/utils/utils.py b/darwin/utils/utils.py index 9c60d95ca..590a4b69e 100644 --- a/darwin/utils/utils.py +++ b/darwin/utils/utils.py @@ -686,7 +686,6 @@ def _parse_darwin_annotation(annotation: Dict[str, Any]) -> Optional[dt.Annotati main_annotation = dt.make_polygon(name, paths[0], bounding_box, slot_names=slot_names) # Darwin JSON 1.0 representation of complex and simple polygons elif "polygon" in annotation: - print(f"Polygon {annotation}") bounding_box = annotation.get("bounding_box") if "additional_paths" in annotation["polygon"]: paths = [annotation["polygon"]["path"]] + annotation["polygon"]["additional_paths"]