Open8

CD-COCOをCOCO-Handにマージ

PINTOPINTO

まずは COCO-Hand 自力再アノテーションデータセットと CD-COCO の画像セットをマッチングして、COCO-Hand に存在する画像のみを CD-COCO から抽出する。 結果 4,351枚 が残った。COCO-Handはアスペクト比を無視して 480x360 へリサイズしてしまっているゴミデータセットなので、CD-COCO の画像と COCO-Hand の画像サイズは一致していない。ただ、YOLOのアノテーションフォーマットが [classid, cx, cy, w, h] の画像全体に対するスケール値管理なので、画像サイズの変化によってアノテーションが影響を受けることは無い想定。

01_cocohand_cdcoco_matching.py
import os
from tqdm import tqdm

# Bフォルダの画像のファイル名をリスト化
b_folder = 'train_val_cocohand'
b_files = set(os.listdir(b_folder))

# Aフォルダのファイル数を取得
a_folder = 'train_val_distorted'
original_a_count = len(os.listdir(a_folder))

# Aフォルダをループし、Bフォルダのリストにないファイルを削除
deleted_count = 0
for file in tqdm(os.listdir(a_folder), dynamic_ncols=True):
    if file not in b_files:
        os.remove(os.path.join(a_folder, file))
        deleted_count += 1

# 最終的にAフォルダに残ったファイルの数を表示
remaining_count = original_a_count - deleted_count
print(f"Aフォルダに残ったファイルの数: {remaining_count}")


PINTOPINTO

COCO-Hand のアノテーションを CD-COCO 側にコピーする

Aフォルダの画像枚数:4,351枚
.txtをAフォルダにコピーしたあとの想定ファイル数:8,702枚

02_cocohand_anno_copy_to_cdcoco.sh
!#/bin/bash

cd train_val_distorted

# Aフォルダの画像ファイルの拡張子を除くファイル名を取得
for img_file in *.{jpg,jpeg,png,JPG,JPEG,PNG}; do
    if [[ -f "$img_file" ]]; then
        base_name="${img_file%.*}"

        # 拡張子を除くファイル名がBフォルダの *.txt ファイルと一致するか確認
        if [ -f "../train_val_cocohand/$base_name.txt" ]; then
            # 一致する場合、BフォルダからAフォルダにコピー
            cp "../train_val_cocohand/$base_name.txt" .
        fi
    fi
done

CD-COCO の画像ファイルとアノテーションファイルを一括リネームして COCO-Hand と重複しないようにする。

03_cdcoco_rename.sh
!#/bin/bash

cd train_val_distorted

for file in *; do
    mv "$file" "dist_$file"
done

COCO-Hand 側の画像とアノテーションファイルを CD-COCO 側にすべてコピーする。
処理後の想定ファイル数:9,776 + 8,702 -> 18,478

04_copy_cocohand_to_cdcoco.sh
!#/bin/bash

cd train_val_cocohand

for file in *; do
    cp "$file" ../train_val_distorted/
done
PINTOPINTO
  • train_val_distorted フォルダの画像ファイルのみをZIPに圧縮してCVATへアップロードする 9,239枚 になるはず
  • CVATからYOLOフォーマットのアノテーションデータテンプレートのみをダウンロードする
  • CVATからダウンロードしたアノテーションテンプレートに train_val_distorted.txt を一括上書き更新する
  • 上書き更新したアノテーションテンプレートをZIPに圧縮してCVATへアップロードする
  • CVATからVOC形式でアノテーションをダウンロードする
  • convert_voc_to_coco.py で VOC形式 から COCO形式 のXMLファイルを生成する
PINTOPINTO

train, val への振り向け

05_voc_to_coco_prep.py
import os

dataset_directory = 'body_head_hand_face_dist_voc'

# 学習/検証データパス(train/validation data path)
train_directory = './train'
validation_directory = './validation'

# 学習データ格納ディレクトリ作成(Create training data storage directory)
os.makedirs(train_directory, exist_ok=True)
# 検証データ格納ディレクトリ作成(Create verification data storage directory)
os.makedirs(validation_directory, exist_ok=True)

import glob
import shutil
import random

# 学習データの割合(Percentage of training data)
train_ratio = 0.8

# コピー元ファイルリスト取得(Get copy source file list)
annotation_list = sorted(glob.glob(dataset_directory + '/*.xml'))
image_list = sorted(glob.glob(dataset_directory + '/*.jpg')+glob.glob(dataset_directory + '/*.PNG')+glob.glob(dataset_directory + '/*.png'))

