什么是VOC标注风格的xml文件
VOC指的是PASCAL VOC项目,这是一个主要包含了目标检测
和图像语义分割
任务的计算机视觉竞赛项目,从2005年举办至2012年。现在多用来作为检测和分割的入门练习。由于该数据集按年份累加,即VOC2012包含了2007-2012所有的数据(2005和2006不知为何没有包含在内),所以我们一般只使用VOC2012。
VOC2012共有17125张图片,以今日(2020年)的眼光来看,这个数据量是相当之少了,所以类似分类任务中的MNIST一样,已经变成了入门练习用的数据集。不过至少2015年之前VOC2012还是给大家提供了非常不错的研究素材,产生过很多优秀的文章。现在大家更多地会去玩COCO数据集。
VOC目标检测任务提供的标注信息是xml格式的文件,xml文件复杂起来没个边,但是VOC的xml标注文件相对比较简单。它主要由两个级别的信息组成。第一个级别是图像一些整体信息,比如所在文件夹,文件名,尺寸等等,其中特别需要注意的是object字段。这第二个级别的信息其实就是object字段,object字段可以有多个,每一个表示图像中一个被标注的目标,因此每个object下面包含一些与目标相关的标注信息,比如目标类别,矩形框,特征点等。
object字段下面可能还包含其他级别的信息,比如矩形框的标注就可以再往下延伸一级,因为一个矩形框包含四个数字(xmin,ymin,xmax,ymax)。
下面是VOC2012的第一个xml文件,文件名2007_000027.xml
,图片及标注信息如下:
<annotation>
<folder>VOC2012</folder>
<filename>2007_000027.jpg</filename>
<source>
<database>The VOC2007 Database</database>
<annotation>PASCAL VOC2007</annotation>
<image>flickr</image>
</source>
<size>
<width>486</width>
<height>500</height>
<depth>3</depth>
</size>
<segmented>0</segmented>
<object>
<name>person</name>
<pose>Unspecified</pose>
<truncated>0</truncated>
<difficult>0</difficult>
<bndbox>
<xmin>174</xmin>
<ymin>101</ymin>
<xmax>349</xmax>
<ymax>351</ymax>
</bndbox>
<part>
<name>head</name>
<bndbox>
<xmin>169</xmin>
<ymin>104</ymin>
<xmax>209</xmax>
<ymax>146</ymax>
</bndbox>
</part>
<part>
<name>hand</name>
<bndbox>
<xmin>278</xmin>
<ymin>210</ymin>
<xmax>297</xmax>
<ymax>233</ymax>
</bndbox>
</part>
<part>
<name>foot</name>
<bndbox>
<xmin>273</xmin>
<ymin>333</ymin>
<xmax>297</xmax>
<ymax>354</ymax>
</bndbox>
</part>
<part>
<name>foot</name>
<bndbox>
<xmin>319</xmin>
<ymin>307</ymin>
<xmax>340</xmax>
<ymax>326</ymax>
</bndbox>
</part>
</object>
</annotation>
画图的代码如下,因为还没有开始解析xml文件,所以代码比较简单粗暴,直接把信息填到cv2命令中去。
import cv2
img = cv2.imread('2007_000027.jpg', cv2.IMREAD_UNCHANGED)
color = (0, 255, 0)
thickness = 2
cv2.rectangle(img, (174, 101), (349, 351), color, thickness) # person
cv2.rectangle(img, (169, 104), (209, 146), color, thickness) # head
cv2.rectangle(img, (278, 210), (297, 233), color, thickness) # hand
cv2.rectangle(img, (273, 333), (297, 354), color, thickness) # foot
cv2.rectangle(img, (319, 307), (340, 326), color, thickness) # foot
cv2.imshow('VOC', img)
cv2.waitKey()
cv2.destroyAllWindows()
下面看一下标注信息的结构。
annotation就不说了,VOC风格的xml全部以annotation打头,我们姑且称之为第零级别的字段吧。
第一级别的字段有folder, filename, segmented, source, size, object (repeated)
其中object可以有多个,但是在这个例子中只有一个,可以有多个的字段用repeated表示。其中source, size
有且只有第二级字段。object
下面有二级或者二级以上的字段,所以对解析来讲最不好处理的地方就在于此,object下面的字段级别不定。
如果object的类别是person
的话,那么还可以进而标注其他身体部位,也就是其下的part
字段,可以是head, hand, foot
,所以part字段也可以有多个。
annotation
folder
filename
segmented
source
database
annotation
image
size
width
height
depth
object (repeated)
name
pose
truncated
difficult
bndbox
xmin
ymin
xmax
ymax
part (repeated)
name
bndbox
xmin
ymin
xmax
ymax
xml文件解析模块
python有一些内置的库可以帮助解析xml文件,但是当我们想要使用解析结果时就会发现不是很方便,所以一般都需要再封装一下,以方便使用。
这里使用xml.etree.ElementTree
进行最初的解析,解析代码很简单,一句话即可:
import xml.etree.ElementTree as XmlET
info = XmlET.parse('2007_000027.xml').getroot()
接下来我们当然是希望能够从info中把标注信息拿出来用,然后就会发现,真是相当之不方便,因为没有一个类似get
功能的方法可以直接获取到标注信息的值,可能是因为xml文件的信息级别一般不定吧。
info
本身是第零级字段,即annotation,想要获得字段的名字,使用tag
成员变量,想要获得字段的值,使用text
成员变量,这两个成员变量非常重要,这是我们封装解析结果的基础。其中tag
比较容易理解,它就是当前字段尖括号内的部分,即annotation。而text
略微需要一点主意,它表示当前字段的尖括号和下一个尖括号之间的部分,比如info.text
就是<annotation>和<folder>
之间的字符,也就是\n\t
,一个换行加一个tab。如果我们继续向下获得folder字段的话,folder.text
就等于<folder>和</folder>
之间的部分,即VOC2012
。所以我们可以看出来了,如果一个字段存在下一级字段的话,那么它的text
通常没什么意义,一般就是一个换行符加上n个tab符。
在进行封装之前,我们写一个打印标注信息的函数,以增强对标注信息结构的理解。由于标注信息的级别不定,所以想要遍历所有字段并打印的话,比较好的方式就是进行递归,深度优先那种。在打印时有两种特殊情况
需要处理,一个是存在下一级字段的情况(其text值的第一个字符是换行符),另一个是当前字段的值为空的情况(其text值为None)。原始标注信息里没有字段为空的情况,但我们可以构造一个,比如我们可以把标注信息中<segmented>0</segmented>
中间的0
删掉,变成<segmented></segmented>
。
打印代码如下:
import xml.etree.ElementTree as XmlET
def print_xml(info):
def __print(info, level=1):
for elem in list(info):
indent = '\t' * level
if elem.text is None:
print(indent, '<%s></%s>' % (elem.tag, elem.tag))
elif elem.text.strip(' ').strip('\t')[0] == '\n':
# there is sub elements
print(indent, '<%s>' % elem.tag)
__print(elem, level + 1)
print(indent, '</%s>' % elem.tag)
else:
print(indent, '<%s>%s</%s>' % (elem.tag, elem.text, elem.tag))
print('<annotation>')
__print(info)
print('</annotation>')
info = XmlET.parse('2007_000027.xml').getroot()
print_xml(info)
打印结果就不再重新贴一遍了,下面我们就开始封装标注信息,使其方便被调用。
再封装
解析使用的基本数据类型是dict
,如果某个字段可以重复多次的话,那么在dict基础上将其装入list
。xml.etree.ElementTree
解析出来的所有信息都是str
类型,但是标注信息最有价值的部分往往是数字,所以我个人觉得能转数字的可以都转成数字,也方便后续使用,是否需要转数字需要单独写一个函数进行判断,考虑到回归任务可能出现浮点数,字符串自带的isdigit(),isdecimal()等等均无法满足需求,它们只能判断纯数字或者整数的情况。
再封装仍然要使用递归的方法获取各个字段的tag和text。
代码如下(文件名保存为xml_parser.py
):
# -*- coding: utf-8 -*-
import xml.etree.ElementTree as XmlET
class VocXmlParser(object):
def __init__(self):
# header is root tag of xml file, normally it is 'annotation' for a
# VOC style xml file
self.header = ''
self.info = {}
def __getitem__(self, level_one_tag):
return self.info[level_one_tag]
def __setitem__(self, key, value):
self.info[key] = value
def clear(self):
"""
clear the content of parser
"""
self.header = ''
self.info = {}
def parse(self, filename):
"""
parse a xml file
"""
def _process_text(str_in):
"""
convert string into number if possible
"""
try:
output = float(str_in)
if output == int(output):
output = int(output)
return output
except ValueError:
return str_in
def _add_key(_dict, key, value):
"""
add key-value pair into a dict. if key already exists then append
value to dict[key], if dict[key] is not a list, turn it into list
first. is key does not exist then just make a normal assignment
"""
if key in _dict.keys():
if not isinstance(_dict[key], list):
_dict[key] = [_dict.get(key)]
_dict[key].append(value)
else:
_dict[key] = value
def _parse(elements):
"""
recursively parse the result from xml.etree.ElementTree
"""
out = {}
for elem in list(elements):
if elem.text is None:
out[elem.tag] = ''
elif elem.text.strip(' ').strip('\t')[0] == '\n':
# there is sub elements
_add_key(out, elem.tag, _parse(elem))
else:
_add_key(out, elem.tag, _process_text(elem.text))
return out
root = XmlET.parse(filename).getroot()
self.header = root.tag
self.info = _parse(root)
def set_as_list(self, tag, level=None):
"""
for a nested dict, set value of element(s) into list if its key equals
to tag. if level is None, then recursively set all eligible elements,
else only set those whose nested depth equal to level
"""
def _set(_dict, level, cnt=1):
flag = level is not None
if not isinstance(_dict, dict):
return
if flag and cnt > level:
return
for key in _dict:
if (key == tag) and (not isinstance(_dict.get(key), list)):
if (not flag) or (flag and cnt == level):
_dict[key] = [_dict.get(key)]
_set(_dict.get(key), level, cnt + 1)
_set(self.info, level)
def to_string(self):
"""
convert self.info to string
"""
def _to_string(_dict, level=1):
out = ''
for key in _dict:
indent = '\t' * level
if isinstance(_dict.get(key), dict):
out += indent + '<%s>\n' % (key)
out += _to_string(_dict.get(key), level + 1)
out += indent + '</%s>\n' % (key)
elif isinstance(_dict.get(key), list):
for item in _dict.get(key):
out += indent + '<%s>\n' % (key)
out += _to_string(item, level + 1)
out += indent + '</%s>\n' % (key)
else:
out += indent + '<%s>%s</%s>\n' % (
key, str(_dict.get(key)), key)
return out
out = '<%s>\n' % self.header
out += _to_string(self.info)
out += '</%s>\n' % self.header
return out
def write(self, filename):
"""
save self.info to disk
"""
with open(filename, 'w') as fid:
fid.write(self.to_string())
if __name__ == '__main__':
filename = '2007_000027.xml'
xml = VocXmlParser()
xml.parse(filename)
print(xml.info) # ==> {'folder': 'VOC2012', 'filename': '2007_000027.jpg' ...
print(xml['filename']) # ==> 2007_000027.jpg
print(xml['object']) # ==> {'name': 'person', 'pose': 'Unspecified', 'truncated': 0 ...
print(xml['object']['part'][0]['bndbox']) # ==> {'xmin': 169, 'ymin': 104, 'xmax': 209, 'ymax': 146}
xml.set_as_list('object', 1)
print(xml['object']) # ==> [{'name': 'person', 'pose': 'Unspecified', 'truncated': 0 ...
xml.write('temp.xml')
解析的结果是一个多重嵌套的字典,所以就按照字典的调用方式来使用即可(还是有些累赘,要敲很多中括号和引号)。
程序设计的部分逻辑或功能:
- VOC风格的xml文件的最外层字段叫
annotation
,通常没什么用,所以专门准备了一个成员变量进行保存,省的调用时候还需要多码一些字。 - 在解析时,所有能转成数字的信息都会被转换,这是为了方便使用。转换函数是
_process_text()
,该函数首先考虑是否能转float,能的话进一步看看是否能转int,如果不能转float就保持原有的str类型返回即可。 - parse函数中,如果在某个作用域内相同的(缩进)级别上解析到多个相同的关键字,比如object中的part,那么会自动将其转换成
list
,这样才能保存多份。但是这样会导致问题,比如object字段,有些图片中只有一个object,那么解析到的object的类型就是dict;有些图片中可能有多个object,那么解析到的object的类型就是list,list的元素才是dict。这就给使用带来了麻烦,因为是否为list决定了我们在使用时,是否需要加索引,这种不统一显然易导致bug。 set_as_list()
函数可以将某个字段变成list
类型,这个功能非常关键,可解决parse()
中的遗留问题。使用该函数可以将某个字段转成list,比如本文档例子中object只有一个,所以parse()
解析的结果,object的类型是dict,现在我们可以使用set_as_list()
将object转成list,这样我们使用时就统一了:都要加索引。上面的测试代码中有两处print(xml['object'])
,第一个结果的类型是dict,第二个是list(在最外层套了个中括号)。- 这里重写了
__getitem__()
和__setitem__()
函数,所以无论调用还是赋值,都不需要把成员函数info
再码出来,直接使用如xml['filename']
的形式,而不必使用xml.info['filename']
。
画标注信息
接下来我们就不需要像之前那样很粗暴地把标注信息一个一个填进opencv函数里去画标注信息了,现在可以写个函数统一进行画图,代码如下:
# -*- coding: utf-8 -*-
import cv2
from xml_parser import VocXmlParser
ARGS = {'color': (0, 255, 0),
'thickness': 2,
'fontFace': cv2.FONT_HERSHEY_SIMPLEX,
'fontScale': 0.8,
'x_offset': 0,
'y_offset': -10}
def get_bndbox(bndbox):
xmin = bndbox.get('xmin')
ymin = bndbox.get('ymin')
xmax = bndbox.get('xmax')
ymax = bndbox.get('ymax')
return xmin, ymin, xmax, ymax
def draw_label(image, label, args):
for obj in label['object']:
# draw bounding box for object
if 'bndbox' not in obj.keys():
continue
xmin, ymin, xmax, ymax = get_bndbox(obj.get('bndbox'))
cv2.rectangle(image, (xmin, ymin), (xmax, ymax),
args.get('color'), args.get('thickness'))
cv2.putText(image, obj.get('name'),
(xmin + args.get('x_offset'), ymin + args.get('y_offset')),
args.get('fontFace'), args.get('fontScale'),
args.get('color'), args.get('thickness'))
# draw bounding box for part
if 'part' not in obj.keys():
continue
for part in obj.get('part'):
xmin, ymin, xmax, ymax = get_bndbox(part.get('bndbox'))
cv2.rectangle(image, (xmin, ymin), (xmax, ymax),
args.get('color'), args.get('thickness'))
cv2.putText(image, part.get('name'),
(xmin + args.get('x_offset'),
ymin + args.get('y_offset')),
args.get('fontFace'), args.get('fontScale'),
args.get('color'), args.get('thickness'))
return image
if __name__ == '__main__':
# parse xml file to get label
xml = VocXmlParser()
xml.parse('2007_000027.xml')
xml.set_as_list('object', 1)
xml.set_as_list('part', 2)
# read image and draw label on image
img = cv2.imread('2007_000027.jpg', cv2.IMREAD_UNCHANGED)
img = draw_label(img, xml, ARGS)
# show image
cv2.imshow('VOC', img)
cv2.waitKey()
cv2.destroyAllWindows()
cv2.imwrite('2007_000027_draw_label.jpg', img)
这里使用了两张图作为例子,前面的美女和2007_000480.jpg
(三壮汉看飞机):