kaggleのコンペ解説：胸部X線画像による病気の検知


こちらのkaggleのコンペについて解説していきます。
https://www.kaggle.com/c/vinbigdata-chest-xray-abnormalities-detection/overview

タイトルは「VinBigData Chest X-ray Abnormalities Detection」です。

コンペの趣旨は以下です。
・トレーニングデータとして胸部X線画像および14種類の病気の正解ラベルと位置情報（バウンディングボックス）があり、それからモデルを学習させる
・テストデータの画像に対して学習モデルにより病気を検知させて精度が高かった人が賞金をもらう


#賞金
1st Place - $20,000
2nd Place - $14,000
3rd Place - $8,000
Special Prize: $8,000
となっています。
https://www.kaggle.com/c/vinbigdata-chest-xray-abnormalities-detection/overview/prizes

#評価基準
評価基準については
The challenge uses the standard PASCAL VOC 2010 mean Average Precision (mAP) at IoU > 0.4.
と記載されています。
https://www.kaggle.com/c/vinbigdata-chest-xray-abnormalities-detection/overview/evaluation

mean Average Precisionについては
https://jonathan-hui.medium.com/map-mean-average-precision-for-object-detection-45c121a31173
がわかりやすいと思います。
IoU (Intersection over union)は正解と予測モデルのバウンディングボックスの重なっている割合を表しており、これが0.4より大きいことが条件となっています。


提出するデータのフォーマットとしては、テストデータの画像に対し予測モデルから得られた
class ID：病名
confidence score：信頼度
xmin, ymin, xmax, ymax：バウンディングボックスの四隅の位置座標
を列としている必要があります。


#データ

今回のコンペ用のデータは
https://www.kaggle.com/c/vinbigdata-chest-xray-abnormalities-detection/data
からダウンロードできます。192GBほどのもので重いです。X線画像データはdicomという形式で保存されています。

この元データを使うと計算が大変ですが、リトアニア人のraddarさんがデータをダウンサンプリングしてjpgファイルとして約3.4GBにしたものを以下のURLに保存してくれています。
https://www.kaggle.com/raddar/vinbigdata-competition-jpg-data-3x-downsampled

今回ではこのダウンサンプリングしたデータを使っていきます。

データは18,000個のX線画像のjpgファイルであり、そのうち15,000個がトレーニングデータ、3,000個がテストデータです。
jpgファイル以外にtrain_downsampled.csvというファイルがあり、トレーニングデータ15,000個それぞれのimage_idに対し、以下14種類の病気のラベル情報があります。

0 - Aortic enlargement
1 - Atelectasis
2 - Calcification
3 - Cardiomegaly
4 - Consolidation
5 - ILD
6 - Infiltration
7 - Lung Opacity
8 - Nodule/Mass
9 - Other lesion
10 - Pleural effusion
11 - Pleural thickening
12 - Pneumothorax
13 - Pulmonary fibrosis

また、病気が見つからなかった画像は、
14 - No finding
となっています。


#データ解析の下準備: 有用なNoteBookの紹介

コンペに参加して賞金を狙いたいところですが、ここでは今回のコンペの下準備として有用な情報を共有している人のNotebookを紹介します。
インド人のSreevishnu DamodaranさんのNotebookなのですが、以下このコードを見ていきます。
https://www.kaggle.com/sreevishnudamodaran/vinbigdata-fusing-bboxes-coco-dataset

タイトルは「VinBigData - Fusing Bboxes + Coco Dataset」となっており、今回のコンペでは以下の問題があると指摘しています。
・トレーニングデータにおいて、複数の放射線医師によって同じ病気に対し複数のデータが入っている
・同じエリアに複数の病気が検知されている

これらの問題を回避するため、Sreevishnu Damodaranさんは
・Non-maximum Suppression (NMS)
・Soft-NMS
・Non-maximum Weighted (NMW)
・Weighted Bboxes Fusion (WBF)
という四つの手法の対応策を行い、コードを公開してくれています。
以下、その内容について紹介します。

準備としてopencvとensemble-boxesを以下でインストールします。
pip install opencv-python
pip install ensemble-boxes

Jupyter Notebookでライブラリをインポートします。

```
%matplotlib inline

import os
import matplotlib.pyplot as plt
import seaborn as sns
from matplotlib import rcParams
sns.set(rc={"font.size":9,"axes.titlesize":15,"axes.labelsize":9,
            "axes.titlepad":11, "axes.labelpad":9, "legend.fontsize":7,
            "legend.title_fontsize":7, 'axes.grid' : False})
import cv2
import json
import pandas as pd
import glob
import os.path as osp
from path import Path
import datetime
import numpy as np
from tqdm.auto import tqdm
import random
import shutil
from sklearn.model_selection import train_test_split

from ensemble_boxes import *
import warnings
from collections import Counter
```

train_downsampled.csvの内容を表示してみます。

