Skip to content

Commit 112bab1

Browse files
zhiltsov-maxnmanovic
authored andcommitted
Add LabelMe format support (#844)
* Add labelme export * Add LabelMe import * Add labelme format to readme * Updated CHANGELOG.md
1 parent 98e851a commit 112bab1

File tree

4 files changed

+312
-2
lines changed

4 files changed

+312
-2
lines changed

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
- Ability to [get basic information about users without admin permissions](
1111
https://github.com/opencv/cvat/issues/750).
1212
- Changed REST API: removed PUT and added DELETE methods for /api/v1/users/ID.
13-
- Added Mask-RCNN Auto Annotation Script in OpenVINO format
14-
- Added Yolo Auto Annotation Script
13+
- Mask-RCNN Auto Annotation Script in OpenVINO format
14+
- Yolo Auto Annotation Script
1515
- Auto segmentation using Mask_RCNN component (Keras+Tensorflow Mask R-CNN Segmentation)
16+
- Ability to dump/load annotations in LabelMe format from UI
1617

1718
### Changed
1819
-

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Format selection is possible after clicking on the Upload annotation / Dump anno
4343
| [MS COCO Object Detection](http://cocodataset.org/#format-data) | X | X |
4444
| PNG mask | X | |
4545
| [TFrecord](https://www.tensorflow.org/tutorials/load_data/tf_records) | X | X |
46+
| [LabelMe](http://labelme.csail.mit.edu/Release3.0) | X | X |
4647

4748
## Links
4849
- [Intel AI blog: New Computer Vision Tool Accelerates Annotation of Digital Images and Video](https://www.intel.ai/introducing-cvat)

cvat/apps/annotation/labelme.py

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
# Copyright (C) 2019 Intel Corporation
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
format_spec = {
6+
"name": "LabelMe",
7+
"dumpers": [
8+
{
9+
"display_name": "{name} {format} {version} for images",
10+
"format": "ZIP",
11+
"version": "3.0",
12+
"handler": "dump_as_labelme_annotation"
13+
}
14+
],
15+
"loaders": [
16+
{
17+
"display_name": "{name} {format} {version}",
18+
"format": "ZIP",
19+
"version": "3.0",
20+
"handler": "load",
21+
}
22+
],
23+
}
24+
25+
26+
_DEFAULT_USERNAME = 'cvat'
27+
_MASKS_DIR = 'Masks'
28+
29+
30+
def dump_frame_anno(frame_annotation):
31+
from collections import defaultdict
32+
from lxml import etree as ET
33+
34+
root_elem = ET.Element('annotation')
35+
36+
ET.SubElement(root_elem, 'filename').text = frame_annotation.name
37+
ET.SubElement(root_elem, 'folder').text = ''
38+
39+
source_elem = ET.SubElement(root_elem, 'source')
40+
ET.SubElement(source_elem, 'sourceImage').text = ''
41+
ET.SubElement(source_elem, 'sourceAnnotation').text = 'CVAT'
42+
43+
image_elem = ET.SubElement(root_elem, 'imagesize')
44+
ET.SubElement(image_elem, 'nrows').text = str(frame_annotation.height)
45+
ET.SubElement(image_elem, 'ncols').text = str(frame_annotation.width)
46+
47+
groups = defaultdict(list)
48+
49+
for obj_id, shape in enumerate(frame_annotation.labeled_shapes):
50+
obj_elem = ET.SubElement(root_elem, 'object')
51+
ET.SubElement(obj_elem, 'name').text = str(shape.label)
52+
ET.SubElement(obj_elem, 'deleted').text = '0'
53+
ET.SubElement(obj_elem, 'verified').text = '0'
54+
ET.SubElement(obj_elem, 'occluded').text = \
55+
'yes' if shape.occluded else 'no'
56+
ET.SubElement(obj_elem, 'date').text = ''
57+
ET.SubElement(obj_elem, 'id').text = str(obj_id)
58+
59+
parts_elem = ET.SubElement(obj_elem, 'parts')
60+
if shape.group:
61+
groups[shape.group].append((obj_id, parts_elem))
62+
else:
63+
ET.SubElement(parts_elem, 'hasparts').text = ''
64+
ET.SubElement(parts_elem, 'ispartof').text = ''
65+
66+
if shape.type == 'rectangle':
67+
ET.SubElement(obj_elem, 'type').text = 'bounding_box'
68+
69+
poly_elem = ET.SubElement(obj_elem, 'polygon')
70+
x0, y0, x1, y1 = shape.points
71+
points = [ (x0, y0), (x1, y0), (x1, y1), (x0, y1) ]
72+
for x, y in points:
73+
point_elem = ET.SubElement(poly_elem, 'pt')
74+
ET.SubElement(point_elem, 'x').text = '%.2f' % x
75+
ET.SubElement(point_elem, 'y').text = '%.2f' % y
76+
77+
ET.SubElement(poly_elem, 'username').text = _DEFAULT_USERNAME
78+
elif shape.type == 'polygon':
79+
poly_elem = ET.SubElement(obj_elem, 'polygon')
80+
for x, y in zip(shape.points[::2], shape.points[1::2]):
81+
point_elem = ET.SubElement(poly_elem, 'pt')
82+
ET.SubElement(point_elem, 'x').text = '%.2f' % x
83+
ET.SubElement(point_elem, 'y').text = '%.2f' % y
84+
85+
ET.SubElement(poly_elem, 'username').text = _DEFAULT_USERNAME
86+
elif shape.type == 'polyline':
87+
pass
88+
elif shape.type == 'points':
89+
pass
90+
else:
91+
raise NotImplementedError("Unknown shape type '%s'" % shape.type)
92+
93+
attrs = ['%s=%s' % (a.name, a.value) for a in shape.attributes]
94+
ET.SubElement(obj_elem, 'attributes').text = ', '.join(attrs)
95+
96+
for _, group in groups.items():
97+
leader_id, leader_parts_elem = group[0]
98+
leader_parts = [str(o_id) for o_id, _ in group[1:]]
99+
ET.SubElement(leader_parts_elem, 'hasparts').text = \
100+
','.join(leader_parts)
101+
ET.SubElement(leader_parts_elem, 'ispartof').text = ''
102+
103+
for obj_id, parts_elem in group[1:]:
104+
ET.SubElement(parts_elem, 'hasparts').text = ''
105+
ET.SubElement(parts_elem, 'ispartof').text = str(leader_id)
106+
107+
return ET.tostring(root_elem, encoding='unicode', pretty_print=True)
108+
109+
def dump_as_labelme_annotation(file_object, annotations):
110+
from zipfile import ZipFile, ZIP_DEFLATED
111+
112+
with ZipFile(file_object, 'w', compression=ZIP_DEFLATED) as output_zip:
113+
for frame_annotation in annotations.group_by_frame():
114+
xml_data = dump_frame_anno(frame_annotation)
115+
filename = frame_annotation.name
116+
filename = filename[ : filename.rfind('.')] + '.xml'
117+
output_zip.writestr(filename, xml_data)
118+
119+
def parse_xml_annotations(xml_data, annotations, input_zip):
120+
from cvat.apps.annotation.coco import mask_to_polygon
121+
from io import BytesIO
122+
from lxml import etree as ET
123+
import numpy as np
124+
import os.path as osp
125+
from PIL import Image
126+
127+
def parse_attributes(attributes_string):
128+
parsed = []
129+
if not attributes_string:
130+
return parsed
131+
132+
read = attributes_string.split(',')
133+
read = [a.strip() for a in read if a.strip()]
134+
for attr in read:
135+
if '=' in attr:
136+
name, value = attr.split('=', maxsplit=1)
137+
parsed.append(annotations.Attribute(name, value))
138+
else:
139+
parsed.append(annotations.Attribute(attr, '1'))
140+
141+
return parsed
142+
143+
144+
root_elem = ET.fromstring(xml_data)
145+
146+
frame_number = annotations.match_frame(root_elem.find('filename').text)
147+
148+
parsed_annotations = dict()
149+
group_assignments = dict()
150+
root_annotations = set()
151+
for obj_elem in root_elem.iter('object'):
152+
obj_id = int(obj_elem.find('id').text)
153+
154+
ann_items = []
155+
156+
attributes = []
157+
attributes_elem = obj_elem.find('attributes')
158+
if attributes_elem is not None and attributes_elem.text:
159+
attributes = parse_attributes(attributes_elem.text)
160+
161+
occluded = False
162+
occluded_elem = obj_elem.find('occluded')
163+
if occluded_elem is not None and occluded_elem.text:
164+
occluded = (occluded_elem.text == 'yes')
165+
166+
deleted = False
167+
deleted_elem = obj_elem.find('deleted')
168+
if deleted_elem is not None and deleted_elem.text:
169+
deleted = bool(int(deleted_elem.text))
170+
171+
poly_elem = obj_elem.find('polygon')
172+
segm_elem = obj_elem.find('segm')
173+
type_elem = obj_elem.find('type') # the only value is 'bounding_box'
174+
if poly_elem is not None:
175+
points = []
176+
for point_elem in poly_elem.iter('pt'):
177+
x = float(point_elem.find('x').text)
178+
y = float(point_elem.find('y').text)
179+
points.append(x)
180+
points.append(y)
181+
label = obj_elem.find('name').text
182+
if label and attributes:
183+
label_id = annotations._get_label_id(label)
184+
if label_id:
185+
attributes = [a for a in attributes
186+
if annotations._get_attribute_id(label_id, a.name)
187+
]
188+
else:
189+
attributes = []
190+
else:
191+
attributes = []
192+
193+
if type_elem is not None and type_elem.text == 'bounding_box':
194+
xmin = min(points[::2])
195+
xmax = max(points[::2])
196+
ymin = min(points[1::2])
197+
ymax = max(points[1::2])
198+
ann_items.append(annotations.LabeledShape(
199+
type='rectangle',
200+
frame=frame_number,
201+
label=label,
202+
points=[xmin, ymin, xmax, ymax],
203+
occluded=occluded,
204+
attributes=attributes,
205+
))
206+
else:
207+
ann_items.append(annotations.LabeledShape(
208+
type='polygon',
209+
frame=frame_number,
210+
label=label,
211+
points=points,
212+
occluded=occluded,
213+
attributes=attributes,
214+
))
215+
elif segm_elem is not None:
216+
label = obj_elem.find('name').text
217+
if label and attributes:
218+
label_id = annotations._get_label_id(label)
219+
if label_id:
220+
attributes = [a for a in attributes
221+
if annotations._get_attribute_id(label_id, a.name)
222+
]
223+
else:
224+
attributes = []
225+
else:
226+
attributes = []
227+
228+
mask_file = segm_elem.find('mask').text
229+
mask = input_zip.read(osp.join(_MASKS_DIR, mask_file))
230+
mask = np.asarray(Image.open(BytesIO(mask)).convert('L'))
231+
mask = (mask != 0)
232+
polygons = mask_to_polygon(mask)
233+
234+
for polygon in polygons:
235+
ann_items.append(annotations.LabeledShape(
236+
type='polygon',
237+
frame=frame_number,
238+
label=label,
239+
points=polygon,
240+
occluded=occluded,
241+
attributes=attributes,
242+
))
243+
244+
if not deleted:
245+
parsed_annotations[obj_id] = ann_items
246+
247+
parts_elem = obj_elem.find('parts')
248+
if parts_elem is not None:
249+
children_ids = []
250+
hasparts_elem = parts_elem.find('hasparts')
251+
if hasparts_elem is not None and hasparts_elem.text:
252+
children_ids = [int(c) for c in hasparts_elem.text.split(',')]
253+
254+
parent_ids = []
255+
ispartof_elem = parts_elem.find('ispartof')
256+
if ispartof_elem is not None and ispartof_elem.text:
257+
parent_ids = [int(c) for c in ispartof_elem.text.split(',')]
258+
259+
if children_ids and not parent_ids and hasparts_elem.text:
260+
root_annotations.add(obj_id)
261+
group_assignments[obj_id] = [None, children_ids]
262+
263+
# assign a single group to the whole subtree
264+
current_group_id = 0
265+
annotations_to_visit = list(root_annotations)
266+
while annotations_to_visit:
267+
ann_id = annotations_to_visit.pop()
268+
ann_assignment = group_assignments[ann_id]
269+
group_id, children_ids = ann_assignment
270+
if group_id:
271+
continue
272+
273+
if ann_id in root_annotations:
274+
current_group_id += 1 # start a new group
275+
276+
group_id = current_group_id
277+
ann_assignment[0] = group_id
278+
279+
# continue with children
280+
annotations_to_visit.extend(children_ids)
281+
282+
assert current_group_id == len(root_annotations)
283+
284+
for ann_id, ann_items in parsed_annotations.items():
285+
group_id = 0
286+
if ann_id in group_assignments:
287+
ann_assignment = group_assignments[ann_id]
288+
group_id = ann_assignment[0]
289+
290+
for ann_item in ann_items:
291+
if group_id:
292+
ann_item = ann_item._replace(group=group_id)
293+
if isinstance(ann_item, annotations.LabeledShape):
294+
annotations.add_shape(ann_item)
295+
else:
296+
raise NotImplementedError()
297+
298+
def load(file_object, annotations):
299+
from zipfile import ZipFile
300+
301+
with ZipFile(file_object, 'r') as input_zip:
302+
for filename in input_zip.namelist():
303+
if not filename.endswith('.xml'):
304+
continue
305+
306+
xml_data = input_zip.read(filename)
307+
parse_xml_annotations(xml_data, annotations, input_zip)

cvat/apps/annotation/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@
1212
os.path.join(path_prefix, 'coco.py'),
1313
os.path.join(path_prefix, 'mask.py'),
1414
os.path.join(path_prefix, 'tfrecord.py'),
15+
os.path.join(path_prefix, 'labelme.py'),
1516
)

0 commit comments

Comments
 (0)