第6回富士フィルムBrain(s)コンテスト参加記

twitterでコンテストの広告が流れてきて、医療分野で富士フィルムちょっと興味あったので取り組んでみました。

# コンペ概要  
コンペは3つの課題から成っており、Q1は初心者向けの簡単な画像処理、Q2はclassification,Q3はsegmentationの課題でした。
データは[SDNET2018](https://www.sciencedirect.com/science/article/pii/S2352340918314082)という、デジタルカメラで撮影した壁・舗道・橋の画像です。 
画像の元のサイズは(3584x4608)ですが、これではCNNで学習するには重いということで(256x256)に分割されていました。結合して元のサイズに戻して処理したほうが色々と都合が良いので二度手間です。  
また、各画像は画像内にひび割れが存在する/しないのラベルが割り振られており、コンペではこの情報はメタデータとして利用する事ができました。

更に、__このコンペではtrainingデータに対する手動でのアノテーション(Hand-Labeling)が認められていました__。

### Q2:classification
Q2では、コンクリートを流し込むときに発生するPコン跡を検出し、その画像にPコンが写っているか/いないかを分類するタスクでした。
正解ラベルがバイナリ形式(0か1か)で与えられるため、classificationのタスクとして解くことが可能です。
評価指標はf1スコアでした。

### Q3:segmentation
Q3では、ひび割れのセグメンテーションを行うタスクでした。
正解ラベルが与えられない為、Hand-Labelingで教師データを作成するか、画像処理ベースでのアプローチによるセグメンテーションか、あるいはSDNET2018自体のひび割れのラベルを用いてclassificationの学習をし、Grad-CAMなどの注目領域をうまく活用してセグメンテーションを行うなどのアプローチが考えられました。
評価指標はIoUでした。

# solution
Q2,Q3それぞれに対して大まかにこんなことやったよ、というのを書いていきます。

## Q2 Pコンの分類
![キャプチャ3.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549401/fcfc74d1-584e-c931-1870-726fd96632ee.jpeg)

 

Q2は正解ラベルが与えられたclassificationです。なので、そのままCNNに入れて分類しても、Pコンなんて超わかりやすいですから、そこそこの精度は出ます。
ただ、このタスクの肝は、画像の端にちょっとだけ存在するPコンの検出精度がどれだけ上げられるか？にあると思いました。
じゃあ検出精度を上げるためにどうするかというと、元々(256x256)領域のパッチ画像ですから、その外側を広げてやれば良いことになります。画像の上下左右128pixelを広げることで、(512x512)の画像を作成し、ここからPコン跡を検出するというアプローチを取りました。
しかし、この場合だと元のパッチ画像に与えられたラベルと領域拡張後のラベルが異なる場合があります。そこで、タスクをセグメンテーションにしてやることによって、ピクセル単位でPコン跡が存在するかどうかを調べてから、分類タスクに落とし込むことにしました。

まず、[labelme](https://github.com/wkentaro/labelme)というアプリを用いて、アノテーションを行いました。結構適当に円形のアノテーションを当てはめていき、全72枚の画像に30分くらいでアノテーションしました。アノテーションデータはコンペのslackで公開しました。

次に、512のパッチ画像のデータセットを作成して、セグメンテーションモデルを学習させました。
モデルはUNetで、encoderにはefficientnet-b3を用いました。画像サイズ512に対してはb3~5くらいを使う印象があります。今回のコンペでは学習済みモデルを使用することは禁止されていたので、weight=Noneから学習させました。
そんなに複雑なタスクでも無いので、パラメータチューニングもそこそこに、Data Augmentation周りだけちょっと時間かけていじりました。重めのAugmentationがいい感じに精度出ました。

```py
def get_aug(p=1.0):
    return A.Compose([
        A.HorizontalFlip(p=0.5),
        A.VerticalFlip(p=0.5),
        A.ShiftScaleRotate(scale_limit=0.20, rotate_limit=10, shift_limit=0.1, p=0.5, border_mode=cv2.BORDER_REFLECT),
        A.RandomBrightnessContrast(p=0.5),
        A.CLAHE(p=0.5),
        A.GridDistortion(p=0.5),
        A.OpticalDistortion(p=0.5),
        A.RandomGamma(p=0.5),
        A.GaussNoise(p=0.5),
        A.HueSaturationValue(p=0.5),
        A.ToGray(p=0.5),
        A.CoarseDropout(max_holes=2,min_holes=1,max_height=100,max_width=100,min_height=40,min_width=40,mask_fill_value=0, p=0.3)
    ], p=p)
```

あとモデル周りで工夫した点は、
・最後の数epochはAugmentationを弱める
・RandomWeightedSamplerでPコン跡の画像割合を増やす(pos:neg=1:1)
・LossはBCEとDiceを組合わせる
とかしました。

CVのDiceが0.96くらい出たので、うまく学習できているなということで、セグメンテーションモデルは完成しました。
一回他モデルとのaverage-ensemblingも試したのですが、あまり結果が良くなかったのでUNet(enetb3)一本で行くことにしました。

あとはモデルから出力された512のマスク画像から中央256領域(元のパッチ画像)を切り抜いて、マスク画像の合計値(Pコン跡がどれだけ検出されたか)を求め、classificationを行います。

classificationの閾値はoof(out-of-fold)でf1スコアが最も高くなるようなマスク画像の合計ピクセル数としました。

<details><summary>ちょっとマニアックな学び(折りたたみ)</summary>
セグメンテーションの予測値は各ピクセルごとにどれだけの確率でそのピクセルがpositiveか？を出力しますが、結局提出時は0か1かに閾値で切り分けて提出することになります。
今回、モデルの学習は5foldで行ったので、test画像に対するモデルの予測値(0~1の確率)を得た後に、次の工程を踏むことになります。  
①5foldの予測値を合計して平均する
②各ピクセルに対して0.5を閾値としてバイナリ値にする
③予測したマスク画像の合計値を取る   
今回の場合、各ピクセルに対する予測値(つまりセグメンテーションの精度評価)を提出するのではなく、画像一枚に対する分類を行えば良いので、②の手順は省略して、①→③でも別に良いのですが、②を介した方が精度が良いことが分かりました。実際に画像を見て確認したところ、0.3くらいの予測値で出てきた偽陽性を排除するためにこれが役立っていることが分かりました。  
また、最初の段階では②→①→③の順番で処理を行っていたのですが、この精度が結構悪かった事に後で気づきました。よく考えれば①→②→③の方が筋が通っているというのは分かるのですが、結構精度が変わってくる(f1で0.03くらい)ので、この二点は結構大きな学びでした。

</details>


## Q3 ひび割れのセグメンテーション
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549401/b2619887-473e-03a0-c4b6-db6c9fd1bbcf.png)

※mask画像はpredictしたもの

Q3は基本的にはルールベースの画像処理のアプローチで進めました。  
理由としては、コンペの参考文献にリンクが上げられていた[画像処理によるひび割れ検出の論文](http://data.jci-net.or.jp/data_pdf/37/037-01-1291.pdf)を読んだ際に、ルールベースでもこんなにきれいに検出できるのか、と感心したので、とりあえずこの通りに実装してみようと思ったからです。

どんな処理を行ったのか、実際の画像を見ながら書いていきます。
##①メディアンフィルタを用いた影の補正
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549401/3c8b1b4e-97a6-2988-dc7b-f3be5e85acc1.png)
何やってんのこれ？という感じですが、例えば以下のような影の存在する画像の場合、そのまま二値化すると影がガッツリ誤検出される為(中央)、画像全体のコントラストを正規化しています(右)。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549401/22b99a0e-46d8-bbcd-ae0f-09712d939993.png)


