🗺️

Solafune 畑領域のセグメンテーション 17位 回顧録

2024/08/28に公開

Solafuneの畑領域のセグメンテーションに出たので感想や、自分なりの振り返りを行う。

解法

使用環境

google colab(無料版)+kaggle kernel環境(GPU P100・2024/6/5時点の環境)

手法

  • detectron2のinstance segmentation

https://github.com/facebookresearch/detectron2

  • 5Fold分をアンサンブル。
  • 検出しにくい画像※(後述)に対しては、SAHIによる予測を適用

backbone

mask_rcnn_X_101_32x8d_FPN_3xを利用(おそらくresnext101_32x4d)

学習設定

以下の通り(一部検出が容易な画像に対しては、PRE_NMS_TOPKの数値を下げる)

cfg = get_cfg()
cfg.merge_from_file(model_zoo.get_config_file("COCO-InstanceSegmentation/mask_rcnn_X_101_32x8d_FPN_3x.yaml")) #どのモデルの設定を使うかの確認
cfg.DATASETS.TRAIN = ("field_train",) #学習データセットの指定(ここ指定しないとエラーになる)
cfg.DATASETS.TEST = ("field_valid", ) #評価データセットの指定(ここ指定しないとエラーになる)
cfg.DATALOADER.NUM_WORKERS = 2
cfg.MODEL.WEIGHTS = model_zoo.get_checkpoint_url("COCO-InstanceSegmentation/mask_rcnn_X_101_32x8d_FPN_3x.yaml")  # Let training initialize from model zoo
cfg.SOLVER.IMS_PER_BATCH = 1  # RAMが16GBだと1が限界
cfg.SOLVER.BASE_LR = 0.001  # 0.01だと学習が発散
cfg.SOLVER.MAX_ITER = 10000 
cfg.SOLVER.STEPS = [5000, 8000, 9000]  # 学習率を減衰させるタイミング
cfg.MODEL.ROI_HEADS.BATCH_SIZE_PER_IMAGE = 512
cfg.MODEL.ROI_HEADS.NUM_CLASSES = 1

cfg.MODEL.RPN.NMS_THRESH = 0.5
cfg.MODEL.RPN.BATCH_SIZE_PER_IMAGE = 512

cfg.MODEL.RPN.PRE_NMS_TOPK_TRAIN = 5000
cfg.MODEL.RPN.POST_NMS_TOPK_TRAIN = 5000
cfg.MODEL.RPN.LOSS_WEIGHT = 5

cfg.INPUT.CROP.ENABLED = True
cfg.INPUT.CROP.ENABLED = [0.75, 0.75]

前処理

tif画像からRGBの画素を抽出し、画像として保存。一部欠損があったので、近くの画素から拝借して埋め込んだ。
前処理は以下のコード

def convert_RGB(input_image):
    convert_image = image[:,:,1:4].copy()
    RGB_MAX, RGB_MIN = convert_image.max(), convert_image.min()
    for i in range(3):
        #convert_image[:, :, i] = (convert_image[:, :, i]-convert_image[:, :, i].min())/(convert_image[:, :, i].max()-convert_image[:, :, i].min())
        convert_image[:, :, i] = (convert_image[:, :, i]-convert_image.min())/(RGB_MAX-convert_image.min())
        convert_image[:, :, i] = (convert_image[:, :, i]*255)
    convert_image = convert_image.astype(np.uint8)
    return convert_image

for annotation_dict in train_annotation["images"]:
    file_name = annotation_dict["file_name"]
    tif_ds = gdal.Open(f"/content/train_images/images/{file_name}", gdalconst.GA_ReadOnly)
    ul_x,x_res,r_rot,ul_y,c_rot,y_res = tif_ds.GetGeoTransform()
    tif_ds = tif_ds.ReadAsArray().transpose(1,2,0)
    tif_ds = fillna_tif_image(file_name, tif_ds)
    height, width = tif_ds.shape[0], tif_ds.shape[1]

    image = convert_RGB(tif_ds)

    image = image.astype(np.uint8)
    cv2.imwrite(f"/content/train_images_png/{file_name[:-4]}.png", image)

データ拡張

デフォルトのデータ拡張(水平方向のflip)を利用(逆に他のものを利用すると精度が下がった)

後処理

SAMによる予測を利用して、検出方法を分類をする。

定義は以下の通り

  • SAMによる検出面積割合が大きいもの(75%以上)が画像①
    • 予測モデルにPRE(POST)_NMS_TOPK_TESTを1000に
  • SAMによる検出面積が小さいもの(0.3%以下)が画像①
    • 予測モデルにSAHIを追加
  • それ以外は画像③
    • そのまま

