Skip to content

Commit

Permalink
fix(LAB-3380): Correct vertices calculation for each type of export +…
Browse files Browse the repository at this point in the history
… creation of units tests
  • Loading branch information
Sihem Tchabi authored and Sihem Tchabi committed Jan 15, 2025
1 parent e4ffc8f commit 3b386a6
Show file tree
Hide file tree
Showing 9 changed files with 393 additions and 36 deletions.
19 changes: 19 additions & 0 deletions src/kili/services/export/format/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,25 @@ class ExportParams(NamedTuple):
include_sent_back_labels: Optional[bool]


def reverse_rotation_vertices(normalized_vertices, rotation_angle) -> List[Dict]:
"""Allows to retrieve vertices without rotation."""
vertices_before_rotate = []
for vertice in normalized_vertices:
new_x = vertice["x"]
new_y = vertice["y"]
if rotation_angle == 90:
new_x = vertice["y"]
new_y = 1 - vertice["x"]
elif rotation_angle == 180:
new_x = 1 - vertice["x"]
new_y = 1 - vertice["y"]
elif rotation_angle == 270:
new_x = 1 - vertice["y"]
new_y = vertice["x"]
vertices_before_rotate.append({"x": new_x, "y": new_y})
return vertices_before_rotate


class AbstractExporter(ABC): # pylint: disable=too-many-instance-attributes
"""Abstract class defining the interface for all exporters."""