##②Pタイル法を用いた2値化
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549401/1a40141e-a15a-522c-ba0a-2d77b9b53b54.png)

次に二値化を行います。二値化というと、大津の二値化だとか、open cvのadaptivethresholdの印象がありますが、今回は二値化でpositive(ひび割れ)にしたい面積がある程度求まっているので、Pタイル法という手法を用いました。Pタイル法は画素値のヒストグラムを並べたときに、画像全体の何%を占める画素値で閾値を決定し、その画素値より大きいか/小さいかで二値化を行うという方法です。多分opencvに無いので自分で実装しました。

上画像だとなんだかあまりうまく行っていない印象を受けますが、この後の処理でひび割れだけがくっきり残る為、とりあえず良しとしてください。
Pタイル法の閾値をキツめに設定してやると以下のようにひび割れ領域がしっかり見えるようになります。(th=0.07→0.008)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549401/8459efd5-52b7-d45e-fddc-1cc9e6ce6caa.png)

##二値化した画像の面積・輪郭の情報からノイズを除去
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549401/68d702b3-7d53-55fe-f22f-a42ce0588e46.png)


次にcv2.findContoursを用いて、positive部のひとかたまりの面積と輪郭を求め、閾値以下/以上ならそのかたまりをノイズとみなして除去する処理を行います。
ここは説明するよりコードを見た方が早いと思うので載せておきます。  