```
train_annotations = pd.read_csv("../input/vinbigdata-competition-jpg-data-3x-downsampled/train_downsampled.csv")
train_annotations
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543160/d9673271-3ea9-b4cf-9666-e0997796ba9a.png)

67914行のデータとなっています。
これがトレーニングデータについての情報です。

class_nameがNo findingのものは病気がみつからなかったものですが、モデルを学習させる上では不要ですのでNo finding以外のものだけのデータを作ります。
さらに、jpgファイルのパスをimage_pathというデータとして追加します。

```
train_annotations = train_annotations[train_annotations.class_id!=14]
train_annotations['image_path'] = train_annotations['image_id'].map(lambda x:os.path.join('../input/vinbigdata-competition-jpg-data-3x-downsampled/train/train', str(x)+'.jpg'))
train_annotations.head(5)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543160/3285cf33-531c-fbc6-a1db-d74c6eb617d4.png)

発見された病気（No finding以外）データの行数と画像の数を確認します。

```
anno_count = train_annotations.shape[0]
print("Number of Annotations with abnormalities:", anno_count)
imagepaths = train_annotations['image_path'].unique()
print("Number of Images with abnormalities:",len(imagepaths))
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543160/ddde0510-090a-aba1-faff-7584656cb05f.png)

ここで、１画像に病気が複数検知されているものがあるので、
発見された病気の数＞画像の数
となっています。

もとは67914行のデータでしたが、病気がみつかったものだけにすると36096行のデータになりました。
また、独立な画像の個数としては4394個あることがわかりました。


画像や病気のバウンディングボックスを描画するための関数を定義します。

```
def plot_img(img, size=(18, 18), is_rgb=True, title="", cmap='gray'):
    plt.figure(figsize=size)
    plt.imshow(img, cmap=cmap)
    plt.suptitle(title)
    plt.show()

def plot_imgs(imgs, cols=2, size=10, is_rgb=True, title="", cmap='gray', img_size=None):
    rows = len(imgs)//cols + 1
    fig = plt.figure(figsize=(cols*size, rows*size))
    for i, img in enumerate(imgs):
        if img_size is not None:
            img = cv2.resize(img, img_size)
        fig.add_subplot(rows, cols, i+1)
        plt.imshow(img, cmap=cmap)
    plt.suptitle(title)
    
def draw_bbox(image, box, label, color):   
    alpha = 0.1
    alpha_box = 0.4
    overlay_bbox = image.copy()
    overlay_text = image.copy()
    output = image.copy()

    text_width, text_height = cv2.getTextSize(label.upper(), cv2.FONT_HERSHEY_SIMPLEX, 0.6, 1)[0]
    cv2.rectangle(overlay_bbox, (box[0], box[1]), (box[2], box[3]),
                color, -1)
    cv2.addWeighted(overlay_bbox, alpha, output, 1 - alpha, 0, output)
    cv2.rectangle(overlay_text, (box[0], box[1]-7-text_height), (box[0]+text_width+2, box[1]),
                (0, 0, 0), -1)
    cv2.addWeighted(overlay_text, alpha_box, output, 1 - alpha_box, 0, output)
    cv2.rectangle(output, (box[0], box[1]), (box[2], box[3]),
                    color, thickness)
    cv2.putText(output, label.upper(), (box[0], box[1]-5),
            cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1, cv2.LINE_AA)
    return output
```

病気のラベル名を定義します。

```
labels =  [
            "__ignore__",
            "Aortic_enlargement",
            "Atelectasis",
            "Calcification",
            "Cardiomegaly",
            "Consolidation",
            "ILD",
            "Infiltration",
            "Lung_Opacity",
            "Nodule/Mass",
            "Other_lesion",
            "Pleural_effusion",
            "Pleural_thickening",
            "Pneumothorax",
            "Pulmonary_fibrosis"
            ]
viz_labels = labels[1:]
```

トレーニングデータの画像と正解バウンディングボックスを表示します。

```
# map label_id to specify color
#label2color = [[random.randint(0,255) for i in range(3)] for class_id in viz_labels]
label2color = [[59, 238, 119], [222, 21, 229], [94, 49, 164], [206, 221, 133], [117, 75, 3],
                 [210, 224, 119], [211, 176, 166], [63, 7, 197], [102, 65, 77], [194, 134, 175],
                 [209, 219, 50], [255, 44, 47], [89, 125, 149], [110, 27, 100]]

thickness = 3
imgs = []

for img_id, path in zip(train_annotations['image_id'][:6], train_annotations['image_path'][:6]):

    boxes = train_annotations.loc[train_annotations['image_id'] == img_id,
                                  ['x_min', 'y_min', 'x_max', 'y_max']].values
    img_labels = train_annotations.loc[train_annotations['image_id'] == img_id, ['class_id']].values.squeeze()
    
    img = cv2.imread(path)
    
    for label_id, box in zip(img_labels, boxes):
        color = label2color[label_id]
        img = draw_bbox(img, list(np.int_(box)), viz_labels[label_id], color)
    imgs.append(img)

