Skip to content

Commit c4ed0b0

Browse files
feat: LEAP-1840: Add KeyPoints to COCO/YOLO export (#451)
Co-authored-by: fern-api <115122769+fern-api[bot]@users.noreply.github.com>
1 parent 07bd8e9 commit c4ed0b0

File tree

14 files changed

+1581
-414
lines changed

14 files changed

+1581
-414
lines changed

poetry.lock

Lines changed: 321 additions & 301 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "label-studio-sdk"
33

44
[tool.poetry]
55
name = "label-studio-sdk"
6-
version = "1.0.13.dev"
6+
version = "1.0.13"
77
description = ""
88
readme = "README.md"
99
authors = []
@@ -45,6 +45,7 @@ jsonschema = ">=4.23.0"
4545
lxml = ">=4.2.5"
4646
nltk = "^3.9.1"
4747
numpy = ">=1.26.4,<3.0.0"
48+
opencv-python = "^4.9.0"
4849
pandas = ">=0.24.0"
4950
pydantic = ">= 1.9.2"
5051
pydantic-core = "^2.18.2"

src/label_studio_sdk/converter/converter.py

Lines changed: 37 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@
1111
from enum import Enum
1212
from glob import glob
1313
from shutil import copy2
14-
from typing import Optional
14+
from typing import Optional, List, Tuple
1515

1616
import ijson
1717
import ujson as json
1818
from PIL import Image
1919
from label_studio_sdk.converter import brush
2020
from label_studio_sdk.converter.audio import convert_to_asr_json_manifest
21+
from label_studio_sdk.converter.keypoints import process_keypoints_for_coco, build_kp_order, update_categories_for_keypoints, keypoints_in_label_config, get_yolo_categories_for_keypoints
2122
from label_studio_sdk.converter.exports import csv2
2223
from label_studio_sdk.converter.utils import (
2324
parse_config,
@@ -34,6 +35,7 @@
3435
convert_annotation_to_yolo_obb,
3536
)
3637
from label_studio_sdk._extensions.label_studio_tools.core.utils.io import get_local_path
38+
from label_studio_sdk.converter.exports.yolo import process_and_save_yolo_annotations
3739

3840
logger = logging.getLogger(__name__)
3941

@@ -109,13 +111,13 @@ class Converter(object):
109111
"description": "Popular machine learning format used by the COCO dataset for object detection and image "
110112
"segmentation tasks with polygons and rectangles.",
111113
"link": "https://labelstud.io/guide/export.html#COCO",
112-
"tags": ["image segmentation", "object detection"],
114+
"tags": ["image segmentation", "object detection", "keypoints"],
113115
},
114116
Format.COCO_WITH_IMAGES: {
115117
"title": "COCO with Images",
116118
"description": "COCO format with images downloaded.",
117119
"link": "https://labelstud.io/guide/export.html#COCO",
118-
"tags": ["image segmentation", "object detection"],
120+
"tags": ["image segmentation", "object detection", "keypoints"],
119121
},
120122
Format.VOC: {
121123
"title": "Pascal VOC XML",
@@ -128,13 +130,13 @@ class Converter(object):
128130
"description": "Popular TXT format is created for each image file. Each txt file contains annotations for "
129131
"the corresponding image file, that is object class, object coordinates, height & width.",
130132
"link": "https://labelstud.io/guide/export.html#YOLO",
131-
"tags": ["image segmentation", "object detection"],
133+
"tags": ["image segmentation", "object detection", "keypoints"],
132134
},
133135
Format.YOLO_WITH_IMAGES: {
134136
"title": "YOLO with Images",
135137
"description": "YOLO format with images downloaded.",
136138
"link": "https://labelstud.io/guide/export.html#YOLO",
137-
"tags": ["image segmentation", "object detection"],
139+
"tags": ["image segmentation", "object detection", "keypoints"],
138140
},
139141
Format.YOLO_OBB: {
140142
"title": "YOLOv8 OBB",
@@ -205,6 +207,7 @@ def __init__(
205207
self._schema = None
206208
self.access_token = access_token
207209
self.hostname = hostname
210+
self.is_keypoints = None
208211

209212
if isinstance(config, dict):
210213
self._schema = config
@@ -376,11 +379,14 @@ def _get_supported_formats(self):
376379
and (
377380
"RectangleLabels" in output_tag_types
378381
or "PolygonLabels" in output_tag_types
382+
or "KeyPointLabels" in output_tag_types
379383
)
380384
or "Rectangle" in output_tag_types
381385
and "Labels" in output_tag_types
382386
or "PolygonLabels" in output_tag_types
383387
and "Labels" in output_tag_types
388+
or "KeyPointLabels" in output_tag_types
389+
and "Labels" in output_tag_types
384390
):
385391
all_formats.remove(Format.COCO.name)
386392
all_formats.remove(Format.COCO_WITH_IMAGES.name)
@@ -522,6 +528,9 @@ def annotation_result_from_task(self, task):
522528
if "original_height" in r:
523529
v["original_height"] = r["original_height"]
524530
outputs[r["from_name"]].append(v)
531+
if self.is_keypoints:
532+
v['id'] = r.get('id')
533+
v['parentID'] = r.get('parentID')
525534

526535
data = Converter.get_data(task, outputs, annotation)
527536
if "agreement" in task:
@@ -638,6 +647,7 @@ def add_image(images, width, height, image_id, image_path):
638647
os.makedirs(output_image_dir, exist_ok=True)
639648
images, categories, annotations = [], [], []
640649
categories, category_name_to_id = self._get_labels()
650+
categories, category_name_to_id = update_categories_for_keypoints(categories, category_name_to_id, self._schema)
641651
data_key = self._data_keys[0]
642652
item_iterator = (
643653
self.iter_from_dir(input_data)
@@ -703,9 +713,10 @@ def add_image(images, width, height, image_id, image_path):
703713
logger.debug(f'Empty bboxes for {item["output"]}')
704714
continue
705715

716+
keypoint_labels = []
706717
for label in labels:
707718
category_name = None
708-
for key in ["rectanglelabels", "polygonlabels", "labels"]:
719+
for key in ["rectanglelabels", "polygonlabels", "keypointlabels", "labels"]:
709720
if key in label and len(label[key]) > 0:
710721
category_name = label[key][0]
711722
break
@@ -775,11 +786,22 @@ def add_image(images, width, height, image_id, image_path):
775786
"area": get_polygon_area(x, y),
776787
}
777788
)
789+
elif "keypointlabels" in label:
790+
keypoint_labels.append(label)
778791
else:
779792
raise ValueError("Unknown label type")
780793