file_num = len(annotation_list)

# インデックスシャッフル(shuffle)
index_list = list(range(file_num - 1))
random.shuffle(index_list)

for count, index in enumerate(index_list):
    if count < int(file_num * train_ratio):
        # 学習用データ(Training Data)
        shutil.copy2(annotation_list[index], train_directory)
        shutil.copy2(image_list[index], train_directory)
    else:
        # 検証用データ(Validation Data)
        shutil.copy2(annotation_list[index], validation_directory)
        shutil.copy2(image_list[index], validation_directory)
PINTOPINTO

convert_voc_to_coco.py によるフォーマット変換を実行

06_convert_voc_to_coco.py
import os
import json
import glob
import argparse

from tqdm import tqdm
import xml.etree.ElementTree as element_tree


def get_args():
    parser = argparse.ArgumentParser(
        description="Convert Pascal VOC annotation to COCO format.")

    parser.add_argument(
        "xml_dir",
        help="Directory path to xml files.",
        type=str,
        default='Annotations',
    )
    parser.add_argument(
        "json_file",
        help="Output COCO format json file.",
        type=str,
        default='output.json',
    )

    parser.add_argument(
        "--start_image_id",
        type=int,
        default=None,
    )
    parser.add_argument(
        "--start_bbox_id",
        help="Bounding Box start ID.",
        type=int,
        default=1,
    )
    parser.add_argument(
        "--category",
        help="Specify a category list.",
        type=str,
        default=None,
    )
    parser.add_argument(
        "--indent",
        help="COCO format json indent.",
        type=int,
        default=None,
    )
    parser.add_argument(
        "--bbox_offset",
        help="Bounding Box offset.",
        type=int,
        default=-1,
    )

    parser.add_argument(
        "--reserve_category_num",
        type=int,
        default=None,
    )

    args = parser.parse_args()

    return args


def main():
    # 引数取得
    args = get_args()

    xml_dir = args.xml_dir
    json_file = args.json_file

    start_image_id = args.start_image_id
    start_bbox_id = args.start_bbox_id
    category_txt_path = args.category
    indent = args.indent
    bbox_offset = args.bbox_offset

    reserve_category_num = args.reserve_category_num

    # Pascal VOC XMLファイルリスト取得
    xml_files = glob.glob(os.path.join(xml_dir, "*.xml"))

    # 事前定義のカテゴリーリストを取得
    predefine_categories = None
    if category_txt_path is not None:
        with open(category_txt_path, 'r') as f:
            category_list = f.readlines()
            predefine_categories = {
                name.rstrip('\n'): i
                for i, name in enumerate(category_list)
            }

    # カテゴリーリスト生成
    if predefine_categories is not None:
        categories = predefine_categories
    else:
        categories = get_categories(xml_files)

    # Pascal VOC → COCO 変換
    print("Number of xml files: {}".format(len(xml_files)))

    json_dict = convert_xml_to_json(
        xml_files,
        categories,
        start_image_id,
        start_bbox_id,
        bbox_offset,
        reserve_category_num,
    )

    # JSON保存
    if os.path.dirname(json_file):
        os.makedirs(os.path.dirname(json_file), exist_ok=True)

    with open(json_file, "w") as fp:
        json_text = json.dumps(json_dict, indent=indent)
        fp.write(json_text)

    print("Success: {}".format(json_file))


def get_categories(xml_files):
    classes_names = []

    # 全XMLのobjectからnameを取得
    for xml_file in xml_files:
        tree = element_tree.parse(xml_file)
        root = tree.getroot()

        for member in root.findall("object"):
            classes_names.append(member[0].text)

    # 重複を削除してソート
    classes_names = list(set(classes_names))
    classes_names.sort()

    # Dict形式に変換
    # categories = {name: i + 1 for i, name in enumerate(classes_names)}
    categories = {'body': 1, 'head': 2, 'hand': 3, 'face': 4}

    return categories


def get_element(root, name, length=None):
    # 指定タグの値を取得
    vars = root.findall(name)

    # 長さチェック
    if length is not None:
        if len(vars) == 0:
            raise ValueError("Can not find %s in %s." % (name, root.tag))
        if length > 0 and len(vars) != length:
            raise ValueError(
                "The size of %s is supposed to be %d, but is %d." %
                (name, length, len(vars)))

        if length == 1:
            vars = vars[0]

    return vars


def get_basename_without_ext(filename):
    # 拡張子を含まないファイル名を取得
    basename_without_ext = filename.replace("\\", "/")
    basename_without_ext = os.path.splitext(os.path.basename(filename))[0]

    return str(basename_without_ext)


