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

2020.04.27

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

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

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
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 形式に変換する