From 46e8fa5b3799c2fab4d1e53163766532c32edf51 Mon Sep 17 00:00:00 2001 From: KBolashev Date: Tue, 1 Oct 2024 15:36:02 +0300 Subject: [PATCH 1/3] Add ellipselabels LS struct + IREllipseImageAnnotation --- .../formats/label_studio/ellipselabels.py | 56 +++++++++++++++++++ .../ir/image/__init__.py | 2 + .../ir/image/annotations/ellipse.py | 22 ++++++++ 3 files changed, 80 insertions(+) create mode 100644 dagshub_annotation_converter/formats/label_studio/ellipselabels.py create mode 100644 dagshub_annotation_converter/ir/image/annotations/ellipse.py diff --git a/dagshub_annotation_converter/formats/label_studio/ellipselabels.py b/dagshub_annotation_converter/formats/label_studio/ellipselabels.py new file mode 100644 index 0000000..aa1c1eb --- /dev/null +++ b/dagshub_annotation_converter/formats/label_studio/ellipselabels.py @@ -0,0 +1,56 @@ +from typing import List, Sequence + +from dagshub_annotation_converter.formats.label_studio.base import ImageAnnotationResultABC +from dagshub_annotation_converter.ir.image import IRImageAnnotationBase, IREllipseImageAnnotation, CoordinateStyle +from dagshub_annotation_converter.util.pydantic_util import ParentModel + + +class EllipseLabelsAnnotationsValue(ParentModel): + x: float + y: float + radiusX: float + radiusY: float + rotation: float = 0.0 + ellipselabels: List[str] + + +class EllipseLabelsAnnotation(ImageAnnotationResultABC): + value: EllipseLabelsAnnotationsValue + type: str = "ellipselabels" + + def to_ir_annotation(self) -> Sequence[IRImageAnnotationBase]: + res = IREllipseImageAnnotation( + categories={self.value.ellipselabels[0]: 1.0}, + coordinate_style=CoordinateStyle.NORMALIZED, + top=self.value.y / 100, + left=self.value.x / 100, + radiusX=self.value.radiusX / 100, + radiusY=self.value.radiusY / 100, + rotation=self.value.rotation, + image_width=self.original_width, + image_height=self.original_height, + ) + res.imported_id = self.id + return [res] + + @staticmethod + def from_ir_annotation(ir_annotation: IRImageAnnotationBase) -> Sequence["ImageAnnotationResultABC"]: + assert isinstance(ir_annotation, IREllipseImageAnnotation) + + ir_annotation = ir_annotation.normalized() + category = ir_annotation.ensure_has_one_category() + + return [ + EllipseLabelsAnnotation( + original_width=ir_annotation.image_width, + original_height=ir_annotation.image_height, + value=EllipseLabelsAnnotationsValue( + x=ir_annotation.left * 100, + y=ir_annotation.top * 100, + radiusX=ir_annotation.radiusX * 100, + radiusY=ir_annotation.radiusY * 100, + rotation=ir_annotation.rotation, + ellipselabels=[category], + ), + ) + ] diff --git a/dagshub_annotation_converter/ir/image/__init__.py b/dagshub_annotation_converter/ir/image/__init__.py index 0e9f613..6798c02 100644 --- a/dagshub_annotation_converter/ir/image/__init__.py +++ b/dagshub_annotation_converter/ir/image/__init__.py @@ -3,6 +3,7 @@ from .annotations.pose import IRPoseImageAnnotation, IRPosePoint from .annotations.segmentation import IRSegmentationImageAnnotation, IRSegmentationPoint from .annotations.bbox import IRBBoxImageAnnotation +from .annotations.ellipse import IREllipseImageAnnotation __all__ = [ "CoordinateStyle", @@ -12,4 +13,5 @@ "IRSegmentationImageAnnotation", "IRSegmentationPoint", "IRBBoxImageAnnotation", + "IREllipseImageAnnotation", ] diff --git a/dagshub_annotation_converter/ir/image/annotations/ellipse.py b/dagshub_annotation_converter/ir/image/annotations/ellipse.py new file mode 100644 index 0000000..799b5b6 --- /dev/null +++ b/dagshub_annotation_converter/ir/image/annotations/ellipse.py @@ -0,0 +1,22 @@ +from dagshub_annotation_converter.ir.image import IRImageAnnotationBase + + +class IREllipseImageAnnotation(IRImageAnnotationBase): + top: float + left: float + radiusX: float + radiusY: float + rotation: float = 0.0 + """Rotation in degrees (pivot point - top-left)""" + + def _normalize(self): + self.top = self.top / self.image_height + self.left = self.left / self.image_width + self.radiusX = self.radiusX / self.image_width + self.radiusY = self.radiusY / self.image_height + + def _denormalize(self): + self.top = self.top * self.image_height + self.left = self.left * self.image_width + self.radiusX = self.radiusX * self.image_width + self.radiusY = self.radiusY * self.image_height From db08911b53fc197f327a35eb15e4458e4fa7be1e Mon Sep 17 00:00:00 2001 From: KBolashev Date: Tue, 1 Oct 2024 18:39:16 +0300 Subject: [PATCH 2/3] Add CVAT import, fix LS format --- .../converters/cvat.py | 6 ++-- .../formats/cvat/__init__.py | 2 ++ .../formats/cvat/box.py | 1 + .../formats/cvat/ellipse.py | 28 +++++++++++++++++++ .../formats/label_studio/ellipselabels.py | 16 +++++------ .../formats/label_studio/task.py | 4 +++ .../ir/image/annotations/ellipse.py | 24 ++++++++-------- 7 files changed, 58 insertions(+), 23 deletions(-) create mode 100644 dagshub_annotation_converter/formats/cvat/ellipse.py diff --git a/dagshub_annotation_converter/converters/cvat.py b/dagshub_annotation_converter/converters/cvat.py index b99e063..fc36f7f 100644 --- a/dagshub_annotation_converter/converters/cvat.py +++ b/dagshub_annotation_converter/converters/cvat.py @@ -1,6 +1,6 @@ import logging from os import PathLike -from typing import Sequence, List, Dict +from typing import Sequence, List, Dict, Union from zipfile import ZipFile import lxml.etree @@ -38,12 +38,12 @@ def load_cvat_from_xml_string( return annotations -def load_cvat_from_xml_file(xml_file: PathLike) -> Dict[str, Sequence[IRImageAnnotationBase]]: +def load_cvat_from_xml_file(xml_file: Union[str, PathLike]) -> Dict[str, Sequence[IRImageAnnotationBase]]: with open(xml_file, "rb") as f: return load_cvat_from_xml_string(f.read()) -def load_cvat_from_zip(zip_path: PathLike) -> Dict[str, Sequence[IRImageAnnotationBase]]: +def load_cvat_from_zip(zip_path: Union[str, PathLike]) -> Dict[str, Sequence[IRImageAnnotationBase]]: with ZipFile(zip_path) as proj_zip: with proj_zip.open("annotations.xml") as f: return load_cvat_from_xml_string(f.read()) diff --git a/dagshub_annotation_converter/formats/cvat/__init__.py b/dagshub_annotation_converter/formats/cvat/__init__.py index 8a2082b..0151fdc 100644 --- a/dagshub_annotation_converter/formats/cvat/__init__.py +++ b/dagshub_annotation_converter/formats/cvat/__init__.py @@ -3,6 +3,7 @@ from lxml.etree import ElementBase from .box import parse_box +from .ellipse import parse_ellipse from .polygon import parse_polygon from .points import parse_points from .skeleton import parse_skeleton @@ -15,4 +16,5 @@ "polygon": parse_polygon, "points": parse_points, "skeleton": parse_skeleton, + "ellipse": parse_ellipse, } diff --git a/dagshub_annotation_converter/formats/cvat/box.py b/dagshub_annotation_converter/formats/cvat/box.py index 532cb08..eba9741 100644 --- a/dagshub_annotation_converter/formats/cvat/box.py +++ b/dagshub_annotation_converter/formats/cvat/box.py @@ -58,6 +58,7 @@ def parse_box(elem: ElementBase, containing_image: ElementBase) -> IRBBoxImageAn left=left, width=width, height=height, + rotation=rotation, image_width=image_info.width, image_height=image_info.height, filename=image_info.name, diff --git a/dagshub_annotation_converter/formats/cvat/ellipse.py b/dagshub_annotation_converter/formats/cvat/ellipse.py new file mode 100644 index 0000000..af69ef1 --- /dev/null +++ b/dagshub_annotation_converter/formats/cvat/ellipse.py @@ -0,0 +1,28 @@ +from lxml.etree import ElementBase + +from dagshub_annotation_converter.formats.cvat.context import parse_image_tag +from dagshub_annotation_converter.ir.image import IREllipseImageAnnotation, CoordinateStyle + + +def parse_ellipse(elem: ElementBase, containing_image: ElementBase) -> IREllipseImageAnnotation: + center_x = float(elem.attrib["cx"]) + center_y = float(elem.attrib["cy"]) + radius_x = float(elem.attrib["rx"]) + radius_y = float(elem.attrib["ry"]) + + rotation = float(elem.attrib.get("rotation", 0.0)) + + image_info = parse_image_tag(containing_image) + + return IREllipseImageAnnotation( + categories={str(elem.attrib["label"]): 1.0}, + center_x=round(center_x), + center_y=round(center_y), + radius_x=radius_x, + radius_y=radius_y, + rotation=rotation, + image_width=image_info.width, + image_height=image_info.height, + filename=image_info.name, + coordinate_style=CoordinateStyle.DENORMALIZED, + ) diff --git a/dagshub_annotation_converter/formats/label_studio/ellipselabels.py b/dagshub_annotation_converter/formats/label_studio/ellipselabels.py index aa1c1eb..e9b0144 100644 --- a/dagshub_annotation_converter/formats/label_studio/ellipselabels.py +++ b/dagshub_annotation_converter/formats/label_studio/ellipselabels.py @@ -22,10 +22,10 @@ def to_ir_annotation(self) -> Sequence[IRImageAnnotationBase]: res = IREllipseImageAnnotation( categories={self.value.ellipselabels[0]: 1.0}, coordinate_style=CoordinateStyle.NORMALIZED, - top=self.value.y / 100, - left=self.value.x / 100, - radiusX=self.value.radiusX / 100, - radiusY=self.value.radiusY / 100, + center_x=self.value.x / 100, + center_y=self.value.y / 100, + radius_x=self.value.radiusX / 100, + radius_y=self.value.radiusY / 100, rotation=self.value.rotation, image_width=self.original_width, image_height=self.original_height, @@ -45,10 +45,10 @@ def from_ir_annotation(ir_annotation: IRImageAnnotationBase) -> Sequence["ImageA original_width=ir_annotation.image_width, original_height=ir_annotation.image_height, value=EllipseLabelsAnnotationsValue( - x=ir_annotation.left * 100, - y=ir_annotation.top * 100, - radiusX=ir_annotation.radiusX * 100, - radiusY=ir_annotation.radiusY * 100, + x=ir_annotation.center_x * 100, + y=ir_annotation.center_y * 100, + radiusX=ir_annotation.radius_x * 100, + radiusY=ir_annotation.radius_y * 100, rotation=ir_annotation.rotation, ellipselabels=[category], ), diff --git a/dagshub_annotation_converter/formats/label_studio/task.py b/dagshub_annotation_converter/formats/label_studio/task.py index dfd1ab6..01f723e 100644 --- a/dagshub_annotation_converter/formats/label_studio/task.py +++ b/dagshub_annotation_converter/formats/label_studio/task.py @@ -8,6 +8,7 @@ from pydantic import SerializeAsAny, Field, BeforeValidator from dagshub_annotation_converter.formats.label_studio.base import AnnotationResultABC, ImageAnnotationResultABC +from dagshub_annotation_converter.formats.label_studio.ellipselabels import EllipseLabelsAnnotation from dagshub_annotation_converter.formats.label_studio.keypointlabels import KeyPointLabelsAnnotation from dagshub_annotation_converter.formats.label_studio.polygonlabels import PolygonLabelsAnnotation from dagshub_annotation_converter.formats.label_studio.rectanglelabels import RectangleLabelsAnnotation @@ -18,6 +19,7 @@ CoordinateStyle, IRPosePoint, IRSegmentationImageAnnotation, + IREllipseImageAnnotation, ) from dagshub_annotation_converter.util.pydantic_util import ParentModel @@ -25,12 +27,14 @@ "polygonlabels": PolygonLabelsAnnotation, "rectanglelabels": RectangleLabelsAnnotation, "keypointlabels": KeyPointLabelsAnnotation, + "ellipselabels": EllipseLabelsAnnotation, } ir_annotation_lookup: Dict[Type[IRImageAnnotationBase], Type[ImageAnnotationResultABC]] = { IRPoseImageAnnotation: KeyPointLabelsAnnotation, IRBBoxImageAnnotation: RectangleLabelsAnnotation, IRSegmentationImageAnnotation: PolygonLabelsAnnotation, + IREllipseImageAnnotation: EllipseLabelsAnnotation, } logger = logging.getLogger(__name__) diff --git a/dagshub_annotation_converter/ir/image/annotations/ellipse.py b/dagshub_annotation_converter/ir/image/annotations/ellipse.py index 799b5b6..bc68e4a 100644 --- a/dagshub_annotation_converter/ir/image/annotations/ellipse.py +++ b/dagshub_annotation_converter/ir/image/annotations/ellipse.py @@ -2,21 +2,21 @@ class IREllipseImageAnnotation(IRImageAnnotationBase): - top: float - left: float - radiusX: float - radiusY: float + center_x: float + center_y: float + radius_x: float + radius_y: float rotation: float = 0.0 """Rotation in degrees (pivot point - top-left)""" def _normalize(self): - self.top = self.top / self.image_height - self.left = self.left / self.image_width - self.radiusX = self.radiusX / self.image_width - self.radiusY = self.radiusY / self.image_height + self.center_x = self.center_x / self.image_width + self.center_y = self.center_y / self.image_height + self.radius_x = self.radius_x / self.image_width + self.radius_y = self.radius_y / self.image_height def _denormalize(self): - self.top = self.top * self.image_height - self.left = self.left * self.image_width - self.radiusX = self.radiusX * self.image_width - self.radiusY = self.radiusY * self.image_height + self.center_x = self.center_x * self.image_width + self.center_y = self.center_y * self.image_height + self.radius_x = self.radius_x * self.image_width + self.radius_y = self.radius_y * self.image_height From f67f1dc5811f7652ffb2aaa5069a95021a0152d6 Mon Sep 17 00:00:00 2001 From: KBolashev Date: Sun, 6 Oct 2024 11:37:36 +0300 Subject: [PATCH 3/3] Add tests for ellipses --- tests/cvat/test_parsers.py | 30 ++++++++++- tests/label_studio/test_ellipse.py | 85 ++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 tests/label_studio/test_ellipse.py diff --git a/tests/cvat/test_parsers.py b/tests/cvat/test_parsers.py index ae14ca3..2537408 100644 --- a/tests/cvat/test_parsers.py +++ b/tests/cvat/test_parsers.py @@ -7,7 +7,7 @@ parse_box, parse_polygon, parse_points, - parse_skeleton, + parse_skeleton, parse_ellipse, ) from dagshub_annotation_converter.ir.image import ( CoordinateStyle, @@ -15,7 +15,7 @@ IRSegmentationImageAnnotation, IRSegmentationPoint, IRPosePoint, - IRPoseImageAnnotation, + IRPoseImageAnnotation, IREllipseImageAnnotation, ) @@ -159,3 +159,29 @@ def test_skeleton(): ) assert expected == actual + + +def test_ellipse(): + data = """ + + + """ + + image, annotation = to_xml(data) + + actual = parse_ellipse(annotation, image) + + expected = IREllipseImageAnnotation( + filename="000.png", + categories={"circle1": 1.0}, + image_width=1920, + image_height=1200, + coordinate_style=CoordinateStyle.DENORMALIZED, + center_x=392, + center_y=323, + radius_x=205.06, + radius_y=202.84, + rotation=0.0, + ) + + assert actual == expected diff --git a/tests/label_studio/test_ellipse.py b/tests/label_studio/test_ellipse.py new file mode 100644 index 0000000..416ce65 --- /dev/null +++ b/tests/label_studio/test_ellipse.py @@ -0,0 +1,85 @@ +from typing import Dict + +import pytest + +from dagshub_annotation_converter.formats.label_studio.ellipselabels import EllipseLabelsAnnotation +from dagshub_annotation_converter.formats.label_studio.task import LabelStudioTask, parse_ls_task +from dagshub_annotation_converter.ir.image import IREllipseImageAnnotation, CoordinateStyle +from tests.label_studio.common import generate_annotation, generate_task + + +@pytest.fixture +def ellipse_annotation() -> Dict: + annotation = { + "x": 10, + "y": 20, + "radiusX": 30, + "radiusY": 40, + "ellipselabels": ["dog"], + } + + return generate_annotation(annotation, "ellipselabels", "deadbeef") + + +@pytest.fixture +def ellipse_task(ellipse_annotation) -> str: + return generate_task([ellipse_annotation]) + + +@pytest.fixture +def parsed_ellipse_task(ellipse_task) -> LabelStudioTask: + return parse_ls_task(ellipse_task) + + +def test_ellipse_parsing(parsed_ellipse_task): + actual = parsed_ellipse_task + assert len(actual.annotations) == 1 + assert len(actual.annotations[0].result) == 1 + + ann = actual.annotations[0].result[0] + assert isinstance(ann, EllipseLabelsAnnotation) + assert ann.value.x == 10 + assert ann.value.y == 20 + assert ann.value.radiusX == 30 + assert ann.value.radiusY == 40 + + +def test_ellipse_ir(parsed_ellipse_task): + actual = parsed_ellipse_task.annotations[0].result[0].to_ir_annotation() + + assert len(actual) == 1 + ann = actual[0] + assert isinstance(ann, IREllipseImageAnnotation) + + assert ann.center_x == 0.1 + assert ann.center_y == 0.2 + assert ann.radius_x == 0.3 + assert ann.radius_y == 0.4 + + +def test_ir_ellipse_addition(): + task = LabelStudioTask() + ellipse = IREllipseImageAnnotation( + categories={"dog": 1.0}, + center_x=0.1, + center_y=0.2, + radius_x=0.3, + radius_y=0.4, + rotation=60.0, + image_width=100, + image_height=100, + coordinate_style=CoordinateStyle.NORMALIZED, + ) + + task.add_ir_annotation(ellipse) + + assert len(task.annotations) == 1 + assert len(task.annotations[0].result) == 1 + + ann = task.annotations[0].result[0] + assert isinstance(ann, EllipseLabelsAnnotation) + assert ann.value.x == 10 + assert ann.value.y == 20 + assert ann.value.radiusX == 30 + assert ann.value.radiusY == 40 + assert ann.value.rotation == 60.0