Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

Pascal VOC形式のxmlファイルとAWS SageMaker Ground Truthのmanifestファイルの相互変換

こんにちは、マネーフォワードの田中です。

はじめに

Amazon SageMaker Ground Truth は、AWSが提供するアノテーションプラットフォームで、ユーザはS3にデータセットをアップロードすることで、アノテーション作業を容易に行うことができます。Ground Truthは様々な問題設定に対するアノテーションツールを提供していて、物体検出もその1つです。

Ground Truthへ入力、またはGround Truthが出力するアノテーションデータのファイルフォーマットは、AWSが独自に規定するjsonファイルであり(実際には複数のjsonファイルを1つにまとめたmanifestファイル)、以下のような問題があります。

  • manifestファイルを、他形式のアノテーションファイルを前提とするシステムに直接入力したり、他形式のファイルを対象としたアノテーションツールで追加アノテーションができない
  • 他形式で既にアノテーションを行ったデータをGround Truthに直接インポートできない

今回は物体検出のためのアノテーションデータの代表的なフォーマットの1つであるPascal VOC形式と、Amazon SageMaker Ground Truth形式のアノテーションファイルを相互に変換する方法について記述します。

また、下記で利用しているコードをPyPIパッケージとして公開しているので、良ければご覧になってみてください:https://pypi.org/project/pascalgt/

物体検出のアノテーションデータフォーマット

物体検出とは、画像中のどこに関心物体が存在するかを推定する問題です。何かしらの推定器を作ることを考えると、関心物体が画像中のどこに存在するかを示す、正解データ(アノテーションデータ)が必要になります。 例えば、「犬」と「猫」という2つの物体に関心があるとき、以下のようなアノテーションを行います。

このようなアノテーションデータを表現するためには、

  • 画像のメタデータ(画像サイズやファイル名)
  • 各アノテーションbounding boxに関する情報(座標やラベル)

を保存しておけば良さそうです。これらの情報を保持する方法はPascal VOC形式とGround Truth形式で大体同じなのですが、微妙に異なる点があります。

Pascal VOC形式

Pascal VOCとは、Pascal VOC project で採用されている物体検出におけるアノテーションファイルのフォーマットです。より詳細には、以下のようなxmlファイルとして与えられます。

<annotation>
    <folder>imgs</folder>
    <filename>image1.jpg</filename>
    <size>
        <width>1108</width>
        <height>1477</height>
        <depth>3</depth>
    </size>
    <object>
        <name>cat</name>
        <difficult>0</difficult>
        <bndbox>
            <xmin>541</xmin>
            <ymin>764</ymin>
            <xmax>1088</xmax>
            <ymax>1137</ymax>
        </bndbox>
    </object>
    <object>
        <name>dog</name>
        <difficult>0</difficult>
        <bndbox>
            <xmin>3</xmin>
            <ymin>604</ymin>
            <xmax>515</xmax>
            <ymax>1126</ymax>
        </bndbox>
    </object>
</annotation>

アノテーション対象の画像に関する情報や、各アノテーションbounding boxに関する情報が含まれています。 bounding boxを表す座標系には、画像の左上を原点とし、右側にx座標正方向、下側にy座標正方向としたものが利用されています。bounding boxは、boxの左上の点のx座標(xmin)、y座標(ymin)、boxの右下の点のx座標(xmax)、y座標(ymax)として与えられています。

今回の例では、「犬」と「猫」に対するアノテーションが1つずつobjectの中に格納されています。

SageMaker Ground Truth形式

SageMaker Ground Truthの入出力には、以下のようなmanifestファイルを利用します。