def get_basename_with_ext(filename):
    # 拡張子を含むファイル名を取得
    basename_with_ext = filename.replace("\\", "/")
    basename_with_ext = os.path.basename(basename_with_ext)

    return str(basename_with_ext)


def convert_xml_to_json(
    xml_files,
    categories=None,
    start_image_id=None,
    start_bbox_id=1,
    bbox_offset=-1,
    reserve_category_num=None,
):
    json_dict = {
        "images": [],
        "type": "instances",
        "annotations": [],
        "categories": []
    }
    count_dict = {}

    bbox_id = start_bbox_id
    if start_image_id is not None:
        image_id_count = start_image_id

    for xml_file in tqdm(xml_files, "Convert XML to JSON"):
        # ルート要素取得
        tree = element_tree.parse(xml_file)
        root = tree.getroot()

        # 画像ファイル名取得
        path = get_element(root, "path")
        if len(path) == 1:
            filename = os.path.basename(path[0].text)
        elif len(path) == 0:
            filename = get_element(root, "filename", 1).text
        else:
            raise ValueError("%d paths found in %s" % (len(path), xml_file))

        # 画像情報取得
        size = get_element(root, "size", 1)
        width = int(get_element(size, "width", 1).text)
        height = int(get_element(size, "height", 1).text)
        if start_image_id is None:
            image_id = get_basename_without_ext(filename)
        else:
            image_id = image_id_count

        # JSON Dict追加
        image_info = {
            "file_name": filename,
            "height": height,
            "width": width,
            "id": image_id,
        }
        json_dict["images"].append(image_info)

        # object情報取得
        for obj in get_element(root, "object"):
            # バウンディングボックス情報取得
            bbox = get_element(obj, "bndbox", 1)
            xmin = int(float(get_element(bbox, "xmin", 1).text)) + bbox_offset
            ymin = int(float(get_element(bbox, "ymin", 1).text)) + bbox_offset
            xmax = int(float(get_element(bbox, "xmax", 1).text))
            ymax = int(float(get_element(bbox, "ymax", 1).text))
            if xmin > xmax:
                continue
            if ymin > ymax:
                continue
            # カテゴリー名取得
            category = get_element(obj, "name", 1).text
            # 初出のカテゴリー名の場合、リストに追加
            if category not in categories:
                new_id = len(categories)
                categories[category] = new_id
            # カテゴリー数カウント
            if category not in count_dict:
                count_dict[category] = 0
            else:
                count_dict[category] += 1

            # カテゴリーID取得
            category_id = categories[category]

            # print(f'{filename} {xmin}-{xmax} {ymin}-{ymax}')
            # assert xmax > xmin
            # assert ymax > ymin
            bbox_width = abs(xmax - xmin)
            bbox_height = abs(ymax - ymin)

            # JSON Dict追加
            annotation_info = {
                "area": bbox_width * bbox_height,
                "iscrowd": 0,
                "image_id": image_id,
                "bbox": [xmin, ymin, bbox_width, bbox_height],
                "category_id": category_id,
                "id": bbox_id,
                "ignore": 0,
                "segmentation": [],
            }
            json_dict["annotations"].append(annotation_info)

            bbox_id = bbox_id + 1

        if start_image_id is not None:
            image_id_count = image_id_count + 1

    # カテゴリー情報
    max_category_id = 0
    for category_name, category_id in categories.items():
        category_info = {
            "supercategory": "none",
            "id": category_id,
            "name": category_name
        }
        json_dict["categories"].append(category_info)

        if max_category_id <= category_id:
            max_category_id = category_id + 1

    if reserve_category_num is not None:
        for index in range(len(json_dict["categories"]), reserve_category_num):
            category_info = {
                "supercategory": "none",
                "id": max_category_id,
                "name": 'reserve_' + str(index).zfill(4)
            }
            json_dict["categories"].append(category_info)

            max_category_id += 1

    print(count_dict)

    return json_dict


if __name__ == "__main__":
    main()
python 06_convert_voc_to_coco.py \
train train/train_annotations.json \
--start_image_id=0

python 06_convert_voc_to_coco.py \
validation validation/validation_annotations.json \
--start_image_id=10000000
PINTOPINTO

YOLOXトレーニング用データ階層の作成

mkdir -p dataset/images/train2017
mkdir -p dataset/images/val2017
mkdir -p dataset/images/annotations

