0 项目背景

在PaddlePaddle系列套件的模型库中,有PPOCRLabel之于PaddleOCR、EISeg之于PaddleSeg的半自动标注解决方案,但是在适用场景相对最广、需求强烈的目标检测领域,一直缺少特别有效的解决方案。

注:EISeg的实例分割可以实现部分目标检测数据的标注,但相对于简单的矩形框标注而言,使用EISeg时间成本又比较高。

诚然,当前商用的目标检测数据标注选择还是比较多的,比如使用EasyDL进行快速标注。但在一些场景下,如因安全、成本、数据量等多种原因,数据上云受限。

因此,本系列项目,希望从目标检测方向切入,给出一种开源、本地及云上皆适用的数据标注解决方案。

当然,如果最后能形成PaddleDetection的半自动标注解决方案,那是再好不过啦,强烈欢迎各位大佬指导。https://ai-studio-static-online.cdn.bcebos.com/d500f483e55544a5b929abad59de208f180c068cc81648009fab60a0b6d9bda2

参考资料

本文有两个关联项目,关于生成VOC格式自动标注结果的关键代码、应用效果更详细的介绍,可参考:

虽然不是直接相关,但是一些思路有继承关系,可以帮助大家更好地理解。

1 整体思路

该项目从笔者面临的实际需求而来,如,笔者目前有2100张图片需要标注,标注工作量非常大。笔者先花了0.5个工作日,完成了6类目标共300张图片的标注,标签也相对平均分布。由于数据授权存在限制,在本项目中,使用平台现有的VOC示例数据集进行展示。

作为全系列的开篇,我们先走极简路线,选的是相对最为简单的标注方式:LabelImg。同时,用的标注框也是最简单的——标准矩形框。

整体思路如下:

  • 少量手动标注
    • 使用LabelImg,每个目标类别标注约50张图片
  • 训练检测模型
    • 使用PaddleDetection基于少量标注的数据集进行训练
    • 观察训练结果,如果mAP较为满意,保存预测模型
  • 生成自动标注
    • 使用基于少量标注训练的模型,对未标注数据进行预测,将预测结果保存为VOC格式
  • 矫正标注结果
    • 基于数据集特性,矫正目标标签
    • 导出预测数据的标注文件,回到LabelImg中,修正目标矩形框和标签

2 工具及数据集准备

# 获取示例数据集
!wget https://bj.bcebos.com/paddlex/datasets/insect_det.tar.gz
# 解压数据集
!tar -zxvf insect_det.tar.gz

作为PaddleX目标检测项目的示例,昆虫数据集其实是一个完整标注的数据集。

但是,我们探索的是基于少量标注的解决方案,并且,实际情况中,使用LabelImg逐一标完数据也是非常困难的,因此,这里假设只标注了昆虫数据集的前99张图片。

基于此,整个示例的数据集要进行重新划分。

# 删除100张之后的标注
!rm insect_det/Annotations/01*.xml
!rm insect_det/Annotations/02*.xml

这里,我们马上会遇到第一个问题:数据量和标注量不匹配。在这种情况下,使用PaddleX的数据集切分工具,是不能完成VOC数据划分的。