{"source-ref":"s3://sample-bucket/image1.jpg","test-project":{"image_size":[{"width":1108,"height":1477,"depth":3}],"annotations":[{"class_id":0,"top":640,"left":10,"height":463,"width":488},{"class_id":1,"top":777,"left":589,"height":359,"width":511}]},"test-project-metadata":{"objects":[{"confidence":0},{"confidence":0}],"class-map":{"0":"dog","1":"cat"},"type":"groundtruth/object-detection","human-annotated":"yes","creation-date":"2021-09-21T12:55:32.782712","job-name":"labeling-job/test-project"}}
{"source-ref":"s3://sample-bucket/image2.jpg","test-project":{"image_size":[{"width":2448,"height":3264,"depth":3}],"annotations":[{"class_id":0,"top":571,"left":311,"height":987,"width":928},{"class_id":1,"top":1562,"left":1072,"height":1079,"width":639}]},"test-project-metadata":{"objects":[{"confidence":0},{"confidence":0}],"class-map":{"0":"dog","1":"cat"},"type":"groundtruth/object-detection","human-annotated":"yes","creation-date":"2021-09-21T12:55:32.780985","job-name":"labeling-job/test-project"}}
{"source-ref":"s3://sample-bucket/image3.jpg","test-project":{"image_size":[{"width":3024,"height":4032,"depth":3}],"annotations":[{"class_id":0,"top":1641,"left":1222,"height":813,"width":1273},{"class_id":1,"top":1273,"left":664,"height":515,"width":803}]},"test-project-metadata":{"objects":[{"confidence":0},{"confidence":0}],"class-map":{"0":"dog","1":"cat"},"type":"groundtruth/object-detection","human-annotated":"yes","creation-date":"2021-09-21T12:55:32.781840","job-name":"labeling-job/test-project"}}

このファイルは1行ずつ見てみると、以下のようなjsonが格納されており、それぞれのjsonが各画像に対するアノテーションデータになっています。以下のjsonは上記manifestファイルの1行目のjsonファイルを取り出してきたものです。

{
   "source-ref":"s3://sample-bucket/image1.jpg",
   "test-project":{
      "image_size":[
         {
            "width":1108,
            "height":1477,
            "depth":3
         }
      ],
      "annotations":[
         {
            "class_id":0,
            "top":640,
            "left":10,
            "height":463,
            "width":488
         },
         {
            "class_id":1,
            "top":777,
            "left":589,
            "height":359,
            "width":511
         }
      ]
   },
   "test-project-metadata":{
      "objects":[
         {
            "confidence":0
         },
         {
            "confidence":0
         }
      ],
      "class-map":{
         "0":"dog",
         "1":"cat"
      },
      "type":"groundtruth/object-detection",
      "human-annotated":"yes",
      "creation-date":"2021-09-21T12:55:32.782712",
      "job-name":"labeling-job/test-project"
   }
}

こちらも同じくアノテーション対象の画像ファイルに関する情報や各アノテーションbounding boxに関する情報が含まれています。

bounding boxを表す座標系には、Pascal VOCと同じく画像の左上を原点とし、右側にx座標正方向、下側にy座標正方向としたものが利用されています。bounding boxは、boxの左上の点のx座標(left)、y座標(top)、boxの横幅(width)、縦幅(height)として与えられています。 また、クラス名がクラスidとのセットでmetadataの中に保持されています。

今回の例では、「犬」と「猫」に対するアノテーションが1つずつannotationsの中に格納されています。

変換

以下では変換を行うコードと、変換を行ったアノテーションデータが実際に利用可能であることを確認します。

PascalVOC to Ground Truth

PascalVOC形式のアノテーションデータを用意するために、 RecatLabelというツールを使ってみます。RectLabelでは、アノテーションデータをPascal VOC形式のxmlファイルでインポート・エクスポートができます。今回は以下の3枚の画像に対してclass dogとclass catの2クラスのアノテーションをつけてみます。

このように作成したアノテーションをPascal VOC形式でエクスポートします。今回は./xml/image1.xml, ./xml/image2.xml, ./xml/image3.xml にエクスポートしたとします。このように出力したPascal VOC xmlファイル群から、Ground Truth形式のmanifestファイルを1つ作ることが目標になります。

変換には以下のようなコードを利用します。

import datetime
import json
from pathlib import Path
from lxml import etree
from typing import List