cp -rf train/*.jpg dataset/images/train2017
cp -rf train/*.PNG dataset/images/train2017
cp -rf train/*.png dataset/images/train2017

cp -rf validation/*.jpg dataset/images/val2017
cp -rf validation/*.PNG dataset/images/val2017
cp -rf validation/*.png dataset/images/val2017

cp -rf train/train_annotations.json dataset/images/annotations
cp -rf validation/validation_annotations.json dataset/images/annotations
PINTOPINTO
  • epoch=130 あたりがベストっぽい
python train.py \
-f x.py \
-d 1 \
-b 32 \
--fp16 \
-o \
-c yolox_x.pth
PINTOPINTO
  • 155 epochの結果
 Average Precision  (AP) @[ IoU=0.50:0.95 | area=   all | maxDets=100 ] = 0.568
 Average Precision  (AP) @[ IoU=0.50      | area=   all | maxDets=100 ] = 0.837
 Average Precision  (AP) @[ IoU=0.75      | area=   all | maxDets=100 ] = 0.611
 Average Precision  (AP) @[ IoU=0.50:0.95 | area= small | maxDets=100 ] = 0.396
 Average Precision  (AP) @[ IoU=0.50:0.95 | area=medium | maxDets=100 ] = 0.746
 Average Precision  (AP) @[ IoU=0.50:0.95 | area= large | maxDets=100 ] = 0.882
 Average Recall     (AR) @[ IoU=0.50:0.95 | area=   all | maxDets=  1 ] = 0.181
 Average Recall     (AR) @[ IoU=0.50:0.95 | area=   all | maxDets= 10 ] = 0.512
 Average Recall     (AR) @[ IoU=0.50:0.95 | area=   all | maxDets=100 ] = 0.605
 Average Recall     (AR) @[ IoU=0.50:0.95 | area= small | maxDets=100 ] = 0.457
 Average Recall     (AR) @[ IoU=0.50:0.95 | area=medium | maxDets=100 ] = 0.781
 Average Recall     (AR) @[ IoU=0.50:0.95 | area= large | maxDets=100 ] = 0.901
per class AP:
| class   | AP     | class   | AP     | class   | AP     |
|:--------|:-------|:--------|:-------|:--------|:-------|
| body    | 62.314 | head    | 59.383 | hand    | 53.498 |
| face    | 51.892 |         |        |         |        |
per class AR:
| class   | AR     | class   | AR     | class   | AR     |
|:--------|:-------|:--------|:-------|:--------|:-------|
| body    | 65.385 | head    | 62.703 | hand    | 57.922 |
| face    | 56.005 |         |        |         |        |
  • データセット強化前の結果
 Average Precision  (AP) @[ IoU=0.50:0.95 | area=   all | maxDets=100 ] = 0.526
 Average Precision  (AP) @[ IoU=0.50      | area=   all | maxDets=100 ] = 0.800
 Average Precision  (AP) @[ IoU=0.75      | area=   all | maxDets=100 ] = 0.555
 Average Precision  (AP) @[ IoU=0.50:0.95 | area= small | maxDets=100 ] = 0.374
 Average Precision  (AP) @[ IoU=0.50:0.95 | area=medium | maxDets=100 ] = 0.745
 Average Precision  (AP) @[ IoU=0.50:0.95 | area= large | maxDets=100 ] = 0.884
 Average Recall     (AR) @[ IoU=0.50:0.95 | area=   all | maxDets=  1 ] = 0.166
 Average Recall     (AR) @[ IoU=0.50:0.95 | area=   all | maxDets= 10 ] = 0.465
 Average Recall     (AR) @[ IoU=0.50:0.95 | area=   all | maxDets=100 ] = 0.574
 Average Recall     (AR) @[ IoU=0.50:0.95 | area= small | maxDets=100 ] = 0.446
 Average Recall     (AR) @[ IoU=0.50:0.95 | area=medium | maxDets=100 ] = 0.785
 Average Recall     (AR) @[ IoU=0.50:0.95 | area= large | maxDets=100 ] = 0.902
per class AP:
| class   | AP     | class   | AP     | class   | AP     |
|:--------|:-------|:--------|:-------|:--------|:-------|
| body    | 56.340 | head    | 54.669 | hand    | 47.280 |
| face    | 51.928 |         |        |         |        |
per class AR:
| class   | AR     | class   | AR     | class   | AR     |
|:--------|:-------|:--------|:-------|:--------|:-------|
| body    | 61.122 | head    | 58.182 | hand    | 53.339 |
| face    | 56.775 |         |        |         |        |