plot_imgs(imgs, size=9, cmap=None)
plt.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543160/0e904a3f-c71f-6a2a-165d-7d50d4d83e11.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543160/0141fc35-2d8f-177f-09b0-283044ab2aed.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543160/cbead110-e3a1-c580-d513-4d569dd5ba61.png)

同じ病気がほとんど同じ箇所で複数検知されていることがわかります。
以下、対応策をみていきます。

##Non-maximum Suppression (NMS)

Non-maximum Suppressionによって重複を回避します。

```
iou_thr = 0.5
skip_box_thr = 0.0001
viz_images = []

for i, path in tqdm(enumerate(imagepaths[5:8])):
    img_array  = cv2.imread(path)
    image_basename = Path(path).stem
    print(f"(\'{image_basename}\', \'{path}\')")
    img_annotations = train_annotations[train_annotations.image_id==image_basename]

    boxes_viz = img_annotations[['x_min', 'y_min', 'x_max', 'y_max']].to_numpy().tolist()
    labels_viz = img_annotations['class_id'].to_numpy().tolist()
    
    print("Bboxes before nms:\n", boxes_viz)
    print("Labels before nms:\n", labels_viz)
    
    ## Visualize Original Bboxes
    img_before = img_array.copy()
    for box, label in zip(boxes_viz, labels_viz):
        x_min, y_min, x_max, y_max = (box[0], box[1], box[2], box[3])
        color = label2color[int(label)]
        img_before = draw_bbox(img_before, list(np.int_(box)), viz_labels[label], color)
    viz_images.append(img_before)
    
    boxes_list = []
    scores_list = []
    labels_list = []
    weights = []
    
    boxes_single = []
    labels_single = []
    
    cls_ids = img_annotations['class_id'].unique().tolist()
    count_dict = Counter(img_annotations['class_id'].tolist())
    print(count_dict)

    for cid in cls_ids:       
        ## Performing Fusing operation only for multiple bboxes with the same label
        if count_dict[cid]==1:
            labels_single.append(cid)
            boxes_single.append(img_annotations[img_annotations.class_id==cid][['x_min', 'y_min', 'x_max', 'y_max']].to_numpy().squeeze().tolist())

        else:
            cls_list =img_annotations[img_annotations.class_id==cid]['class_id'].tolist()
            labels_list.append(cls_list)
            bbox = img_annotations[img_annotations.class_id==cid][['x_min', 'y_min', 'x_max', 'y_max']].to_numpy()
            ## Normalizing Bbox by Image Width and Height
            bbox = bbox/(img_array.shape[1], img_array.shape[0], img_array.shape[1], img_array.shape[0])
            bbox = np.clip(bbox, 0, 1)
            boxes_list.append(bbox.tolist())
            scores_list.append(np.ones(len(cls_list)).tolist())

            weights.append(1)
            
    # Perform NMS
    boxes, scores, box_labels = nms(boxes_list, scores_list, labels_list, weights=weights,
                                    iou_thr=iou_thr)
    
    boxes = boxes*(img_array.shape[1], img_array.shape[0], img_array.shape[1], img_array.shape[0])
    boxes = boxes.round(1).tolist()
    box_labels = box_labels.astype(int).tolist()

    boxes.extend(boxes_single)
    box_labels.extend(labels_single)
    
    print("Bboxes after nms:\n", boxes)
    print("Labels after nms:\n", box_labels)
    
    ## Visualize Bboxes after operation
    img_after = img_array.copy()
    for box, label in zip(boxes, box_labels):
        color = label2color[int(label)]
        img_after = draw_bbox(img_after, list(np.int_(box)), viz_labels[label], color)
    viz_images.append(img_after)
    print()
        
plot_imgs(viz_images, cmap=None)
plt.figtext(0.3, 0.9,"Original Bboxes", va="top", ha="center", size=25)
plt.figtext(0.73, 0.9,"Non-max Suppression", va="top", ha="center", size=25)
plt.savefig('nms.png', bbox_inches='tight')
plt.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543160/5395925b-9b91-76cb-36fb-f306e0d9fe94.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543160/e44827f0-4179-7d3c-1cf6-a466a5702c79.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543160/66c8a0ae-20f2-21dd-17bd-386d253556ce.png)

重複を回避できていることがわかります。

他にもSreevishnu DamodaranさんのNotebookには
・Soft-NMS
・Non-maximum Weighted (NMW)
・Weighted Bboxes Fusion (WBF)
によって重複を回避するコードが書かれますが、ここでは割愛します。

#まとめと今後
記事の内容は以下でした。
・kaggleのコンペの「VinBigData Chest X-ray Abnormalities Detection」（胸部X線画像による病気の検知）の内容について紹介
・そのコンペ内で公開されているNotebookの中で、トレーニングデータにおいて病気のバウンディングボックスデータが重複しているという問題に対処するコードを紹介

これからモデルを学習させてこのコンペに参加しようと思いますが、進展ありましたら記事を更新します。