class Pascal2GT:
    """
    Transform PASCAL-VOC xml files into ground truth
    """

    def __init__(self, project_name: str, s3_path: str):
        self.project_name = project_name
        self.s3_path = s3_path  # s3 key of the directory including images

    def run(self, path_target_manifest: str, path_source_xml_dir: str) -> None:
        path_target_manifest = Path(path_target_manifest)
        path_source_xml_dir = Path(path_source_xml_dir)

        list_xml = []
        for path_file in path_source_xml_dir.iterdir():
            if path_file.suffix == ".xml":
                xml = self.read_pascal_xml(path_file)
                list_xml.append(xml)
        output_manifest_text = self.aggregate_xml(list_xml)
        with open(str(path_target_manifest), mode='w') as f:
            f.write(output_manifest_text)
        return

    def read_pascal_xml(self, path_xml: Path) -> etree.Element:
        assert path_xml.exists()
        xml = etree.parse(str(path_xml))
        return xml

    def aggregate_xml(self, list_xml: List[etree.Element]) -> str:
        """
        aggregate list of xml structure objects into one output.manifest text.
        """
        outputText = ""
        list_output_json_dict = []
        class_name2class_id_mapping = {}

        for xml in list_xml:
            output_dict = {}

            filename = xml.find("filename").text
            size_object = xml.find("size")
            image_height = int(size_object.find("height").text)
            image_width = int(size_object.find("width").text)
            image_depth = int(size_object.find("depth").text)

            output_dict["source-ref"] = f"{self.s3_path}/{filename}"
            output_dict[self.project_name] = {"image_size": [{"width": image_width,
                                                              "height": image_height,
                                                              "depth": image_depth}],
                                              "annotations": []}
            output_dict[f"{self.project_name}-metadata"] = {"objects": [],
                                                            "class-map": {},
                                                            "type": "groundtruth/object-detection",
                                                            "human-annotated": "yes",
                                                            "creation-date": datetime.datetime.now().isoformat(
                                                                timespec='microseconds'),
                                                            "job-name": f"labeling-job/{self.project_name}"}

            objects = xml.findall("object")

            for annotated_object in objects:
                class_name = annotated_object.find("name").text
                if class_name2class_id_mapping.get(class_name) is None:
                    class_name2class_id_mapping[class_name] = len(class_name2class_id_mapping)

                class_id = class_name2class_id_mapping[class_name]

                bbox_object = annotated_object.find("bndbox")
                x1 = int(bbox_object.find("xmin").text)
                x2 = int(bbox_object.find("xmax").text)
                y1 = int(bbox_object.find("ymin").text)
                y2 = int(bbox_object.find("ymax").text)
                bbox_width = x2 - x1
                bbox_height = y2 - y1

                output_dict[self.project_name]["annotations"].append(
                    {
                        "class_id": class_id,
                        "top": y1,
                        "left": x1,
                        "height": bbox_height,
                        "width": bbox_width,
                    }
                )
                output_dict[f"{self.project_name}-metadata"]["objects"].append({"confidence": 0})

            list_output_json_dict.append(output_dict)

        for output_dict in list_output_json_dict:
            output_dict[f"{self.project_name}-metadata"]["class-map"] = {str(class_id): str(class_name) for (class_name, class_id) in class_name2class_id_mapping.items()}
            outputText += json.dumps(output_dict, separators=(",", ":")) + "\n"
        return outputText

manifestファイルの中には、Ground Truthでのラベリングジョブ名と、画像データを置くS3へのキーを指定する必要があるので、__init__()の引数で project_name, s3_pathとしてそれぞれ指定します。

Pascal VOC形式とGround Truth形式ではアノテーションbounding boxの座標の表現法が微妙に異なるので注意してください。

import Pascal2GT

# transform Pascal VOC xml files into a manifest file of AWS Ground Truth
pascal2gt = Pascal2GT(project_name="test-project", s3_path="s3://test-project/images")
pascal2gt.run(path_target_manifest="./output.manifest", path_source_xml_dir="./xml/")

上記コードによって、Ground Truth形式のoutput.manifestを得ることができます。

