iPhone で円形度を使ってエゴマと蕎麦の実を識別


Vison Framework を利用することで簡単に円形度の計算ができたのでその紹介です。
サンプルとして[エゴマ](https://www.weblio.jp/content/エゴマ)と蕎麦の実の形状の違いを円形度で比較してみます。
＜完成イメージ＞
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/25ab2b91-ae34-fa6f-f538-af75ed4a21c6.gif" width=280>　<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/06182fee-8892-9d68-f905-2995f65e53d2.gif" width=280>

円形度が高いと青いパス、低いと赤色のパスで描画しています。
より円に近いエゴマの実が青くなっています。

https://github.com/tamai999/CircleDetection

### 1. 円形度

[円形度](https://www.weblio.jp/content/円形度)は次のように計算できます。

$$
円形度=\frac{4 \pi S}{L^2}
$$

Sが面積でLが周囲長です。真円が1となり最大で、複雑な形状ほど値は小さくなります。

### 2. 円を認識させるステップ

次のステップで処理します。

1. 画像の前処理
1. 輪郭検出
1. 円形度の計算

#### 2-1) 画像の前処理

円を認識しやすいように事前に次のような加工をしました。
エッジをきれいに認識させる＆ノイズを除去する、が目的です。

|1.グレースケール化|2.鮮鋭化|3.二値化|4.モルフォロジー処理|
|:-:|:-:|:-:|:-:|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/72dec217-8c86-1c78-ba26-fab20b97ae5e.png">|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/73d8b0f0-ca66-e1c2-0695-20979e7f4e88.png">|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/8be11e70-e327-903d-0223-9c907e1173aa.png">|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/f196ce8c-2c06-0075-9168-54d15afba1de.png">|

##### 1.グレースケール化

処理の理解をシンプルにしたいのでカラー画像をグレースケールに変換します。
ここでは[RGB→YUVの変換](https://ja.wikipedia.org/wiki/YUV)時に用いる輝度変換の計算を[CIColorMatrix](https://developer.apple.com/library/archive/documentation/GraphicsImaging/Reference/CoreImageFilterReference/index.html#//apple_ref/doc/filter/ci/CIColorMatrix)フィルターを使って実装しています。

```swift
// グレースケール化
let colorMatrixFilter = CIFilter.colorMatrix()
colorMatrixFilter.inputImage = ciImage
let grayScaleVector = CIVector(x: 0.298912, y: 0.586611, z:0.114478, w: 0)
colorMatrixFilter.rVector = grayScaleVector
colorMatrixFilter.gVector = grayScaleVector
colorMatrixFilter.bVector = grayScaleVector
colorMatrixFilter.aVector = CIVector(x: 0, y: 0, z: 0, w: 1)
let grayScaleImage = colorMatrixFilter.outputImage
```

##### 2.鮮鋭化

対象の色と背景色が似ていると思ったような輪郭検出ができなかったので、対象のエッジが際立つように鮮鋭化を行います。CIFilterにアンシャープマスキングを行うフィルタ[CIUnsharpMask](https://developer.apple.com/library/archive/documentation/GraphicsImaging/Reference/CoreImageFilterReference/index.html#//apple_ref/doc/filter/ci/CIUnsharpMask)があるのでこれを利用します。

```swift
// 鮮鋭化
let unSharpMaskFilter = CIFilter.unsharpMask()
unSharpMaskFilter.inputImage = grayScaleImage
unSharpMaskFilter.radius = 5.0
unSharpMaskFilter.intensity = 2.0
let unSharpMaskImage = unSharpMaskFilter.outputImage
```

※`VNDetectContoursRequest`には輪郭検出時のコントラストを調整する `contrastAdjustment`プロパティがありますがこちらは試していません。

##### 3.二値化/モルフォロジー処理

白黒画像に変換、および、小さな黒い画素（ノイズ）を削除します。
処理方法は次の記事で解説しているので興味のある方は参照ください。

【参考】[iOSで 光学迷彩](https://qiita.com/pic18f14k50/items/341a2d8502ae75af6dc5#34-モルフォロジー処理で穴や小さな物体を除去マスク画像生成)

#### 2-2) 輪郭検出

輪郭検出にはVision FrameworkのVNDetectContoursRequestを使います。
利用方法はとても簡単です。次のようにCIImageを渡すだけで結果が得られます。

```swift
// 輪郭検出
private func getContours(ciImage: CIImage) -> [VNContour] {
    let contourRequest = VNDetectContoursRequest.init()
    contourRequest.maximumImageDimension = 1024
    
    try? VNImageRequestHandler(ciImage: ciImage).perform([contourRequest])
    // 検出結果取得
    guard let observation = contourRequest.results?.first else { return [] }
    // 外側の輪郭だけ返す
    return observation.topLevelContours
}
```

検出された輪郭は次のようなネスト構造になっています。

<img src="https://camo.qiitausercontent.com/77b6cd96c8c2cc9f23f727205d0ce0d3f11d2380/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f37313630352f65666463393331392d346237332d336263392d346366342d6138643662636464333162392e706e67">

今回は蕎麦やエゴマの実の内部の輪郭は不要で、外側の形状だけ知りたいので一番外側の輪郭が格納されている`topLevelContours`を取得します。

VNDetectContoursRequestを使った輪郭検出ついては次の記事でも解説しています。

【参考】[ARKit+Vision+iOS14 で らくがき のジオメトリ化①【輪郭検出】](https://qiita.com/pic18f14k50/items/0d20f5f544c9010f79e6)

#### 2-3) 円形度の計算

`VNGeometryUtils`を使うことで簡単に面積と周囲長が得られます。
次のように引数に輪郭検出で得られた`VNContour`を与えるだけです。
円形度はその値を使って計算します。

```swift
// 面積
var area: Double = 0.0
try VNGeometryUtils.calculateArea(&area, for: contour, orientedArea: false)

// 周囲長
var perimeter: Double = 0.0
try VNGeometryUtils.calculatePerimeter(&perimeter, for: contour)

// 円形度
var roundness = (4.0 * .pi * area) / (perimeter * perimeter)
```

ちなみに`VNContour`から輪郭を構成する点の座標を配列で取得することができるので次の計算式で自分で面積を求めることもできます。

```math
\begin{align}
S=\frac{1}{2}\left| \sum_{ i = 1 }^{ n } (x_{i}y_{i+1} - x_{i+1}y_{i}) \right| \\
i=n のとき、n+1 = 1
\end{align}
```

`VNContour`の`normalizedPoints`プロパティから座標の配列が[simd_float2]で得られるので、次の処理で面積、周囲長、円形度を計算できます。

```swift
func calcRoundness(points: [simd_float2]) -> (area: Float, perimeter: Float, roundness: Float) {
    var area: Float = 0
    var perimeter: Float = 0
    
    for i in 0..<points.count {
        let point = points[i]
        let nextPoint: simd_float2
        if i == (points.count - 1) {
            nextPoint = points[0]
        } else {
            nextPoint = points[i + 1]
        }
        
        // 面積。２点の外積のz成分（後で半分にする）
        area += simd_cross(point, nextPoint).z
        // 周囲長
        perimeter += simd_distance(point, nextPoint)
    }
    
    area = abs(area / 2)
    
    // 円形度
    var roundness = (4.0 * .pi * area) / (perimeter * perimeter)
    if roundness.isInfinite || roundness.isNaN {
        roundness = 0
    }

    return (area, perimeter, roundness)
}
```

### 3. 真円は1になるか？

調整すればほぼ1になります。

||調整前|調整後|さらに調整後|
|:-:|:-:|:-:|:-:|
|maximumImageDimension|1,024|512|512|
|polygonApproximation|-|-|0.002|
|結果|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/a8111cd9-d808-7a1e-608e-4013bab61158.png">|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/2bffad2b-a369-49c2-12eb-70bc61885317.png">|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/ed0c2abc-d175-4f0a-ed60-7ca09376472e.png">|
|円形度|0.895|0.928|0.993|

* 解像度が高いと輪郭部分のノイズ（小さな凸凹）がより多くなり、結果として周囲長が長くなり、円形度が小さくなります【調整前】。※最初、原因がわかりませんでした。
* 解像度を下げてもノイズが残るため周囲長が長くなる傾向があります【調整後】。
* `VNContour`に用意されている`polygonApproximation`というノイズを除去する仕組みを利用するとほぼ１にすることができます【さらに調整後】。なお、この機能でノイズを消してしまうと形状の違いの差も小さくなってしまうので注意が必要です。

```swift
let contour = try noisyContour.polygonApproximation(epsilon: 0.002)
```

* ノイズを減らすには強い照明も必要です。この十円玉の画像は四方から明るいLEDライトを当てて撮影しています。

### 4. 最後に

 円形度だけだとエゴマと蕎麦の実も思いのほか似ていました。
クラス識別をするなら面積、色といった特徴量を加えたパターン認識が必要だと思います。

記事内容の誤りや改善箇所があればご指摘いただけると幸いです。

【12/22追記】
手持ちの実の円形度と面積を調べたところ次のようになりました。
![sample_size.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/c1b8fdc9-0023-ea92-f095-d25b7547f698.png)
サンプルサイズ：エゴマ=159, 蕎麦=117
面積だけで識別できそうですが、この記事はVision Frameworkを使って円形度を取得する方法の記事なので、この事実は傍におきます。