環境について

2024/6/8時点のkaggleのkernelでdetectron2を実行するにはdetectron2のインストールを以下の方法でする必要がある。

!python -m pip install pyyaml==5.1
import sys, os, distutils.core
# Note: This is a faster way to install detectron2 in Colab, but it does not include all functionalities (e.g. compiled operators).
# See https://detectron2.readthedocs.io/tutorials/install.html for full installation instructions
!git clone 'https://github.com/facebookresearch/detectron2'
dist = distutils.core.run_setup("./detectron2/setup.py")
!python -m pip install {' '.join([f"'{x}'" for x in dist.install_requires])}
sys.path.insert(0, os.path.abspath('./detectron2'))

公式のgithubのページにあるものは既に実行不可

# Properly install detectron2. (Please do not install twice in both ways)
# !python -m pip install 'git+https://github.com/facebookresearch/detectron2.git'

mmdetectionについて

mmdetectionはpytorchとcudaのバージョンが合わないせいかkaggleのkernelやcolabで実行することが出来なかった。(6/8のタイミングでは。時期が変わるとさらに変わる可能性あり。)おそらくcudaのversionなどを変えることで実行は出来るが、ここは試さなかった。

colabの場合一部バージョンを換えると実行は出来るが、環境のセットアップに30分くらいかかり、gpuリソースが無駄になると判断。結局mmdetectionはcloudに課金するかしないと使うのがお手軽に使用するのは難しいかもしれない。

これ以降はコンペの感想・挑戦期間の振り返りなので解法以外の興味ない方は読み終えて大丈夫です。

コンペ取り組みの流れ

  1. tifの衛星情報からルールベースで領域を分類(セグメンテーションみたいな感じ)
  2. tifの衛星情報からロジスティック回帰で領域を分類
  3. mmdetection, detectron2で検出に挑戦
  4. インスタンスセグメンテーションの全体の予測を画像にしたうえで、領域を検出
  5. インスタンスセグメンテーションのそれぞれの予測から領域を検出
  6. アンサンブルの実装
  7. PRE_NMS_TOPK_TRAIN, POST_NMS_TOPK_TRAIN, PRE_NMS_TOPK_TEST, POST_NMS_TOPK_TESTの数値を変更
  8. ResNext101に変更
  9. 検出難易度に応じて検出方法を変更
  10. SAHIの実装
  11. 上記に対応したアンサンブルに変更

詳細解説

  1. 最初の取り組み
  • tifの衛星情報からルールベースで領域を分類(セグメンテーションみたいな感じ)
  • tifの衛星情報からロジスティック回帰で領域を分類

このタイミングではcolab環境を利用。

tif画像の仕組みや、ルールベースで解くことでAIに頼らず基本的な解法で解けないかを確認していた。

ただこの頃は評価指標についてあまり理解していなかった(IOUみたいなセグメンテーションかと思っていた)ので、

多少理解が進んだ今なら、エッジの検出をしてそれぞれを候補にして、どれが畑かを分類するとかは考えられるかもしれない。(それでも精度が出るかは怪しいが...)

SCOREは0.022876

  1. 深層学習に頼る
  • mmdetection, detectron2で検出に挑戦
  • インスタンスセグメンテーションの全体の予測を画像にしたうえで、領域を検出
  • インスタンスセグメンテーションのそれぞれの予測から領域を検出

ルールベースの調査に飽きてきたのと、早い段階でGPUのリソースを使用した方が、使わないよりお得だと感じ始めたので深層学習を用いた方法に手を出すことに。

当初はcolabを利用していたが、ライブラリのインストールに30分以上かかったり、データセットの読み込みに時間がかかり、gpuで学習するまでに時間がかかる問題が発生した。

なので短時間ですぐに学習が開始できるkaggle kernelの方を利用することにした。

mmdetection, detectron2で検出に挑戦したが、便利さやそもそも実行できる環境を考えると、detectron2以外に利用できる選択肢がなかったので、detectron2を利用。(mmdetectionはcudaの環境の違いかGPU環境ではエラーが発生し学習が不可)

当初はインスタンスセグメンテーションの意味をあまり理解していなかったので、インスタンスセグメンテーションの全ての検出候補のセグメンテーションを一つの画像にしてから、領域を検出していた。スコア: 0.19023

predict_array = outputs['instances'].pred_masks
predict_sum = predict_array.detach().cpu().numpy().sum(axis=0)
predict_image = (predict_sum > 0).astype(np.uint8)
print(predict_image.shape)
    