SageMaker Ground Truthでは、このようにAWSの外で自分で作成したアノテーションデータを補正・確認できる ( https://aws.amazon.com/jp/blogs/machine-learning/verifying-and-adjusting-your-data-labels-to-create-higher-quality-training-datasets-with-amazon-sagemaker-ground-truth/) ので、その機能を用いてGround Truthにmanifestファイルをインポートすると、下記のように正しく変換できていることを確認できます。

Ground Truth to PascalVOC

今度は逆にSageMaker Ground Truthでアノテーションした結果の出力manifestファイルをPascal VOC形式に変換してみます。今回は1つのmanifestファイルから複数のxmlファイルを作ることが目標になります。 同じ画像になるので今回は例として画像を示すのを省略します。

変換には以下のようなコードを利用します。

import json
from pathlib import Path
from lxml import etree
from typing import List


class GT2Pascal:
    """
    Transform ground truth files into PASCAL-VOC
    """

    def run(self, path_source_manifest: str, path_target_xml_dir: str) -> None:
        path_source_manifest = Path(path_source_manifest)
        path_target_xml_dir = Path(path_target_xml_dir)

        list_json_dict = self.read_manifest(path_source_manifest)
        project_name = self.extract_project_name(list_json_dict[0])
        for json_dict in list_json_dict:
            xml = self.transform(json_dict, project_name)
            path_source_image = self.get_source_image_filename(json_dict)
            self.save_xml(xml, path_target_xml_dir / path_source_image.with_suffix(".xml"))
        return

    def read_manifest(self, path_manifest: Path):
        list_json_dict = []
        with open(str(path_manifest), "r") as f:
            list_json_text = f.read().split("\n")

        for json_text in list_json_text:
            if json_text != "":
                json_src = json.loads(json_text)
                list_json_dict.append(json_src)
        return list_json_dict

    def save_xml(self, xml_data: etree.Element, path_save: Path) -> None:
        # xmlファイル出力
        out_xml = etree.tostring(xml_data,
                                 encoding="utf-8",
                                 xml_declaration=True,
                                 pretty_print=True)
        with open(str(path_save), "wb") as f:
            f.write(out_xml)
        return

    def extract_project_name(self, gt_json) -> str:
        return list(gt_json.keys())[1]

    def get_source_image_filename(self, gt_json) -> Path:
        source_ref = gt_json["source-ref"]
        return Path(Path(source_ref).name)

    def transform(self, gt_json, project_name: str) -> etree.Element:
        """
        path_gt_file: .manifest file
        """

        boxlabel_metadata = gt_json[f"{project_name}-metadata"]
        class_mapping = boxlabel_metadata["class-map"]
        assert boxlabel_metadata["type"] == "groundtruth/object-detection"

        boxlabel = gt_json[f"{project_name}"]

        image_size = boxlabel["image_size"][0]
        image_height = image_size["height"]
        image_width = image_size["width"]
        image_depth = image_size["depth"]

        xml_data = etree.Element("annotation")
        etree.SubElement(xml_data, "filename").text = str(self.get_source_image_filename(gt_json))
        size = etree.SubElement(xml_data, "size")
        etree.SubElement(size, "width").text = str(image_width)
        etree.SubElement(size, "height").text = str(image_height)
        etree.SubElement(size, "depth").text = str(image_depth)

        for annotated_bbox in boxlabel["annotations"]:
            class_id = annotated_bbox["class_id"]
            class_name = class_mapping[str(class_id)]
            width = annotated_bbox["width"]
            top = annotated_bbox["top"]
            height = annotated_bbox["height"]
            left = annotated_bbox["left"]

            obj = etree.SubElement(xml_data, "object")
            etree.SubElement(obj, "name").text = class_name
            bndbox = etree.SubElement(obj, "bndbox")
            etree.SubElement(bndbox, "xmin").text = str(left)
            etree.SubElement(bndbox, "ymin").text = str(top)
            etree.SubElement(bndbox, "xmax").text = str(int(left) + int(width))
            etree.SubElement(bndbox, "ymax").text = str(int(top) + int(height))
        return xml_data
import GT2Pascal

# transform a manifest file of Ground Truth into Pascal VOC xml files
trans = GT2Pascal()
trans.run("./output.manifest", "./annotations/")

こちらを実行すれば、./annotations/に変換後のxmlファイル群が出力されます。

終わりに

今回は、物体検出のアノテーションデータフォーマットであるPascal VOC形式とSageMaker Ground Truth形式を相互に変換する方法について記述しました。 このようなファイル形式の変換は地味に面倒なので、同じことでお困りの方の助けになれると嬉しいです。

他にも、VoTTというアノテーションツールの出力形式からGround Truth形式に変換する、Classmethod様の記事がありましたので是非ご参照ください:[Amazon Rekognition] VoTTで作成したデータをCustom Labelsで利用可能なAmazon SageMaker Ground Truth形式に変換してみました


マネーフォワードでは、エンジニアを募集しています。 ご応募お待ちしています。

【サイトのご案内】 ■マネーフォワード採用サイトWantedly京都開発拠点

【プロダクトのご紹介】 ■お金の見える化サービス 『マネーフォワード ME』 iPhone,iPad Android

ビジネス向けバックオフィス向け業務効率化ソリューション 『マネーフォワード クラウド』

おつり貯金アプリ 『しらたま』

お金の悩みを無料で相談 『マネーフォワード お金の相談』

だれでも貯まって増える お金の体質改善サービス 『マネーフォワード おかねせんせい』

金融商品の比較・申し込みサイト 『Money Forward Mall』

くらしの経済メディア 『MONEY PLUS』