Skip to content

Commit

Permalink
test(LAB-2558): add negative polygon in COCO format and fix/add tests (
Browse files Browse the repository at this point in the history
…#1739)

Co-authored-by: Josselin BUILS <[email protected]>
  • Loading branch information
josselinbuils and Josselin BUILS authored Jul 9, 2024
1 parent 669277e commit 3e8ba39
Show file tree
Hide file tree
Showing 3 changed files with 206 additions and 16 deletions.
21 changes: 12 additions & 9 deletions src/kili/services/export/format/coco/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,10 +417,10 @@ def _get_coco_image_annotations(
print("continue")
continue
bounding_poly = annotation["boundingPoly"]
area, bbox, poly = _get_coco_geometry_from_kili_bpoly(
area, bbox, polygons = _get_coco_geometry_from_kili_bpoly(
bounding_poly, coco_image["width"], coco_image["height"]
)
if len(poly) < 6: # twice the number of vertices
if len(polygons[0]) < 6: # twice the number of vertices
print("A polygon must contain more than 2 points. Skipping this polygon...")
continue
if bbox[2] == 0 and bbox[3] == 0:
Expand All @@ -436,7 +436,7 @@ def _get_coco_image_annotations(
# Objects have only one connected part.
# But a type of object can appear several times on the same image.
# The limitation of the single connected part comes from Kili.
segmentation=[poly],
segmentation=polygons,
area=area,
iscrowd=0,
)
Expand Down Expand Up @@ -478,17 +478,20 @@ def _get_coco_geometry_from_kili_bpoly(
x_max, y_max = max(p_x), max(p_y)
bbox_width, bbox_height = x_max - x_min, y_max - y_min
area = _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:
negative_normalized_vertices = bounding_poly[1]["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]
area -= _get_shoelace_area(np_x, np_y)
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]
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)]
poly = [p for vertice in poly_vertices for p in vertice]
return area, bbox, poly
return area, bbox, polygons


def _get_coco_categories(cat_kili_id_to_coco_id, merged) -> List[CocoCategory]:
Expand Down
8 changes: 7 additions & 1 deletion tests/unit/services/export/helpers/coco.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import math
from pathlib import Path
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Union

from kili.services.types import Job


def get_asset(
content_path: Path,
with_annotation: Optional[List[Dict]],
negative_polygons: Union[List[List[Dict]], None] = None,
) -> Dict:
# without annotation means that: there is a label for the asset
# but there is no labeling data for the job.
# `annotations=[]` should not exist.
json_response = {"author": {"firstname": "Jean-Pierre", "lastname": "Dupont"}}

if with_annotation:
json_response = {
**json_response,
Expand All @@ -30,6 +32,10 @@ def get_asset(
]
},
}
if negative_polygons:
json_response["JOB_0"]["annotations"][0]["boundingPoly"] += map(
lambda negative_polygon: {"normalizedVertices": negative_polygon}, negative_polygons
)

return {
"latestLabel": {"jsonResponse": json_response},
Expand Down
193 changes: 187 additions & 6 deletions tests/unit/services/export/test_coco.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ def test__get_coco_image_annotations():
assert coco_annotation["annotations"][0]["segmentation"] == [
[0.0, 0.0, 960.0, 0.0, 0.0, 540.0]
]
assert coco_annotation["annotations"][0]["area"] == 2073600
# Area of a triangle: base * height / 2
assert coco_annotation["annotations"][0]["area"] == 960.0 * 540.0 / 2

good_date = True
try:
Expand All @@ -101,6 +102,182 @@ def test__get_coco_image_annotations():
)


def test__get_coco_image_annotation_area_with_self_intersecting_polygon():
with TemporaryDirectory() as tmp_dir:
job_name = "JOB_0"
output_file = Path(tmp_dir) / job_name / "labels.json"
local_file_path = tmp_dir / Path("image1.jpg")
image_width = 1920
image_height = 1080
Image.new("RGB", (image_width, image_height)).save(local_file_path)
_, paths = _convert_kili_semantic_to_coco(
jobs={
JobName(job_name): {
"mlTask": "OBJECT_DETECTION",
"content": {
"categories": {
"OBJECT_A": {"name": "Object A"},
"OBJECT_B": {"name": "Object B"},
}
},
"instruction": "",
"isChild": False,
"isNew": False,
"isVisible": True,
"models": {},
"required": True,
"tools": ["semantic"],
}
},
assets=[
helpers.get_asset(
local_file_path,
with_annotation=[
{
"x": 0.0,
"y": 0.0,
},
{
"x": 0.5,
"y": 0.0,
},
{
"x": 0.0,
"y": 0.5,
},
{
"x": 0.5,
"y": 0.5,
},
{
"x": 0.0,
"y": 0.0,
},
],
)
],
output_dir=Path(tmp_dir),
title="Test project",
project_input_type="IMAGE",
annotation_modifier=lambda x, _, _1: x,
merged=False,
)

assert paths[0] == output_file
with output_file.open("r", encoding="utf-8") as f:
coco_annotation = json.loads(f.read())

assert coco_annotation["annotations"][0]["bbox"] == [0, 0, 960, 540]
assert coco_annotation["annotations"][0]["segmentation"] == [
[0.0, 0.0, 960.0, 0.0, 0.0, 540.0, 960.0, 540.0, 0.0, 0.0]
]
# Here we have a self-intersecting polygon with 2 opposites triangles, so the area is
# the sum of the areas of the 2 triangles.
# Area of a triangle: base * height / 2
assert coco_annotation["annotations"][0]["area"] == (960.0 * 270.0 / 2) * 2


def test__get_coco_image_annotation_area_with_negative_polygons():
with TemporaryDirectory() as tmp_dir:
job_name = "JOB_0"
output_file = Path(tmp_dir) / job_name / "labels.json"
local_file_path = tmp_dir / Path("image1.jpg")
image_width = 1920
image_height = 1080
Image.new("RGB", (image_width, image_height)).save(local_file_path)
_, paths = _convert_kili_semantic_to_coco(
jobs={
JobName(job_name): {
"mlTask": "OBJECT_DETECTION",
"content": {
"categories": {
"OBJECT_A": {"name": "Object A"},
"OBJECT_B": {"name": "Object B"},
}
},
"instruction": "",
"isChild": False,
"isNew": False,
"isVisible": True,
"models": {},
"required": True,
"tools": ["semantic"],
}
},
assets=[
helpers.get_asset(
local_file_path,
with_annotation=[
{
"x": 0.0,
"y": 0.0,
},
{
"x": 0.5,
"y": 0.0,
},
{
"x": 0.0,
"y": 0.5,
},
],
negative_polygons=[
[
{
"x": 0.1,
"y": 0.1,
},
{
"x": 0.4,
"y": 0.1,
},
{
"x": 0.1,
"y": 0.4,
},
],
[
{
"x": 0.0,
"y": 0.0,
},
{
"x": 0.1,
"y": 0.0,
},
{
"x": 0.0,
"y": 0.1,
},
],
],
)
],
output_dir=Path(tmp_dir),
title="Test project",
project_input_type="IMAGE",
annotation_modifier=lambda x, _, _1: x,
merged=False,
)

assert paths[0] == output_file
with output_file.open("r", encoding="utf-8") as f:
coco_annotation = json.loads(f.read())

assert coco_annotation["annotations"][0]["bbox"] == [0, 0, 960, 540]
assert coco_annotation["annotations"][0]["segmentation"] == [
[0.0, 0.0, 960.0, 0.0, 0.0, 540.0],
[192.0, 108.0, 768.0, 108.0, 192.0, 432.0],
[0.0, 0.0, 192.0, 0.0, 0.0, 108.0],
]
# Here we have a positive triangle with 2 negative triangles inside, so the area is the
# area of the positive triangle minus the area of the negative triangles.
# Area of a triangle: base * height / 2
assert coco_annotation["annotations"][0]["area"] == (960.0 * 540.0 / 2) - (
576.0 * 324.0 / 2
) - (192.0 * 108.0 / 2)


@pytest.mark.parametrize(
("name", "normalized_vertices", "expected_angle", "expected_bounding_box"),
[
Expand Down Expand Up @@ -139,8 +316,6 @@ def test__get_coco_image_annotations_with_label_modifier(
local_file_path = tmp_dir / Path("image1.jpg")
Image.new("RGB", (image_width, image_height)).save(local_file_path)

area = 2073600

expected_segmentation = [
a for p in normalized_vertices for a in [p["x"] * image_width, p["y"] * image_height]
]
Expand Down Expand Up @@ -199,7 +374,10 @@ def test__get_coco_image_annotations_with_label_modifier(
assert coco_annotation["annotations"][0]["segmentation"][0] == pytest.approx(
expected_segmentation
)
assert coco_annotation["annotations"][0]["area"] == area
# Area of a rectangle: width * height
assert coco_annotation["annotations"][0]["area"] == pytest.approx(
expected_bounding_box[2] * expected_bounding_box[3]
)

good_date = True
try:
Expand Down Expand Up @@ -409,13 +587,16 @@ def test_get_coco_geometry_from_kili_bpoly():
}
]
image_width, image_height = 1920, 1080
bbox, poly = _get_coco_geometry_from_kili_bpoly(boundingPoly, image_width, image_height)
area, bbox, polygons = _get_coco_geometry_from_kili_bpoly(
boundingPoly, image_width, image_height
)
assert bbox == [192, 108, 1344, 324]
assert area == bbox[2] * bbox[3] # Area of a rectangle: width * height
assert bbox[0] == int(0.1 * image_width)
assert bbox[1] == int(0.1 * image_height)
assert bbox[2] == int((0.8 - 0.1) * image_width)
assert bbox[3] == int((0.4 - 0.1) * image_height)
assert poly == [192.0, 108.0, 192.0, 432.0, 1536.0, 432.0, 1536.0, 108.0]
assert polygons == [[192.0, 108.0, 192.0, 432.0, 1536.0, 432.0, 1536.0, 108.0]]


def test__get_kili_cat_id_to_coco_cat_id_mapping_with_split_jobs():
Expand Down

0 comments on commit 3e8ba39

Please sign in to comment.