!pip install paddlex==1.3
!paddlex --split_dataset --format VOC --dataset_dir insect_det --val_value 0.2 --test_value 0.1
[1;31;40m2022-01-17 16:40:44 [ERROR]	The annotation file 0196.xml doesn't exist![0m[0m
[0m[0m

因此,我们需要将未标注的数据挪到测试集中。这对后续工作也是有好处的,毕竟到时候预测的时候,直接对测试集做预测、生成标注就行。

# 准备测试集目录
!mkdir insect_det/TESTImages
import os
import re
import shutil
# 获取文件名
def getfiles(ann_path):
    filenames = os.listdir(ann_path)
    return filenames
# 移动文件
def mymovefile(srcfile,dstfile):
    if not os.path.isfile(srcfile):
        print("src not exist!")
    else:
        fpath,fname=os.path.split(dstfile)    #分离文件名和路径
        if not os.path.exists(fpath):
            os.makedirs(fpath)                #创建路径
        shutil.move(srcfile,dstfile)          #移动文件
# 找到没有标注的文件并移动
def move_file(filePath,testPath):
    for file_path, empty_list, file_name_list in os.walk(filePath):
        # file_name_list该列表是存放目标目录中所有文件名
        for file_name in file_name_list:
            ann_list = getfiles(r'insect_det/Annotations')
            if (file_name[:-3]+'xml') not in ann_list:
                src = filePath+'/'+file_name
                dst = testPath+'/'+file_name
                print(src)
                mymovefile(src,dst)

move_file('insect_det/JPEGImages','insect_det/TESTImages')
# 划分手动标注的数据集
!paddlex --split_dataset --format VOC --dataset_dir insect_det --val_value 0.2 --test_value 0.0
Dataset Split Done.[0m
[0mTrain samples: 80[0m
[0mEval samples: 19[0m
[0mTest samples: 0[0m
[0mSplit files saved in insect_det[0m
[0m[0m

3 训练检测模型

这里目标就是模型能尽快收敛,以最快速度跑出一个效果过得去的baseline,比如本文选择用PaddleDetection/configs/yolov3/yolov3_darknet53_270e_voc.yml,原因也很简单:只需要改改数据集路径、调整下学习率即可,其它现成的配置文件都有了,不需要特别关心。

请注意,我们既然选择小样本标注,这一步就不太需要纠结于“炼丹”,模型效果过得去,能减少后续矫正时工作量就行——当然,如果基于小样本标注的模型效果很好,后面也省力;只是从概率上说,考虑到对于复杂数据集,基于小样本数据集的模型训练恐怕很难达到完美。个人建议,这个步骤还是遵循效率第一、效果第二的思路。

3.1 修改数据集路径配置

具体的配置文件处理上,第一个重点就是PaddleDetection/configs/datasets/voc.yml中数据集路径的配置。

metric: VOC
map_type: 11point
num_classes: 8

TrainDataset:
  !VOCDataSet
    dataset_dir: /home/aistudio/insect_det
    anno_path: train_list.txt
    label_list: labels.txt
    data_fields: ['image', 'gt_bbox', 'gt_class', 'difficult']

EvalDataset:
  !VOCDataSet
    dataset_dir: /home/aistudio/insect_det
    anno_path: test_list.txt
    label_list: labels.txt
    data_fields: ['image', 'gt_bbox', 'gt_class', 'difficult']

TestDataset:
  !ImageFolder
    anno_path: /home/aistudio/insect_det/labels.txt

3.2 修改训练超参数

比如PaddleDetection/configs/yolov3/_base_/optimizer_270e.yml可以做如下修改:

epoch: 70

LearningRate:
  base_lr: 0.001
  schedulers:
  - !PiecewiseDecay
    gamma: 0.1
    milestones:
    - 50
    - 60
  - !LinearWarmup
    start_factor: 0.
    steps: 400

OptimizerBuilder:
  optimizer:
    momentum: 0.9
    type: Momentum
  regularizer:
    factor: 0.0005
    type: L2

3.3 开始训练

没错!没有要改的了,直奔主题,开始训练!https://ai-studio-static-online.cdn.bcebos.com/d500f483e55544a5b929abad59de208f180c068cc81648009fab60a0b6d9bda2

!git clone https://gitee.com/paddlepaddle/PaddleDetection.git
%cd PaddleDetection/
/home/aistudio/PaddleDetection
!pip install -r requirements.txt
!export CUDA_VISIBLE_DEVICES=0
!python tools/train.py -c configs/yolov3/yolov3_darknet53_270e_voc.yml --use_vdl=True --vdl_log_dir=./output --eval
!python tools/eval.py -c configs/yolov3/yolov3_darknet53_270e_voc.yml -o weights=output/yolov3_darknet53_270e_voc/59.pdparams
/opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages/paddle/tensor/creation.py:130: DeprecationWarning: `np.object` is a deprecated alias for the builtin `object`. To silence this warning, use `object` by itself. Doing this will not modify any behavior and is safe. 
Deprecated in NumPy 1.20; for more details and guidance: https://numpy.org/devdocs/release/1.20.0-notes.html#deprecations
  if data.dtype == np.object:
Warning: import ppdet from source directory without installing, run 'python setup.py install' to install ppdet firstly
W0114 20:49:31.823413   825 device_context.cc:447] Please NOTE: device: 0, GPU Compute Capability: 7.0, Driver API Version: 10.1, Runtime API Version: 10.1
W0114 20:49:31.828456   825 device_context.cc:465] device: 0, cuDNN Version: 7.6.
[01/14 20:49:35] ppdet.utils.checkpoint INFO: Finish loading model weights: output/yolov3_darknet53_270e_voc/59.pdparams
[01/14 20:49:35] ppdet.engine INFO: Eval iter: 0
[01/14 20:49:37] ppdet.metrics.metrics INFO: Accumulating evaluatation results...
[01/14 20:49:37] ppdet.metrics.metrics INFO: mAP(0.50, 11point) = 82.00%
[01/14 20:49:37] ppdet.engine INFO: Total sample number: 63, averge FPS: 28.58731442371075

下面展示笔者在真实的手动标注数据集上训练的效果。对300张手动标注数据集进行划分后,60个epoch的训练总时长约65分钟,验证集和测试集的mAP就达到0.8+。


可以选取一些测试集的图片进行验证,如果感觉检测效果已经不错了,就进入下一个环节:生成数据标注。

!python tools/infer.py -c configs/yolov3/yolov3_darknet53_270e_voc.yml --infer_img=../insect_det/TESTImages/0101.jpg -o weights=output/yolov3_darknet53_270e_voc/59.pdparams  --draw_threshold=0.1
!python tools/infer.py -c configs/yolov3/yolov3_darknet53_270e_voc.yml --infer_img=../Dataset/TESTImages/62.jpg -o weights=output/yolov3_darknet53_270e_voc/59.pdparams  --draw_threshold=0.1
/opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages/paddle/tensor/creation.py:130: DeprecationWarning: `np.object` is a deprecated alias for the builtin `object`. To silence this warning, use `object` by itself. Doing this will not modify any behavior and is safe. 
Deprecated in NumPy 1.20; for more details and guidance: https://numpy.org/devdocs/release/1.20.0-notes.html#deprecations
  if data.dtype == np.object:
Warning: import ppdet from source directory without installing, run 'python setup.py install' to install ppdet firstly
W0117 23:43:50.255735  4436 device_context.cc:447] Please NOTE: device: 0, GPU Compute Capability: 7.0, Driver API Version: 10.1, Runtime API Version: 10.1
W0117 23:43:50.265116  4436 device_context.cc:465] device: 0, cuDNN Version: 7.6.
[01/17 23:43:53] ppdet.utils.checkpoint INFO: Finish loading model weights: output/yolov3_darknet53_270e_voc/59.pdparams
[01/17 23:43:54] ppdet.engine INFO: Detection bbox results save in output/62.jpg

4 生成预测标注结果

在这一步,重点就是对PaddleDetection的源代码进行修改。但是,磨刀不误砍柴工,我们在改代码前,先要做到:明确目标、理解代码、定位函数,最后才是大刀阔斧动手改代码。

4.1 明确目标:分析标注文件

从OCR到(旋转)目标检测:四点标注转VOC和roLabelImg这个项目中,已经提到,极简版的VOC格式标注如下:

<annotation>
	<filename>2012_004331.jpg</filename>
	<folder>VOC2012</folder>
	<object>
		<name>person</name>
		<bndbox>
			<xmax>208</xmax>
			<xmin>102</xmin>
			<ymax>230</ymax>
			<ymin>25</ymin>
		</bndbox>
		<difficult>0</difficult>
	</object>
	<size>
		<depth>3</depth>
		<height>375</height>
		<width>500</width>
	</size>
</annotation>

从该项目生成VOC格式数据集的关键代码中,我们也可以看出,其实重点就是要解决8个参数的赋值:

  • 图片名 filename
  • 图片宽 width
  • 图片高height
  • 目标标签 label
  • 定位标注框的坐标 xmin,ymin,xmax,ymaxx,y,w,h
with open(os.path.join('./Annotations', filename.rstrip('.jpg')) + '.xml', 'w') as f:
                f.write(f"""<annotation>
            <folder>JPEGImages</folder>
            <filename>{filename}.jpg</filename>
            <size>
                <width>{width}</width>
                <height>{height}</height>
                <depth>3</depth>
            </size>
            <segmented>0</segmented>\n""")
                for label, x1, y1, x2, y2 in mem[filename]:
                    f.write(f"""    <object>
                <name>{label}</name>
                <pose>Unspecified</pose>
                <truncated>0</truncated>
                <difficult>0</difficult>
                <bndbox>
                    <xmin>{x1}</xmin>
                    <ymin>{y1}</ymin>
                    <xmax>{x2}</xmax>
                    <ymax>{y2}</ymax>
                </bndbox>
            </object>\n""")
                f.write("</annotation>")

另外两个注意事项:

因为我们是希望生成了标注文件还要放回LabelImg去做后续手动调整的,所以本项目在保存VOC标注文件时,还要记得把<path>写进去,比如<path>F:\windows_v1.8.1\JPEGImages\{filename}.jpg</path>

同时,我们肯定也希望,放回自动生成的标注文件后,可以将它们和已经手动标注确认了的文件区分开来,因此,生成标注文件的开头,应该是<annotation verified="no">

4.2 理解代码:PPDET是怎么画预测框的

在预测图片时,我们用的是PaddleDetection/tools/infer.py,很显然,既然能画框(而且框上有标签名),说明预测的时候能正常返回bbox坐标以及目标的label;同时,我们会发现输出的预测图片,文件名和输入时是一样的,那么filename也搞定了;更不用说,本身输入待预测图片的路径,这也意味着widthheight能获取到……

所以,仔细算一算,上面提到的8个参数的赋值,在PPDETinfer过程中,都是能比较轻松获取到的。接下来,就是找到它们,并且用最快的速度,将它们重新组装起来,用最快速度生成VOC标注数据格式。

4.3 定位函数:找到要改的文件和函数

接下来,就是一个返查倒推的过程,这里强烈点赞AIStudio的代码跳转功能,网页端也能像IDE一样简单操作。

PaddleDetection/tools/infer.py

def run(FLAGS, cfg):
    # build trainer
    trainer = Trainer(cfg, mode='test')

    # load weights
    trainer.load_weights(cfg.weights)

    # get inference images
    images = get_test_images(FLAGS.infer_dir, FLAGS.infer_img)

    # inference
    trainer.predict(
        images,
        draw_threshold=FLAGS.draw_threshold,
        output_dir=FLAGS.output_dir,
        save_txt=FLAGS.save_txt)

找到PaddleDetection/ppdet/engine/trainer.py

    def predict(self,
                images,
                draw_threshold=0.5,
                output_dir='output',
                save_txt=False):
        ……
        if save_txt:
                    save_path = os.path.splitext(save_name)[0] + '.txt'
                    results = {}
                    results["im_id"] = im_id
                    if bbox_res:
                        results["bbox_res"] = bbox_res
                    if keypoint_res:
                        results["keypoint_res"] = keypoint_res
                    save_result(save_path, results, catid2name, draw_threshold)
                start = end

最后是PaddleDetection/ppdet/utils/visualizer.py

def draw_bbox(image, im_id, catid2name, bboxes, threshold):
   

def save_result(save_path, results, catid2name, threshold):

其实整个排查路线非常清晰,我们也会发现,我们需要的8个参数赋值,PaddleDetection/ppdet/engine/trainer.pyPaddleDetection/ppdet/utils/visualizer.py对应的几个函数里都有覆盖到了。

4.4 动手修改:最小改动、最快效率

上面其实已经拎出了一个关键函数save_result(save_path, results, catid2name, threshold),控制它的开关就是--save_txt,显然,PaddleDetection特别贴心,知道有时候我们需要输出bbox的具体结果,而不是封装好的绘图效果。

如果要追求最小改动、最快效率,自动生成标注文件,那么直接把生成VOC的代码插入到save_result()里,并做相应修改即可(传参的时候要传入图片宽、高)。

不过,为了便于理解,本项目没有直接用--save_txt控制开关,而是依样画葫芦,写了个--save_voc开关,由于篇幅限制,详情请读者查看修改后的infer.pytrainer.pyvisualizer.py这三个文件,用的时候直接替换即可。

注:PPDET预测的bbox坐标是float格式,必须转换成int格式,否则放回LabelImg识别不出来。

!cp ../infer.py ppdet/utils/visualizer.py
!cp ../trainer.py ppdet/engine/trainer.py
!cp ../visualizer.py ppdet/utils/visualizer.py
# 单文件预测,检查生成的voc格式效果
!python tools/infer.py -c configs/yolov3/yolov3_darknet53_270e_voc.yml --infer_img=../insect_det/TESTImages/0101.jpg -o weights=output/yolov3_darknet53_270e_voc/59.pdparams  --draw_threshold=0.1 --save_voc=True
# 批量生成标注文件
!python tools/infer.py -c configs/yolov3/yolov3_darknet53_270e_voc.yml --infer_dir=../insect_det/TESTImages -o weights=output/yolov3_darknet53_270e_voc/59.pdparams  --draw_threshold=0.1 --save_voc=True
# 随机抽一个检查标注文件是否正确
# !cat output/62.xml
!cat output/0101.xml
<annotation  verified="no">
                <folder>JPEGImages</folder>
                <filename>62.jpg</filename>
                <path>F:\windows_v1.8.1\JPEGImages\62.jpg</path>
                <source>
                    <database>Unknown</database>
                </source>
                <size>
                    <width>1280</width>
                    <height>960</height>
                    <depth>3</depth>
                </size>
                <segmented>0</segmented>
    <object>
                    <name>R-white</name>
                    <pose>Unspecified</pose>
                    <truncated>0</truncated>
                    <difficult>0</difficult>
                    <bndbox>
                        <xmin>453</xmin>
                        <ymin>771</ymin>
                        <xmax>572</xmax>
                        <ymax>919</ymax>
                    </bndbox>
        </object>
</annotation>

5 矫正标注结果

一种矫正标注结果的方式非常直接而简单,就是把生成的标注文件打包下载,放回LabelImg中手动调整即可。

这里主要想说,面对个别特殊数据集,我们还有一种“更懒”的办法。https://ai-studio-static-online.cdn.bcebos.com/d500f483e55544a5b929abad59de208f180c068cc81648009fab60a0b6d9bda2

所谓特殊数据集,指的是,一张图片里,只有一种目标的情形——看似特殊,其实在工业质检的场景中应非常常见。

举个例子,比如布匹的瑕疵检测。按常识想,一块布料同时存在多种瑕疵,概率应该不太高吧?
https://ai-studio-static-online.cdn.bcebos.com/1e017b19272547848e22eab831bce9f6218d8c1465f94436b681140fdb3923b8

往往是设备哪里坏了,比如漏油,导致某段时间经过的布料集中存在油污;又或者是哪里过于尖锐,导致经过的布料被划伤……如果是这种情况,其实反而与图像分类会比较类似,一张图里,我们需要找的label只有一种,那么参考图像分类数据集的组织形式,原始数据集可以这样处理:

. 
├── 目标一 
├── 目标二
│ ├── 001.jpg 
│ ├── 002.jpg  
│ ├── 003.jpg  
│ └── ……
├── 目标三 
├── 目标四
└── ……

然后再通过命令行的重命名脚本,将图片统一改成类似A001.jpg,B001.jpg,B002.jpg……的格式,ABC对照label123,这样,某张图片中,全部目标label的信息就出现在了文件名里。接下来我们需要做的,无非是,提取文件名中的label,直接替换掉文件对应的VOC标注里,目标的label

注:之所以用ABC区分,是因为看起来操作比较简单,截取字符串首字母即可——当然,场景不一样,情况也就不一样,如果有几十种目标,可以考虑label+分隔符的形式。

对于上文讲的特殊数据集,经过前面提到的一番“骚操作”处理,我们会发现,这时,手动矫正只需要调整矩形框大小位置了!进一步降低了工作量。

而这个“矫正”的操作,甚至不需要额外进行,直接在save_result()函数里面改就行!关键代码如下:

filename = os.path.basename(save_path)[:-4]
if filename[0] == 'E':
    label = 'labelE'
elif filename[0] == 'F':
    label = 'labelF'
else:
    label = catid2name[catid]

注:再次强调,能不能进行这种操作得看具体情况!比如昆虫检测这个示例数据集就没法这样写!

6 小结

通过实际操作估算,笔者标注全部数据集时,前300张图片手动标注花了0.5天,然后用了约1.5个小时,训练得到自动生成的VOC标注文件,灌回LabelImg后手动矫正,剩下1800张图片的标注才用了0.5天……效率提高了500%以上!https://ai-studio-static-online.cdn.bcebos.com/d500f483e55544a5b929abad59de208f180c068cc81648009fab60a0b6d9bda2

当然,不同数据集标注提速肯定有差异……https://ai-studio-static-online.cdn.bcebos.com/d500f483e55544a5b929abad59de208f180c068cc81648009fab60a0b6d9bda2

系列项目后续思路:

  • 构建pipeline
  • 旋转目标检测小样本手动标注解决方案
  • EISeg标注+小样本图像分割/实例分割转目标检测
  • 远期目标,实现类似vott的解决方案,比如把vott的引擎换成PPDET/paddlejs(大坑)

或者各位读者要是有更好的点子,敬请不吝赐教~~

Logo

学大模型,用大模型上飞桨星河社区!每天8点V100G算力免费领!免费领取ERNIE 4.0 100w Token >>>

更多推荐