#ポリゴンで後処理する
contours, _ = cv2.findContours(predict_image, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

#面積が一定以上のポリゴンをフィルタリング
contours = list(filter(lambda x: cv2.contourArea(x) > 100, contours))

polygons = [cv2.approxPolyDP(contour, 0.05, True) for contour in contours]

しかしこれだと、一部セグメンテーションがくっつきでそれぞれの領域が上手く検出できなかったので、精度が出ないことが判明。この辺りからPanoptic Qualityの意味を少しづつ理解できるようになっていった。

最終的に各検出領域に対して、ポリゴンを作成するほうが精度がでることが判明したので、それを利用することに。スコア: 0.26899

SCOREは0.022876→0.19023→0.26899

  1. アンサンブルの実装
  • アンサンブルの実装

ここまでは1Fold分で予測していたが、最終的に5Fold全てのモデルで予測して、それらをアンサンブルする方法が良い方が精度良いことが多いので、アンサンブルの方法を模索することに。

物体検出のアンサンブルは有名なものがあるが、インスタンスセグメンテーションには対応していないので、一部はオリジナルで実装することに。

方法は、予測したバウンディングボックスをwbfでアンサンブル。

最終的に出力した検出候補に対して、各モデルの予測が最も一致する検出候補をのセグメンテーション部分を平均することにした。

コードは以下の通り。

ensemble_boxes, ensemble_scores, ensemble_labels = weighted_boxes_fusion(boxes_list, scores_list, labels_list,
                                                                             weights=np.ones(len(boxes_list)), iou_thr=0.9, skip_box_thr=0.0001)

ensemble_boxes = ensemble_boxes[ensemble_scores >= score_th]
ensemble_scores = ensemble_scores[ensemble_scores >= score_th]
    
ensemble_masks = []
for i, (ensemble_box, ensemble_score) in enumerate(zip(ensemble_boxes, ensemble_scores)):
    # 箱の面積と元に戻す
    ensemble_box = ensemble_box * np.array([w,h,w,h])
    ensemble_box_area = (ensemble_box[2] - ensemble_box[0]) * (ensemble_box[3] - ensemble_box[1])
            
    # アンサンブルしたボックスをfor文で回し、それぞれの予測で最も一致するボックスを抽出する
    best_ious, best_indexes = [], []
    for predict_box in boxes_list:
        # 箱の予測を元に戻す
        predict_box = predict_box * np.array([w,h,w,h])

        # 各種予測の総面積を算出
        areas = (predict_box[:, 2] - predict_box[:, 0]) * (predict_box[:, 3] - predict_box[:, 1])

        # それぞれのボックスの最小と最大を算出、最も類似しているボックス同士(つまりx,yの最大値と最小値の差が最も小さいもの)が、x,yの最大値と最小値となる
        # xx1, yy1: 検出したboxの中から最も小さいものを検出
        xx1 = np.maximum(ensemble_box[0], predict_box[:, 0])
        yy1 = np.maximum(ensemble_box[1], predict_box[:, 1])
        # xx2, yy2: 検出したboxの中から最も大きいものを検出
        xx2 = np.minimum(ensemble_box[2], predict_box[:, 2])
        yy2 = np.minimum(ensemble_box[3], predict_box[:, 3])

        # 重複箇所を検出(予測を大きく外している場合、)
        dw = np.maximum(0.0, xx2 - xx1)
        dh = np.maximum(0.0, yy2 - yy1)

        # areas: 予測ボックスの面積たち
        # ensemble_box_area: 調査元となるboxの面積
        # dw*dh: 二つの箱の座標を組み合わせた時の総面積
        ovr = (dw*dh) / (ensemble_box_area + areas - (dw*dh))
        #print(i, np.max(ovr), np.argmax(ovr))
        best_iou, best_index = np.max(ovr), np.argmax(ovr)
        best_ious.append(best_iou)
        best_indexes.append(best_index)

# 最も一致したボックスのインデックスからマスクを取り出してアンサンブル
ensemble_mask = np.zeros((h, w))
for predict_masks, predict_score, best_index, best_iou in zip(masks_list, scores_list, best_indexes, best_ious):
    use_mask = predict_masks[best_index]
    mask_score = predict_score[best_index]

    if best_iou >= 0.75:
        ensemble_mask += use_mask.astype(int)
    else:
        continue
    # 予測値の平均を取る
    ensemble_mask /= len(masks_list)
    ensemble_box = np.round(ensemble_box, 0).astype(int)

    ensemble_mask = (ensemble_mask > mask_th).astype(np.uint8)
    ensemble_masks.append(ensemble_mask)
# ここに予測の後処理を投入
ensemble_masks = np.stack(ensemble_masks)

SCOREは0.26899→0.31705

振り返るとこのスコアの向上は検出部分が大幅に増えたことによるスコアの向上だと思われる。(実際に検出判定を少しでも厳しくするとスコアが下がった)

  1. MaskRCNNの構造とかハイパーパラメータとか
  • PRE_NMS_TOPK_TRAIN, POST_NMS_TOPK_TRAIN, PRE_NMS_TOPK_TEST, POST_NMS_TOPK_TESTの数値を変更
  • backboneをResNext101に変更

この辺りから小さな領域部分が上手く検出できていないことを確認。

そもそも検出数が数百~数千ある状態で100個程度しか検出できていないことに疑問を持つことに。

(本来は元論文を読むべきだが読む気が何故か湧かなかったので)ChatGPTに聞いたところ、以下の回答が。

  • RPN.IOU_THRESHOLDS:
  • IOU閾値を調整することで、より精度の高い候補を得ることができます。例えば、>IOU_THRESHOLDS: [0.4, 0.7]からIOU_THRESHOLDS: [0.5, 0.8]に変更すると、より高いIOUを持つ候補を優先するようになります。
  • NMS(Non-Maximum Suppression)閾値を下げることで、重複する検出候補を減らし、精度を向上させます。例えば、NMS_THRESH: 0.7NMS_THRESH: 0.6に変更します。
    • RPN.PRE_NMS_TOPK_TRAINRPN.POST_NMS_TOPK_TRAIN:
      • 訓練時の候補数を増やすことで、より多くの候補を検討することができます。例えば、>PRE_NMS_TOPK_TRAIN: 2000PRE_NMS_TOPK_TRAIN: 3000に、POST_NMS_TOPK_TRAIN: >1000POST_NMS_TOPK_TRAIN: 1500に変更します。
    • RPN.POSITIVE_FRACTION:
      • 正例の割合を増やすことで、より多くの正例を含むバッチを作成できます。例えば、>POSITIVE_FRACTION: 0.5POSITIVE_FRACTION: 0.6に変更します。

結論、PRE_NMS_TOPK_TRAIN(TEST), PRE_NMS_TOPK_TRAIN(TEST)を増やすと良いことが判明。またPRE_NMS_TOPK_TRAIN, PRE_NMS_TOPK_TRAINを増やして学習すると精度が良くなった。

NMS_THRESHは数値上精度はよくなったが、正直誤差の範囲内なので良い調整といえるか怪しい。

RPN.POSITIVE_FRACTIONは変化なし。RPN.IOU_THRESHOLDSは失念。

その後思いつくことがなくなってきたので、backboneをResnet50からResNext101に変更。

SCOREは0.31704→0.38327

  1. 画像に合わせて予測方法を変更
  • SAHIの実装
  • 上記に対応したアンサンブルに変更

5FoldのCVを確認すると、基本的に検出数が多いとスコアが上がるが、一方で若干下がる画像①も存在した。

この画像を確認すると、初期に作成したモデルでも十分に高い精度で予測可能な画像であることが明らかになった。

それ以外の画像は検出数を増やすと精度が良くなるため、画像を分割してそれぞれに予測をすれば検出数が増えて精度が上がるのでは?と考えた。当初は自分で実装していたが、当然そういうのは先人(SAHI)がいるわけで。。。

ただSAHIによる予測は、大体の場合精度が下がる。しかしとにかく検出数を増やすと精度が上がる画像②に対しては有効だった。

そこで画像に応じて予測を変えることでスコアを上げることにした。

画像に応じて予測を変える基準として、教師なしのSAMによる予測を利用した。定義は以下の通りでその下に、予測方法を記載した。

  • SAMによる検出面積割合が大きいもの(75%以上)が画像①
    • 4.の予測モデルにPRE(POST)_NMS_TOPK_TESTを1000に
  • SAMによる検出面積が小さいもの(0.3%以下)が画像①
    • 4.の予測モデルにSAHIを追加
  • それ以外は画像③
    • 4.の予測をそのまま

SCOREは0.38327→0.39894

感想

物体検出は何度か参加させてもらっているが、instance segmentation自体は初めてだったので、その辺り何も知らない状態から、仮説を持って取り組むのは楽しかった。そこから精度が上って上位になるともっと楽しくなるが、ただあまりにも上位を目指すように取り組むと、一瞬でもスコアが出なくなる瞬間に気持ちがなえてしまいコンペに対するモチベが下がってしまうのは良くない。

ただしんどい状態で上位目指して頑張っても得られるものがないので、こだわり持たずに程々に楽しんで得られるものがある状態を維持できれば今後はOKとしたい。

Discussion