[Amazon SageMaker] Amazon SageMaker Ground Truth で作成したデータをオブジェクト検出で利用可能なRecordIO形式に変換してみました

2020.04.27

1 はじめに

CX事業本部の平内(SIN)です。

Amazon SageMaker の組み込みの物体検出アルゴリズム(object-detection)では、RecordIO形式のデータセットが利用可能です。

今回は、Amazon SageMaker Ground Truth(以下、Ground Truth)で作成したデータを変換して、このRecordIO形式のデータセットを作成してみました。

実は、物体検出アルゴリズム(object-detection)では、RecordIO形式の他にも、イメージ形式、拡張形式という3種類のデータセットが利用可能であり、拡張形式は、Ground Truthの出力を指しているので、わざわざ変換しなくても利用可能です。(fit()の際に、RecordIO形式に自動的に変換されている模様)

しかし、増加学習は、イメージ形式と、RecordIO形式でしか行うことが出来ない事と、学習のスピードは、イメージ形式よりRecordIOの方が高速でオススメとの事なので、今回、RecordIO形式への変換作業も確認しておくことにしました。

2 Ground Truth

Ground Truthのデータセットは、前回作成した、プライベートプロジェクトのごく少数のものです。
[Amazon SageMaker] Amazon SageMaker Ground Truth で作成したデータをオブジェクト検出で利用可能なイメージ形式に変換してみました

作業が完了すると、各画像ごと1行のアノテーション情報となっているoutput.manifestが生成されますが、このファイルと画像ファイルが今回の変換元です。

3 RecordIO形式のデータ

RecordIO形式のデータは、MXNetで配布される im2rec.py を使用して作成することができます。

im2rec.pyでは、アノテーション情報として.lst形式の入力を必要とするため、Ground Truthの出力であるoutput.manifestから生成しました。

以下は、生成した.lstファイルと、その当該画像の一例です。

1   4   5   800 600 1   0.141   0.225   0.454   0.695   0   0.534   0.392   0.772   0.632   AHIRU-1586397390447333.jpg
(略)
9   4   5   800 600 1   0.352   0.363   0.614   0.805   AHIRU-1586397276514928.jpg
(略)

.lstファイルの構造は、以下のとおりです。

  • 連番 (1から始まる連番)
  • ヘッダ数 (ヘッダを構成するカラム数 「ヘッダ数、データ数、画像サイズ幅、画像サイズ高さ」で4となる)
  • データ数(1つのアノテーションデータを構成するカラム数「ラベル、左上X座標、左上Y座標、右下X座標、右下Y座標」で5となる)
  • 画像サイズ(幅)
  • 画像サイズ(高さ)
  • 1つ目アノテーションデータ(ラベル、左上X座標、左上Y座標、右下X座標、右下Y座標)座標は、0.0〜1.0で表現される
  • 2つ目のアノテーションデータ (2つ目以降がある場合は、順番に配置される)
  • 画像ファイル名 (画像ファイル名、相対的なパスで指定される)


参考:https://github.com/leocvml/mxnet-im2rec_tutorial

im2rec.pyの使用例です。your_image_folderには、画像が配置されているディレクトリが指定されます。

$ python3 im2rec.py --pack-label <your_lst_file_name> <your_image_folder>

4 学習データと検証データ

教師あり学習のアルゴリズムである、オブジェクト検出では、学習用のデータと、検証用のデータが必要です。

学習用と検証用として、元データを、一定の比率で分割する場合、単純に分割することができません。全てのデータに均等に全てのラベルが含まれているわけでは無いからです。

たとえば、先頭から40件目まで、Aというラベルが指定されており、41〜50件目まで、Bが指定されているとします。そして、これを単純に4:1に分割すると、1〜40と41〜50となるため、学習用データにBのデータが存在しないことになってしまいます。

この問題に対応するため、変換対象となる全てのデータから、含まれるラベルの数をカウントし、サンプル数の少ないラベルから順に、分割するようにしました。

# ラベルの件数の少ない順に並べ替える(配列のインデックスが、クラスIDとなる)
def getLabel(dataList):
    labels = {}
    for data in dataList:
        for annotation in data.annotations:
            label = annotation["label"]
            if(label in labels):
                labels[label] += 1
            else:
                labels[label] = 1
    # ラベルの件数の少ない順に並べ替える(配列のインデックスが、クラスIDとなる)
    labels = sorted(labels.items(), key=lambda x:x[1])
    return labels