Expand Down
39 changes: 26 additions & 13 deletions src/kili/services/export/format/coco/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
NotCompatibleInputType,
NotCompatibleOptions,
)
from kili.services.export.format.base import AbstractExporter
from kili.services.export.format.base import AbstractExporter, reverse_rotation_vertices
from kili.services.export.format.coco.types import (
CocoAnnotation,
CocoCategory,
Expand Down Expand Up @@ -288,7 +288,9 @@ def _get_images_and_annotation_for_images(
width=width,
date_captured=None,
)

rotation_val = 0
if "ROTATION_JOB" in asset["latestLabel"]["jsonResponse"]:
rotation_val = asset["latestLabel"]["jsonResponse"]["ROTATION_JOB"]["rotation"]
coco_images.append(coco_image)
if is_single_job:
assert len(list(jobs.keys())) == 1
Expand All @@ -304,6 +306,7 @@ def _get_images_and_annotation_for_images(
annotation_offset,
coco_image,
annotation_modifier=annotation_modifier,
rotation=rotation_val,
)
coco_annotations.extend(coco_img_annotations)
else:
Expand All @@ -318,6 +321,7 @@ def _get_images_and_annotation_for_images(
annotation_offset,
coco_image,
annotation_modifier=annotation_modifier,
rotation=rotation_val,
)
coco_annotations.extend(coco_img_annotations)
return coco_images, coco_annotations
Expand Down Expand Up @@ -367,7 +371,6 @@ def _get_images_and_annotation_for_videos(
date_captured=None,
)
coco_images.append(coco_image)

if is_single_job:
job_name = next(iter(jobs.keys()))
if job_name not in json_response:
Expand Down Expand Up @@ -405,6 +408,7 @@ def _get_coco_image_annotations(
annotation_offset: int,
coco_image: CocoImage,
annotation_modifier: Optional[CocoAnnotationModifier],
rotation: int = 0,
) -> Tuple[List[CocoAnnotation], int]:
coco_annotations = []

Expand All @@ -418,7 +422,7 @@ def _get_coco_image_annotations(
continue
bounding_poly = annotation["boundingPoly"]
area, bbox, polygons = _get_coco_geometry_from_kili_bpoly(
bounding_poly, coco_image["width"], coco_image["height"]
bounding_poly, coco_image["width"], coco_image["height"], rotation
)
if len(polygons[0]) < 6: # twice the number of vertices
print("A polygon must contain more than 2 points. Skipping this polygon...")
Expand Down Expand Up @@ -468,28 +472,37 @@ def _get_shoelace_area(x: List[float], y: List[float]):


def _get_coco_geometry_from_kili_bpoly(
bounding_poly: List[Dict], asset_width: int, asset_height: int
bounding_poly: List[Dict], asset_width: int, asset_height: int, rotation_angle: int
):
normalized_vertices = bounding_poly[0]["normalizedVertices"]
p_x = [float(vertice["x"]) * asset_width for vertice in normalized_vertices]
p_y = [float(vertice["y"]) * asset_height for vertice in normalized_vertices]
vertices_before_rotate = reverse_rotation_vertices(normalized_vertices, rotation_angle)
p_x = [float(vertice["x"]) * asset_width for vertice in vertices_before_rotate]
p_y = [float(vertice["y"]) * asset_height for vertice in vertices_before_rotate]
poly_vertices = [(float(x), float(y)) for x, y in zip(p_x, p_y)]
x_min, y_min = min(p_x), min(p_y)
x_max, y_max = max(p_x), max(p_y)
x_min, y_min = round(min(p_x)), round(min(p_y))
x_max, y_max = round(max(p_x)), round(max(p_y))
bbox_width, bbox_height = x_max - x_min, y_max - y_min
area = _get_shoelace_area(p_x, p_y)
area = round(_get_shoelace_area(p_x, p_y))
polygons = [[p for vertice in poly_vertices for p in vertice]]

# Compute and remove negative area
if len(bounding_poly) > 1:
for negative_bounding_poly in bounding_poly[1:]:
negative_normalized_vertices = negative_bounding_poly["normalizedVertices"]
np_x = [float(vertice["x"]) * asset_width for vertice in negative_normalized_vertices]
np_y = [float(vertice["y"]) * asset_height for vertice in negative_normalized_vertices]
negative_vertices_before_rotate = reverse_rotation_vertices(
negative_normalized_vertices, rotation_angle
)
np_x = [
float(negative_vertice["x"]) * asset_width
for negative_vertice in negative_vertices_before_rotate
]
np_y = [
float(negative_vertice["y"]) * asset_height
for negative_vertice in negative_vertices_before_rotate
]
area -= _get_shoelace_area(np_x, np_y)
poly_negative_vertices = [(float(x), float(y)) for x, y in zip(np_x, np_y)]
polygons.append([p for vertice in poly_negative_vertices for p in vertice])

bbox = [int(x_min), int(y_min), int(bbox_width), int(bbox_height)]
return area, bbox, polygons

Expand Down
18 changes: 11 additions & 7 deletions src/kili/services/export/format/voc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
NotCompatibleInputType,
NotCompatibleOptions,
)
from kili.services.export.format.base import AbstractExporter
from kili.services.export.format.base import AbstractExporter, reverse_rotation_vertices
from kili.services.export.media.image import get_frame_dimensions, get_image_dimensions
from kili.services.export.media.video import cut_video, get_video_dimensions
from kili.services.types import Job
Expand Down Expand Up @@ -136,6 +136,9 @@ def _parse_annotations(
valid_jobs: Sequence[str],
) -> None:
# pylint: disable=too-many-locals
rotation_val = 0
if "ROTATION_JOB" in response:
rotation_val = response["ROTATION_JOB"]["rotation"]
for job_name, job_response in response.items():
if job_name not in valid_jobs:
continue
Expand All @@ -159,16 +162,17 @@ def _parse_annotations(
occluded = ET.SubElement(annotation_category, "occluded")
occluded.text = "0"
bndbox = ET.SubElement(annotation_category, "bndbox")
x_vertices = [int(round(v["x"] * img_width)) for v in vertices]
y_vertices = [int(round(v["y"] * img_height)) for v in vertices]
vertices_before_rotate = reverse_rotation_vertices(vertices, rotation_val)
x_vertices = [v["x"] * img_width for v in vertices_before_rotate]
y_vertices = [v["y"] * img_height for v in vertices_before_rotate]
xmin = ET.SubElement(bndbox, "xmin")
xmin.text = str(min(x_vertices))
xmin.text = str(round(min(x_vertices)))
xmax = ET.SubElement(bndbox, "xmax")
xmax.text = str(max(x_vertices))
xmax.text = str(round(max(x_vertices)))
ymin = ET.SubElement(bndbox, "ymin")
ymin.text = str(min(y_vertices))
ymin.text = str(round(min(y_vertices)))
ymax = ET.SubElement(bndbox, "ymax")
ymax.text = str(max(y_vertices))
ymax.text = str(round(max(y_vertices)))


def _provide_voc_headers(
Expand Down
11 changes: 8 additions & 3 deletions src/kili/services/export/format/yolo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
NotCompatibleInputType,
NotCompatibleOptions,
)
from kili.services.export.format.base import AbstractExporter
from kili.services.export.format.base import AbstractExporter, reverse_rotation_vertices
from kili.services.export.media.video import cut_video
from kili.services.export.repository import AbstractContentRepository, DownloadError
from kili.services.export.types import JobCategory, LabelFormat, SplitOption
Expand Down Expand Up @@ -259,6 +259,10 @@ def _convert_from_kili_to_yolo_format(
if label is None or "jsonResponse" not in label:
return []
json_response = label["jsonResponse"]
rotation_val = 0
if "ROTATION_JOB" in json_response:
rotation_val = json_response["ROTATION_JOB"]["rotation"]

if not (job_id in json_response and "annotations" in json_response[job_id]):
return []
annotations = json_response[job_id]["annotations"]
Expand All @@ -273,8 +277,9 @@ def _convert_from_kili_to_yolo_format(
if len(bounding_poly) < 1 or "normalizedVertices" not in bounding_poly[0]:
continue
normalized_vertices = bounding_poly[0]["normalizedVertices"]
x_s: List[float] = [vertice["x"] for vertice in normalized_vertices]
y_s: List[float] = [vertice["y"] for vertice in normalized_vertices]
vertices_before_rotate = reverse_rotation_vertices(normalized_vertices, rotation_val)
x_s: List[float] = [vertice["x"] for vertice in vertices_before_rotate]
y_s: List[float] = [vertice["y"] for vertice in vertices_before_rotate]

if annotation["type"] == JobTool.RECTANGLE:
x_min, y_min = min(x_s), min(y_s)
Expand Down
143 changes: 143 additions & 0 deletions tests/fakes/fake_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,105 @@
]
}
}
job_object_detection_with_0_rotation = {
"JOB_0": {
"annotations": [
{
"categories": [{"confidence": 100, "name": "OBJECT_A"}],
"jobName": "JOB_0",
"mid": "2022040515434712-7532",
"mlTask": "OBJECT_DETECTION",
"boundingPoly": [
{
"normalizedVertices": [
{"x": 0.07787903893951942, "y": 0.4746554191458914},
{"x": 0.07787903893951942, "y": 0.09429841078556889},
{"x": 0.3388566694283348, "y": 0.09429841078556889},
{"x": 0.3388566694283348, "y": 0.4746554191458914},
]
}
],
"type": "rectangle",
"children": {},
}
]
}
}
job_object_detection_with_90_rotation = {
"JOB_0": {
"annotations": [
{
"categories": [{"confidence": 100, "name": "OBJECT_A"}],
"jobName": "JOB_0",
"mid": "2022040515434712-7532",
"mlTask": "OBJECT_DETECTION",
"boundingPoly": [
{
"normalizedVertices": [
{"x": 0.5253445808541086, "y": 0.07787903893951942},
{"x": 0.9057015892144311, "y": 0.07787903893951942},
{"x": 0.9057015892144311, "y": 0.3388566694283348},
{"x": 0.5253445808541086, "y": 0.3388566694283348},
]
}
],
"type": "rectangle",
"children": {},
}
]
},
"ROTATION_JOB": {"rotation": 90},
}
job_object_detection_with_180_rotation = {
"JOB_0": {
"annotations": [
{
"categories": [{"confidence": 100, "name": "OBJECT_A"}],
"jobName": "JOB_0",
"mid": "2022040515434712-7532",
"mlTask": "OBJECT_DETECTION",
"boundingPoly": [
{
"normalizedVertices": [
{"x": 0.9221209610604806, "y": 0.5253445808541086},
{"x": 0.9221209610604806, "y": 0.9057015892144311},
{"x": 0.6611433305716652, "y": 0.9057015892144311},
{"x": 0.6611433305716652, "y": 0.5253445808541086},
]
}
],
"type": "rectangle",
"children": {},
}
]
},
"ROTATION_JOB": {"rotation": 180},
}
job_object_detection_with_270_rotation = {
"JOB_0": {
"annotations": [
{
"categories": [{"confidence": 100, "name": "OBJECT_A"}],
"jobName": "JOB_0",
"mid": "2022040515434712-7532",
"mlTask": "OBJECT_DETECTION",
"boundingPoly": [
{
"normalizedVertices": [
{"x": 0.4746554191458914, "y": 0.9221209610604806},
{"x": 0.09429841078556889, "y": 0.9221209610604806},
{"x": 0.09429841078556889, "y": 0.6611433305716652},
{"x": 0.4746554191458914, "y": 0.6611433305716652},
]
}
],
"type": "rectangle",
"children": {},
}
]
},
"ROTATION_JOB": {"rotation": 270},
}
job_object_detection_with_classification = {
"JOB_0": {
"annotations": [
Expand Down Expand Up @@ -85,6 +184,50 @@
"jsonContent": "",
}

asset_image_1_with_0_rotation = {
"latestLabel": {
"jsonResponse": job_object_detection_with_0_rotation,
"author": {"firstname": "Jean-Pierre", "lastname": "Dupont"},
"labelType": "DEFAULT",
},
"externalId": "car_1",
"content": "https://storage.googleapis.com/label-public-staging/car/car_1.jpg",
"jsonContent": "",
}

asset_image_1_with_90_rotation = {
"latestLabel": {
"jsonResponse": job_object_detection_with_90_rotation,
"author": {"firstname": "Jean-Pierre", "lastname": "Dupont"},
"labelType": "DEFAULT",
},
"externalId": "car_1",
"content": "https://storage.googleapis.com/label-public-staging/car/car_1.jpg",
"jsonContent": "",
}

asset_image_1_with_180_rotation = {
"latestLabel": {
"jsonResponse": job_object_detection_with_180_rotation,
"author": {"firstname": "Jean-Pierre", "lastname": "Dupont"},
"labelType": "DEFAULT",
},
"externalId": "car_1",
"content": "https://storage.googleapis.com/label-public-staging/car/car_1.jpg",
"jsonContent": "",
}

asset_image_1_with_270_rotation = {
"latestLabel": {
"jsonResponse": job_object_detection_with_270_rotation,
"author": {"firstname": "Jean-Pierre", "lastname": "Dupont"},
"labelType": "DEFAULT",
},
"externalId": "car_1",
"content": "https://storage.googleapis.com/label-public-staging/car/car_1.jpg",
"jsonContent": "",
}

asset_image_1_without_annotation = {
"latestLabel": {
"jsonResponse": {},
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/services/export/expected/car_1_with_0_rotation.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?xml version="1.0" ?>
<annotation>
<folder/>
<filename>car_1.xml</filename>
<path/>
<source>
<database>Kili Technology</database>
</source>
<size>
<width>1920</width>
<height>1080</height>
<depth>3</depth>
</size>
<segmented/>
<object>
<name>OBJECT_A</name>
<job_name>JOB_0</job_name>
<pose>Unspecified</pose>
<truncated>0</truncated>
<difficult>0</difficult>
<occluded>0</occluded>
<bndbox>
<xmin>150</xmin>
<xmax>651</xmax>
<ymin>102</ymin>
<ymax>513</ymax>
</bndbox>
</object>
</annotation>
Loading

0 comments on commit 3b386a6

Please sign in to comment.