Skip to content

Commit f326999

Browse files
authored
Merge pull request #72 from fastlabel/develop
Merge to main
2 parents 1ad8ec3 + e67afb1 commit f326999

File tree

5 files changed

+200
-82
lines changed

5 files changed

+200
-82
lines changed

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -889,7 +889,18 @@ Get tasks and export as YOLO format files.
889889

890890
```python
891891
tasks = client.get_image_tasks(project="YOUR_PROJECT_SLUG")
892-
client.export_yolo(tasks)
892+
client.export_yolo(tasks, output_dir="YOUR_DIRECTROY")
893+
```
894+
895+
Get tasks and export as YOLO format files with classes.txt
896+
You can use fixed classes.txt and arrange order of each annotaiton file's order
897+
898+
```python
899+
project_slug = "YOUR_PROJECT_SLUG"
900+
tasks = client.get_image_tasks(project=project_slug)
901+
annotations = client.get_annotations(project=project_slug)
902+
classes = list(map(lambda annotation: annotation["value"], annotations))
903+
client.export_yolo(tasks=tasks, classes=classes, output_dir="YOUR_DIRECTROY")
893904
```
894905

895906
### Pascal VOC

fastlabel/__init__.py

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -529,7 +529,8 @@ def create_video_classification_task(
529529
"""
530530
endpoint = "tasks/video/classification"
531531
if not utils.is_video_supported_ext(file_path):
532-
raise FastLabelInvalidException("Supported extensions are mp4.", 422)
532+
raise FastLabelInvalidException(
533+
"Supported extensions are mp4.", 422)
533534
file = utils.base64_encode(file_path)
534535
payload = {"project": project, "name": name, "file": file}
535536
if status:
@@ -651,14 +652,17 @@ def export_coco(self, tasks: list, output_dir: str = os.path.join("output", "coc
651652
with open(file_path, 'w') as f:
652653
json.dump(coco, f, indent=4, ensure_ascii=False)
653654

654-
def export_yolo(self, tasks: list, output_dir: str = os.path.join("output", "yolo")) -> None:
655+
def export_yolo(self, tasks: list, classes: list = [], output_dir: str = os.path.join("output", "yolo")) -> None:
655656
"""
656657
Convert tasks to YOLO format and export as files.
658+
If you pass classes, classes.txt will be generated based on it .
659+
If not , classes.txt will be generated based on passed tasks .(Annotations never used in your project will not be exported.)
657660
658661
tasks is a list of tasks. (Required)
662+
classes is a list of annotation values. e.g. ['dog','bird'] (Optional)
659663
output_dir is output directory(default: output/yolo). (Optional)
660664
"""
661-
annos, categories = converters.to_yolo(tasks)
665+
annos, categories = converters.to_yolo(tasks, classes)
662666
for anno in annos:
663667
file_name = anno["filename"]
664668
basename = utils.get_basename(file_name)
@@ -709,12 +713,12 @@ def export_labelme(self, tasks: list, output_dir: str = os.path.join("output", "
709713
with open(file_path, 'w') as f:
710714
json.dump(labelme, f, indent=4, ensure_ascii=False)
711715

712-
713716
# Instance / Semantic Segmetation
717+
714718
def export_instance_segmentation(self, tasks: list, output_dir: str = os.path.join("output", "instance_segmentation"), pallete: List[int] = const.COLOR_PALETTE) -> None:
715719
"""
716720
Convert tasks to index color instance segmentation (PNG files).
717-
Supports only bbox, polygon and segmentation annotation types. Hollowed points are not supported.
721+
Supports only bbox, polygon and segmentation annotation types.
718722
Supports up to 57 instances in default colors palette. Check const.COLOR_PALETTE for more details.
719723
720724
tasks is a list of tasks. (Required)
@@ -723,12 +727,13 @@ def export_instance_segmentation(self, tasks: list, output_dir: str = os.path.jo
723727
"""
724728
tasks = converters.to_pixel_coordinates(tasks)
725729
for task in tasks:
726-
self.__export_index_color_image(task=task, output_dir=output_dir, pallete=pallete, is_instance_segmentation=True)
727-
730+
self.__export_index_color_image(
731+
task=task, output_dir=output_dir, pallete=pallete, is_instance_segmentation=True)
732+
728733
def export_semantic_segmentation(self, tasks: list, output_dir: str = os.path.join("output", "semantic_segmentation"), pallete: List[int] = const.COLOR_PALETTE) -> None:
729734
"""
730735
Convert tasks to index color semantic segmentation (PNG files).
731-
Supports only bbox, polygon and segmentation annotation types. Hollowed points are not supported.
736+
Supports only bbox, polygon and segmentation annotation types.
732737
Check const.COLOR_PALETTE for color pallete.
733738
734739
tasks is a list of tasks. (Required)
@@ -744,7 +749,8 @@ def export_semantic_segmentation(self, tasks: list, output_dir: str = os.path.jo
744749

745750
tasks = converters.to_pixel_coordinates(tasks)
746751
for task in tasks:
747-
self.__export_index_color_image(task=task, output_dir=output_dir, pallete=pallete, is_instance_segmentation=False, classes=classes)
752+
self.__export_index_color_image(
753+
task=task, output_dir=output_dir, pallete=pallete, is_instance_segmentation=False, classes=classes)
748754

749755
def __export_index_color_image(self, task: list, output_dir: str, pallete: List[int], is_instance_segmentation: bool = True, classes: list = []) -> None:
750756
image = Image.new("RGB", (task["width"], task["height"]), 0)
@@ -753,28 +759,39 @@ def __export_index_color_image(self, task: list, output_dir: str, pallete: List[
753759

754760
index = 1
755761
for annotation in task["annotations"]:
756-
color = index if is_instance_segmentation else classes.index(annotation["value"]) + 1
762+
color = index if is_instance_segmentation else classes.index(
763+
annotation["value"]) + 1
757764
if annotation["type"] == AnnotationType.segmentation.value:
758765
for region in annotation["points"]:
759766
count = 0
760767
for points in region:
761-
cv_draw_points = self.__get_cv_draw_points(points)
762768
if count == 0:
763-
cv2.fillPoly(image, [cv_draw_points], color, lineType=cv2.LINE_8, shift=0)
769+
cv_draw_points = self.__get_cv_draw_points(points)
770+
cv2.fillPoly(
771+
image, [cv_draw_points], color, lineType=cv2.LINE_8, shift=0)
764772
else:
765-
cv2.fillPoly(image, [cv_draw_points], 0, lineType=cv2.LINE_8, shift=0)
773+
# Reverse hollow points for opencv because this points are counter clockwise
774+
cv_draw_points = self.__get_cv_draw_points(
775+
utils.reverse_points(points))
776+
cv2.fillPoly(
777+
image, [cv_draw_points], 0, lineType=cv2.LINE_8, shift=0)
766778
count += 1
767779
elif annotation["type"] == AnnotationType.polygon.value:
768-
cv_draw_points = self.__get_cv_draw_points(annotation["points"])
769-
cv2.fillPoly(image, [cv_draw_points], color, lineType=cv2.LINE_8, shift=0)
780+
cv_draw_points = self.__get_cv_draw_points(
781+
annotation["points"])
782+
cv2.fillPoly(image, [cv_draw_points], color,
783+
lineType=cv2.LINE_8, shift=0)
770784
elif annotation["type"] == AnnotationType.bbox.value:
771-
cv_draw_points = self.__get_cv_draw_points(annotation["points"])
772-
cv2.fillPoly(image, [cv_draw_points], color, lineType=cv2.LINE_8, shift=0)
785+
cv_draw_points = self.__get_cv_draw_points(
786+
annotation["points"])
787+
cv2.fillPoly(image, [cv_draw_points], color,
788+
lineType=cv2.LINE_8, shift=0)
773789
else:
774790
continue
775791
index += 1
776792

777-
image_path = os.path.join(output_dir, utils.get_basename(task["name"]) + ".png")
793+
image_path = os.path.join(
794+
output_dir, utils.get_basename(task["name"]) + ".png")
778795
os.makedirs(os.path.dirname(image_path), exist_ok=True)
779796
image = Image.fromarray(image)
780797
image = image.convert('P')
@@ -823,7 +840,6 @@ def __get_cv_draw_points(self, points: List[int]) -> List[int]:
823840
cv_points.append((new_points[i * 2], new_points[i * 2 + 1]))
824841
return np.array(cv_points)
825842

826-
827843
# Annotation
828844

829845
def find_annotation(self, annotation_id: str) -> dict:

fastlabel/converters.py

Lines changed: 137 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import copy
55
import geojson
66
import numpy as np
7+
import math
78
from fastlabel.const import AnnotationType
89

910
# COCO
@@ -144,10 +145,12 @@ def __calc_area(annotation_type: str, points: list) -> float:
144145
# YOLO
145146

146147

147-
def to_yolo(tasks: list) -> tuple:
148-
coco = to_coco(tasks)
149-
yolo = __coco2yolo(coco)
150-
return yolo
148+
def to_yolo(tasks: list, classes: list) -> tuple:
149+
if len(classes) == 0:
150+
coco = to_coco(tasks)
151+
return __coco2yolo(coco)
152+
else:
153+
return __to_yolo(tasks, classes)
151154

152155

153156
def __coco2yolo(coco: dict) -> tuple:
@@ -198,6 +201,69 @@ def __coco2yolo(coco: dict) -> tuple:
198201
return annos, categories
199202

200203

204+
def __to_yolo(tasks: list, classes: list) -> tuple:
205+
annos = []
206+
for task in tasks:
207+
if task["height"] == 0 or task["width"] == 0:
208+
continue
209+
objs = []
210+
data = [{"annotation": annotation, "task": task, "classes": classes}
211+
for annotation in task["annotations"]]
212+
with ThreadPoolExecutor(max_workers=8) as executor:
213+
results = executor.map(__get_yolo_annotation, data)
214+
for result in results:
215+
if not result:
216+
continue
217+
objs.append(" ".join(result))
218+
anno = {
219+
"filename": task["name"],
220+
"object": objs
221+
}
222+
annos.append(anno)
223+
224+
categories = map(lambda val: {"name": val}, classes)
225+
226+
return annos, categories
227+
228+
229+
def __get_yolo_annotation(data: dict) -> dict:
230+
annotation = data["annotation"]
231+
points = annotation["points"]
232+
annotation_type = annotation["type"]
233+
value = annotation["value"]
234+
classes = list(data["classes"])
235+
task = data["task"]
236+
if annotation_type != AnnotationType.bbox.value and annotation_type != AnnotationType.polygon.value:
237+
return None
238+
if not points or len(points) == 0:
239+
return None
240+
if annotation_type == AnnotationType.bbox.value and (int(points[0]) == int(points[2]) or int(points[1]) == int(points[3])):
241+
return None
242+
if not annotation["value"] in classes:
243+
return None
244+
245+
dw = 1. / task["width"]
246+
dh = 1. / task["height"]
247+
248+
bbox = __to_bbox(points)
249+
xmin = bbox[0]
250+
ymin = bbox[1]
251+
xmax = bbox[0] + bbox[2]
252+
ymax = bbox[1] + bbox[3]
253+
254+
x = (xmin + xmax) / 2
255+
y = (ymin + ymax) / 2
256+
w = xmax - xmin
257+
h = ymax - ymin
258+
259+
x = str(_truncate(x * dw, 7))
260+
y = str(_truncate(y * dh, 7))
261+
w = str(_truncate(w * dw, 7))
262+
h = str(_truncate(h * dh, 7))
263+
category_index = str(classes.index(value))
264+
return [category_index, x, y, w, h]
265+
266+
201267
def _truncate(n, decimals=0) -> float:
202268
multiplier = 10 ** decimals
203269
return int(n * multiplier) / multiplier
@@ -207,10 +273,75 @@ def _truncate(n, decimals=0) -> float:
207273

208274

209275
def to_pascalvoc(tasks: list) -> list:
210-
coco = to_coco(tasks)
211-
pascalvoc = __coco2pascalvoc(coco)
276+
pascalvoc = []
277+
for task in tasks:
278+
if task["height"] == 0 or task["width"] == 0:
279+
continue
280+
281+
pascal_objs = []
282+
data = [{"annotation": annotation}
283+
for annotation in task["annotations"]]
284+
with ThreadPoolExecutor(max_workers=8) as executor:
285+
results = executor.map(__get_pascalvoc_obj, data)
286+
287+
for result in results:
288+
if not result:
289+
continue
290+
pascal_objs.append(result)
291+
292+
voc = {
293+
"annotation": {
294+
"filename": task["name"],
295+
"size": {
296+
"width": task["width"],
297+
"height": task["height"],
298+
"depth": 3,
299+
},
300+
"segmented": 0,
301+
"object": pascal_objs
302+
}
303+
}
304+
pascalvoc.append(voc)
212305
return pascalvoc
213306

307+
def __get_pascalvoc_obj(data: dict) -> dict:
308+
annotation = data["annotation"]
309+
points = annotation["points"]
310+
annotation_type = annotation["type"]
311+
if annotation_type != AnnotationType.bbox.value and annotation_type != AnnotationType.polygon.value:
312+
return None
313+
if not points or len(points) == 0:
314+
return None
315+
if annotation_type == AnnotationType.bbox.value and (int(points[0]) == int(points[2]) or int(points[1]) == int(points[3])):
316+
return None
317+
bbox = __to_bbox(points)
318+
x = bbox[0]
319+
y = bbox[1]
320+
w = bbox[2]
321+
h = bbox[3]
322+
323+
return {
324+
"name": annotation["value"],
325+
"pose": "Unspecified",
326+
"truncated": __get_pascalvoc_tag_value(annotation, "truncated"),
327+
"occluded": __get_pascalvoc_tag_value(annotation, "occluded"),
328+
"difficult": __get_pascalvoc_tag_value(annotation, "difficult"),
329+
"bndbox": {
330+
"xmin": math.floor(x),
331+
"ymin": math.floor(y),
332+
"xmax": math.floor(x + w),
333+
"ymax": math.floor(y + h),
334+
},
335+
}
336+
337+
def __get_pascalvoc_tag_value(annotation: dict, target_tag_name: str) -> int:
338+
attributes = annotation["attributes"]
339+
if not attributes:
340+
return 0
341+
related_attr = next(
342+
(attribute for attribute in attributes if attribute["type"] == "switch" and attribute["key"] == target_tag_name), None)
343+
return int(related_attr["value"]) if related_attr else 0
344+
214345

215346
# labelme
216347

@@ -388,58 +519,3 @@ def __get_pixel_coordinates(points: List[int or float]) -> List[int]:
388519
new_points.append(int(prev_x + int(xdiff / mindiff * (i + 1))))
389520
new_points.append(int(prev_y + int(ydiff / mindiff * (i + 1))))
390521
return new_points
391-
392-
def __coco2pascalvoc(coco: dict) -> list:
393-
pascalvoc = []
394-
395-
for image in coco["images"]:
396-
397-
# Get objects
398-
objs = []
399-
for annotation in coco["annotations"]:
400-
if image["id"] != annotation["image_id"]:
401-
continue
402-
category = _get_category_by_id(
403-
coco["categories"], annotation["category_id"])
404-
405-
x = annotation["bbox"][0]
406-
y = annotation["bbox"][1]
407-
w = annotation["bbox"][2]
408-
h = annotation["bbox"][3]
409-
410-
obj = {
411-
"name": category["name"],
412-
"pose": "Unspecified",
413-
"truncated": 0,
414-
"difficult": 0,
415-
"bndbox": {
416-
"xmin": x,
417-
"ymin": y,
418-
"xmax": x + w,
419-
"ymax": y + h,
420-
},
421-
}
422-
objs.append(obj)
423-
424-
# get annotation
425-
voc = {
426-
"annotation": {
427-
"filename": image["file_name"],
428-
"size": {
429-
"width": image["width"],
430-
"height": image["height"],
431-
"depth": 3,
432-
},
433-
"segmented": 0,
434-
"object": objs
435-
}
436-
}
437-
pascalvoc.append(voc)
438-
439-
return pascalvoc
440-
441-
442-
def _get_category_by_id(categories: list, id_: str) -> str:
443-
category = [
444-
category for category in categories if category["id"] == id_][0]
445-
return category

fastlabel/utils.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,18 @@ def get_basename(file_path: str) -> str:
2121
path/to/file.jpg -> path/to/file
2222
"""
2323
return os.path.splitext(file_path)[0]
24+
25+
26+
def reverse_points(points: list[int]) -> list[int]:
27+
"""
28+
e.g.)
29+
[4, 5, 4, 9, 8, 9, 8, 5, 4, 5] => [4, 5, 8, 5, 8, 9, 4, 9, 4, 5]
30+
"""
31+
reversed_points = []
32+
for index, _ in enumerate(points):
33+
if index % 2 == 0:
34+
reversed_points.insert(
35+
0, points[index + 1])
36+
reversed_points.insert(
37+
0, points[index])
38+
return reversed_points

0 commit comments

Comments
 (0)