labels = getLabel(dataList)

# ラベルの数の少ないものから優先して分割する
for i,label in enumerate(labels):
    # ・・・分割処理

5 コード

RecordIOを生成するコードは、以下のとおりです。以下を定義を設定して利用可能です。

  • inputPath 元データ(画像データとoutput.manifestを置く)
  • outputPath 出力先
  • ratio 学習データと検証データの分割比率

内部で使用しているため、im2rec.pyをカレントに配置する必要があります。

import json
import os
import subprocess

# 定義
inputPath = '/tmp/AHIRU-DOG'
outputPath = '/tmp/AHIRU-DOG-SageMaker'
manifest = 'output.manifest'
# 分割の比率
ratio = 0.8 # 8:2に分割する

# 1件のデータを表現するクラス
class Data():
    def __init__(self, src):
        # プロジェクト名の取得
        for key in src.keys():
            index = key.rfind("-metadata")
            if(index!=-1):
                projectName = key[0:index]

        # メタデータの取得
        metadata = src[projectName + '-metadata']
        class_map = metadata["class-map"]

        # 画像名の取得
        self.__imgFileName = os.path.basename(src["source-ref"])

        # 画像サイズの取得
        project = src[projectName]
        image_size = project["image_size"]
        self.__img_width = image_size[0]["width"]
        self.__img_height = image_size[0]["height"]

        self.__annotations = []
        # アノテーションの取得
        for annotation in project["annotations"]:
            class_id = annotation["class_id"]
            top = annotation["top"]
            left = annotation["left"]
            width = annotation["width"]
            height = annotation["height"]

            self.__annotations.append({
                "label": class_map[str(class_id)],
                "width": width,
                "top": top,
                "height": height,
                "left": left
            })

    @property
    def annotations(self):
        return self.__annotations

    # 指定されたラベルを含むかどうか
    def exsists(self, label):
        for annotation in self.__annotations:
            if(annotation["label"] == label):
                return True
        return False

    # .lstを生成して追加する
    def appendLst(self, lst, labels):

        cls_list = []
        for label in labels:
            cls_list.append(label[0])

        index = len(lst.split('\n'))
        headerSize = 4 # hederSize,dataSize,imageWidth,imageHeight
        dataSize = 5
        str = "{}\t{}\t{}\t{}\t{}".format(index, headerSize, dataSize, self.__img_width, self.__img_height)

        for annotation in self.__annotations:
            cls_id = cls_list.index(annotation["label"])
            left = annotation["left"]
            right = left + annotation["width"]
            top = annotation["top"]
            bottom = top + annotation["height"]

            left = round(left / self.__img_width, 3)
            right = round(right / self.__img_width, 3)
            top = round(top / self.__img_height, 3)
            bottom = round(bottom / self.__img_height, 3)

            str += "\t{}\t{}\t{}\t{}\t{}".format(cls_id, left, top, right, bottom)          
        fileName = self.__imgFileName
        str += "\t{}".format(fileName)
        lst += str + "\n"
        return lst

# dataListをラベルを含むものと、含まないものに分割する
def deviedDataList(dataList, label):
    targetList = []
    unTargetList = []
    for data in dataList:
        if(data.exsists(label)):
            targetList.append(data)
        else:
            unTargetList.append(data)
    return (targetList, unTargetList)

# ラベルの件数の少ない順に並べ替える(配列のインデックスが、クラスIDとなる)
def getLabel(dataList):
    labels = {}
    for data in dataList:
        for annotation in data.annotations:
            label = annotation["label"]
            if(label in labels):
                labels[label] += 1
            else:
                labels[label] = 1
    # ラベルの件数の少ない順に並べ替える(配列のインデックスが、クラスIDとなる)
    labels = sorted(labels.items(), key=lambda x:x[1])
    return labels

# 全てのJSONデータを読み込む
def getDataList(inputPath, manifest):
    dataList = []
    with open("{}/{}".format(inputPath, manifest), 'r') as f:
        srcList = f.read().split('\n')
        for src in srcList:
            if(src != ''):
                json_src = json.loads(src)
                dataList.append(Data(json.loads(src)))
    return dataList