781794
if os.getenv("LABEL_STUDIO_FORCE_ANNOTATOR_EXPORT"):
782795
annotations[-1].update({"annotator": get_annotator(item)})
796+
if keypoint_labels:
797+
kp_order = build_kp_order(self._schema)
798+
annotations.append(process_keypoints_for_coco(
799+
keypoint_labels,
800+
kp_order,
801+
annotation_id=len(annotations),
802+
image_id=image_id,
803+
category_name_to_id=category_name_to_id,
804+
))
783805

784806
with io.open(output_file, mode="w", encoding="utf8") as fout:
785807
json.dump(
@@ -846,7 +868,14 @@ def convert_to_yolo(
846868
else:
847869
output_label_dir = os.path.join(output_dir, "labels")
848870
os.makedirs(output_label_dir, exist_ok=True)
849-
categories, category_name_to_id = self._get_labels()
871+
is_keypoints = keypoints_in_label_config(self._schema)
872+
873+
if is_keypoints:
874+
# we use this attribute to add id and parentID to annotation data
875+
self.is_keypoints = True
876+
categories, category_name_to_id = get_yolo_categories_for_keypoints(self._schema)
877+
else:
878+
categories, category_name_to_id = self._get_labels()
850879
data_key = self._data_keys[0]
851880
item_iterator = (
852881
self.iter_from_dir(input_data)
@@ -923,82 +952,7 @@ def convert_to_yolo(
923952
pass
924953
continue
925954

926-
annotations = []
927-
for label in labels:
928-
category_name = None
929-
category_names = [] # considering multi-label
930-
for key in ["rectanglelabels", "polygonlabels", "labels"]:
931-
if key in label and len(label[key]) > 0:
932-
# change to save multi-label
933-
for category_name in label[key]:
934-
category_names.append(category_name)
935-
936-
if len(category_names) == 0:
937-
logger.debug(
938-
"Unknown label type or labels are empty: " + str(label)
939-
)
940-
continue
941-
942-
for category_name in category_names:
943-
if category_name not in category_name_to_id:
944-
category_id = len(categories)
945-
category_name_to_id[category_name] = category_id
946-
categories.append({"id": category_id, "name": category_name})
947-
category_id = category_name_to_id[category_name]
948-
949-
if (
950-
"rectanglelabels" in label
951-
or "rectangle" in label
952-
or "labels" in label
953-
):
954-
# yolo obb
955-
if is_obb:
956-
obb_annotation = convert_annotation_to_yolo_obb(label)
957-
if obb_annotation is None:
958-
continue
959-
960-
top_left, top_right, bottom_right, bottom_left = (
961-
obb_annotation
962-
)
963-
x1, y1 = top_left
964-
x2, y2 = top_right
965-
x3, y3 = bottom_right
966-
x4, y4 = bottom_left
967-
annotations.append(
968-
[category_id, x1, y1, x2, y2, x3, y3, x4, y4]
969-
)
970-
971-
# simple yolo
972-
else:
973-
annotation = convert_annotation_to_yolo(label)
974-
if annotation is None:
975-
continue
976-
977-
(
978-
x,
979-
y,
980-
w,
981-
h,
982-
) = annotation
983-
annotations.append([category_id, x, y, w, h])
984-
985-
elif "polygonlabels" in label or "polygon" in label:
986-
if not ('points' in label):
987-
continue
988-
points_abs = [(x / 100, y / 100) for x, y in label["points"]]
989-
annotations.append(
990-
[category_id]
991-
+ [coord for point in points_abs for coord in point]
992-
)
993-
else:
994-
raise ValueError(f"Unknown label type {label}")
995-
with open(label_path, "w") as f:
996-
for annotation in annotations:
997-
for idx, l in enumerate(annotation):
998-
if idx == len(annotation) - 1:
999-
f.write(f"{l}\n")
1000-
else:
1001-
f.write(f"{l} ")
955+
categories, category_name_to_id = process_and_save_yolo_annotations(labels, label_path, category_name_to_id, categories, is_obb, is_keypoints, self._schema)
1002956
with open(class_file, "w", encoding="utf8") as f:
1003957
for c in categories:
1004958
f.write(c["name"] + "\n")
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import logging
2+
from label_studio_sdk.converter.utils import convert_annotation_to_yolo, convert_annotation_to_yolo_obb
3+
from label_studio_sdk.converter.keypoints import build_kp_order
4+
5+
logger = logging.getLogger(__name__)
6+
7+
def process_keypoints_for_yolo(labels, label_path,
8+
category_name_to_id, categories,
9+
is_obb, kp_order):
10+
class_map = {c['name']: c['id'] for c in categories}
11+
12+
rectangles = {}
13+
for item in labels:
14+
if item['type'].lower() == 'rectanglelabels':
15+
bbox_id = item['id']
16+
cls_name = item['rectanglelabels'][0]
17+
cls_idx = class_map.get(cls_name)
18+
if cls_idx is None:
19+
continue
20+
21+
x = item['x'] / 100.0
22+
y = item['y'] / 100.0
23+
width = item['width'] / 100.0
24+
height = item['height'] / 100.0
25+
x_c = x + width / 2.0
26+
y_c = y + height / 2.0
27+
28+
rectangles[bbox_id] = {
29+
'class_idx': cls_idx,
30+
'x_center': x_c,
31+
'y_center': y_c,
32+
'width': width,
33+
'height': height,
34+
'kp_dict': {}
35+
}
36+
37+
for item in labels:
38+
if item['type'].lower() == 'keypointlabels':
39+
parent_id = item.get('parentID')
40+
if parent_id not in rectangles:
41+
continue
42+
label_name = item['keypointlabels'][0]
43+
kp_x = item['x'] / 100.0
44+
kp_y = item['y'] / 100.0
45+
rectangles[parent_id]['kp_dict'][label_name] = (kp_x, kp_y, 2) # 2 = visible
46+
47+
lines = []
48+
for rect in rectangles.values():
49+
base = [
50+
rect['class_idx'],
51+
rect['x_center'],
52+
rect['y_center'],
53+
rect['width'],
54+
rect['height']
55+
]
56+
keypoints = []
57+
for k in kp_order:
58+
keypoints.extend(rect['kp_dict'].get(k, (0.0, 0.0, 0)))
59+
line = ' '.join(map(str, base + keypoints))
60+
lines.append(line)
61+
62+
with open(label_path, 'w', encoding='utf-8') as f:
63+
f.write('\n'.join(lines))
64+
65+
66+
def process_and_save_yolo_annotations(labels, label_path, category_name_to_id, categories, is_obb, is_keypoints, label_config):
67+
if is_keypoints:
68+
kp_order = build_kp_order(label_config)
69+
process_keypoints_for_yolo(labels, label_path, category_name_to_id, categories, is_obb, kp_order)
70+
return categories, category_name_to_id
71+
72+
annotations = []
73+
for label in labels:
74+
category_name = None
75+
category_names = [] # considering multi-label
76+
for key in ["rectanglelabels", "polygonlabels", "labels"]:
77+
if key in label and len(label[key]) > 0:
78+
# change to save multi-label
79+
for category_name in label[key]:
80+
category_names.append(category_name)
81+
82+
if len(category_names) == 0:
83+
logger.debug(
84+
"Unknown label type or labels are empty: " + str(label)
85+
)
86+
continue
87+
88+
for category_name in category_names:
89+
if category_name not in category_name_to_id:
90+
category_id = len(categories)
91+
category_name_to_id[category_name] = category_id
92+
categories.append({"id": category_id, "name": category_name})
93+
category_id = category_name_to_id[category_name]
94+
95+
if (
96+
"rectanglelabels" in label
97+
or "rectangle" in label
98+
or "labels" in label
99+
):
100+
# yolo obb
101+
if is_obb:
102+
obb_annotation = convert_annotation_to_yolo_obb(label)
103+
if obb_annotation is None:
104+
continue
105+
106+
top_left, top_right, bottom_right, bottom_left = (
107+
obb_annotation
108+
)
109+
x1, y1 = top_left
110+
x2, y2 = top_right
111+
x3, y3 = bottom_right
112+
x4, y4 = bottom_left
113+
annotations.append(
114+
[category_id, x1, y1, x2, y2, x3, y3, x4, y4]
115+
)
116+
117+
# simple yolo
118+
else:
119+
annotation = convert_annotation_to_yolo(label)
120+
if annotation is None:
121+
continue
122+
123+
(
124+
x,
125+
y,
126+
w,
127+
h,
128+
) = annotation
129+
annotations.append([category_id, x, y, w, h])
130+
131+
elif "polygonlabels" in label or "polygon" in label:
132+
if not ('points' in label):
133+
continue
134+
points_abs = [(x / 100, y / 100) for x, y in label["points"]]
135+
annotations.append(
136+
[category_id]
137+
+ [coord for point in points_abs for coord in point]
138+
)
139+
else:
140+
raise ValueError(f"Unknown label type {label}")
141+
with open(label_path, "w") as f:
142+
for annotation in annotations:
143+
for idx, l in enumerate(annotation):
144+
if idx == len(annotation) - 1:
145+
f.write(f"{l}\n")
146+
else:
147+
f.write(f"{l} ")
148+
149+
return categories, category_name_to_id

0 commit comments

Comments
 (0)