```py  
# 面積及び、面積/(周辺長^2)でのノイズ除去
# 境界を検出して描画する
contours, _ = cv2.findContours(mask, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
for i in contours:
    area = cv2.contourArea(i)

    #面積が200以下なら0で塗りつぶす
    if area < 200:
        mask = cv2.drawContours(mask, [i], 0, 0, -1) #最終引数-1で塗りつぶし

    #円形に近いなら0で塗りつぶす
    elif area/(cv2.arcLength(i,True)**2) > 0.008:
        mask = cv2.drawContours(mask, [i], 0, 0, -1)
```  

## 領域拡張法による細かいひび割れの修復
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549401/e918cbc9-1fb4-f1ec-0ce0-5c4e5ae176dd.png)
領域拡張法とは、ひび割れと検出されたピクセルの周囲のピクセルの値を参照して、先のPタイル法の閾値には引っかからないまでも多少ひびっぽいピクセル値ならそこもひび割れにしちゃおうというのを、これが無くなるまで繰り返す処理です。
これも自力で実装しました。(蟻本の水たまり問題が同じ感じですよね。競プロが役に立った)
1枚の画像が(3000x4000)くらいで、全探索で実装かつ、そこからdfsしていくので1枚の処理に10秒くらいかかります。ここは改善した方が良さそうかな…

画像の変化としては稲妻が走った感じになりました。実際にLBスコアもちゃんと上がったのでこれも効いたのかなと思います。

## Q2のセグメンモデルを用いたPコン跡の除去
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549401/49de3883-c6a1-1905-1089-53bf5823052e.png)
最後の仕上げに、Pコン部分が大きく偽陽性で出てしまっているので、これをQ2で作成したセグメンテーションモデルを用いて除去してあげます。このアプローチの元となった論文は、ハフ変換(画像から直線や円を検出する画像処理技術)を用いてPコン跡の除去をおこなっているのですが、手元ではあまりうまいこといかなかったのと、折角良いアノテーションデータとモデルがあるんだから使わなきゃもったいないということで使いました。


(Q2とQ3ではtrain/testの分け方が異なったので正確にはtrain画像を変更して新しく学習しました)


##完成！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549401/133840b8-7ba3-41c3-01b5-a23fa9ebd41a.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549401/2e18f18f-0b8f-16a0-8f90-469cbf8b4f15.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549401/3b1c6df3-a768-38eb-2d48-d9232162402a.png)

しっかりきれいにひび割れ検出できています！今まではセグメンテーションタスクといえばアノテーションデータをUNetに放り込むだけだったので、ルールベース主体でここまできれいに検出できると達成感がありました。

あとは各パッチ画像に対するひび割れあり/なしの分類データは使用して良いことになっていたので、これを用いてノイズを除去して提出という感じです。(これがスコアを上げる上ではかなり大きいのですが、実用上有益ではないのでただスコア上がるだけ、くらいの感覚です)

# 結果
Q2が4位(F1:0.99)で、Q3が6位くらい(IoU:0.24)でした。富士フィルムのカメラもらえるみたいです。やった。
※Q2はpublicがそのままprivateスコアでした。コンペ終わってから仕様を知ってビビりました。Q3はprivateスコアです。

# 上位の方の解法  
それぞれのタスクについて、一位、二位の方の解法を表彰会のLTで拝聴しました。
### Q2について：
一位、二位のお二方とも、僕の作ったアノテーションデータを使用されていました。
僕のアプローチと大きく異なるのは、お二方共に元の画像ラベルを用いてのclassificationも取り入れているという部分でした。
特に一位の方は、論文実装なども取り入れていて、高レベルな解法を聞くことができました。
パッチ画像外の情報を参照するという考え方は上位入賞者に共通で、これが最も大事な部分だったのかなと思います。

### Q3について：
一位、二位の方両方とも、深層学習ベースの解法でした。
元々アノテーション競争になることはなんとなく分かってはいたので、Q3は自分のやりたいこと(画像処理の勉強)ができたので良かったかなという感じです。(画像処理ベース解法の中で何位だったのか知りたい)

# 感想
Q2の汎用精度は頭打ちのところまで詰められたし、Q3もやりたいことできたので、上位入賞とはいきませんでしたが、概ね満足できる結果になったかなと思います。
セグメンテーションタスクでも、post processでは画像処理によってスコアが伸びる場合があるので、そういったところに今回の経験を活かしていきたいなと思います。