def main():

    # 出力先フォルダ生成
    os.makedirs(outputPath, exist_ok=True)

    # 全てのJSONデータを読み込む
    dataList = getDataList(inputPath, manifest)
    log = "全データ: {}件 ".format(len(dataList))

    # ラベルの件数の少ない順に並べ替える(配列のインデックスが、クラスIDとなる)
    labels = getLabel(dataList)
    for i,label in enumerate(labels):
        log += "[{}]{}: {}件 ".format(i, label[0], label[1])
    print(log)

    # 保存済みリスト
    storedList = [] 
    log = ''
    # 学習数
    trainCount = 0
    # .lst形式
    train = ''
    validation = ''
    # ラベルの数の少ないものから優先して分割する
    for i,label in enumerate(labels):
        log = ''
        log += "{} => ".format(label[0])
        # dataListをラベルが含まれるものと、含まないものに分割する
        (targetList, unTargetList) = deviedDataList(dataList, label[0])
        # 保存済みリストから、当該ラベルで既に保存済の件数をカウントする
        (include, notInclude) = deviedDataList(storedList, label[0])
        storedCounst = len(include)
        # train用に必要な件数
        count = int(label[1] * ratio) - storedCounst
        log += "{}:".format(count)
        # train側への保存
        for i in range(count):
            data = targetList.pop()
            train = data.appendLst(train, labels)
            trainCount+=1
            storedList.append(data)
        # validation側への保存
        log += "{} ".format(len(targetList))
        for data in targetList:
            validation = data.appendLst(validation, labels)
            storedList.append(data)

        dataList = unTargetList
        log += "残り:{}件".format(len(dataList))
        print(log)
    print("Train: {}件".format(trainCount))    

    # .lstファイルの生成
    trainLst = "{}/train.lst".format(outputPath)
    validationLst = "{}/validation.lst".format(outputPath)
    with open(trainLst, mode='w') as f:
        f.write(train)
    with open(validationLst, mode='w') as f:
        f.write(validation)

    # im2rec.pyによるRecordIOファイル生成
    # python im2rec.py --pack-label <your_lst_file_name> <your_image_folder>
    im2rec = "{}/im2rec.py".format(os.getcwd())

    cmd = ["python3", im2rec, "--pack-label", "validation.lst", inputPath]
    result = subprocess.run(cmd, cwd=outputPath)
    print(result)

    cmd = ["python3", im2rec, "--pack-label", "train.lst", inputPath]
    result = subprocess.run(cmd, cwd=outputPath)
    print(result)

main()


例えば、画像ファイルが50件で、ラベルDOGが10件、ラベルAHIRUが50件アノテーションされているデータをを変換すると、以下のようなログが出力されます。

全データ: 50件 [0]DOG: 10件 [1]AHIRU: 50件
DOG => 8:2 残り:40件
AHIRU => 30:10 残り:0件
Train: 38件

そして、出力先に指定したディレクトリで、RecordIOファイル(train.rec及び、varidation.rec)が生成されていることを確認できます。 この2つのファイルを、S3にアップロードして使用します。

$ ls -la
total 12632
drwxr-xr-x   8 sin  staff      256  4 26 15:12 .
drwxr-xr-x  20 sin  staff      640  4 26 13:29 ..
-rw-r--r--   1 sin  staff      395  4 26 15:12 train.idx
-rw-r--r--   1 sin  staff     2114  4 26 15:12 train.lst
-rw-r--r--   1 sin  staff  4105152  4 26 15:12 train.rec
-rw-r--r--   1 sin  staff      108  4 26 15:12 varidation.idx
-rw-r--r--   1 sin  staff      652  4 26 15:12 varidation.lst
-rw-r--r--   1 sin  staff  1297368  4 26 15:12 varidation.rec

6 オブジェクト検出

非常に限られたサンプル(データ)数ですが、とりあえず、モデルを作成してみました。DOGは、10件という事で、無理ですが、AHIRUの方は、限定的ですが検出できている感じがします。

7 最後に

今回は、Ground Truthで作成されたデータをSageMakerで利用するためのデータに変換する作業を行ってみました。

Ground Truthのデータを中心とした、データセット作成の作業では、今回のものが、図中の③になっています。

8参考にさせて頂いたリンク


アノテーションデータを JSON 形式から RecordIO 形式に変換する