OpenCVで面積を計算してみた on iOS


本記事は [OpenCV Advent Calendar 2022](https://qiita.com/advent-calendar/2022/opencv) の14日目の記事です。

## はじめに

去年のアドカレの記事にてOpenCVで寸法測定ができることを知り、自分も実装してみたいと思いました。せっかくならカメラを搭載したデバイスで撮影しながら計測できたらいいなということでiOSアプリとして実装してみたところ、ギリギリ動くものは作れたので記事にしてみました。私はiOS開発は未経験ですしそもそもエンジニアではないので、"付け焼き刃の知識でどこまで行けるか試してみた"、みたいな記事になっています。

参考↓

https://qiita.com/okm_6880/items/0f964484d570208d715d

## 実行環境

* Apple M1 MacBook Pro
* macOS Ventura 13.0.1
* CMake 3.24.3
* Python 3.10.8
* Xcode 14.1
* iPhone 13 Pro
* iOS 16.1.2

## 1. OpenCVのビルド

今回はArUcoを使用するのでopencv_contrib込みのフレームワークを用意する必要がありました。OpenCVが配布しているビルド済みフレームワークにはopencv_contribがおそらく含まれていないのでローカルでビルドします。

```shell-session
git clone https://github.com/opencv/opencv.git -b 4.6.0
git clone https://github.com/opencv/opencv_contrib.git -b 4.6.0
opencv/platforms/apple/build_xcframework.py --out ios --contrib opencv_contrib --iphoneos_archs arm64 --build_only_specified_archs --iphoneos_deployment_target 16.1
```

## 2. 新規iOS Appプロジェクトにフレームワークを追加

Xcodeで新しくiOS App用プロジェクトを作成したら、ビルドしたフレームワークを下図に示すように *Link Binary With Libraries* から追加し、ついでに libc++.tbd も追加しました（多分必要）。

![スクリーンショット 2022-12-11 23.58.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1057021/ce52781e-0632-6658-1446-1817cdc07382.png)

また、追加の *Build Settings* として *Other Linker Flags* に `-all_load` を指定しました。

![スクリーンショット 2022-12-11 23.56.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1057021/3b83c0fc-7b30-1015-3445-dec2c10bc41f.png)


## 3. アプリのインターフェース部分の設計

以下の本を参考にインターフェース部分を作りました。すみずみまでは読んでいなくても意外とそれなりのものが作れてしまう感じでわかりやすい本だったと思います。

* Swift UI対応たった2日でマスターできる iPhoneアプリ開発集中講座 Xcode13/iOS15/Swift 5.5対応 [書籍] 藤治仁, 小林加奈子, 小林由憲 · ソシム
* 詳細!SwiftUI iPhoneアプリ開発入門ノート [書籍] 大重美幸 · ソーテック社

コードの記載は省略して、以下にアプリ画面のイメージ図を示します。

![アートボード 1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1057021/074a8e18-1746-2042-bd54-4622043a8326.png)


## 4. 面積計算アルゴリズムを書く

今回のメイン部分となる面積計算アルゴリズムを書いていきます。

### 4.1 ArUcoを検出して透視変換する

参考にした上記Qiita記事とは少し異なる頂点（内側ではなく外側）で合わせていますが、基本的には検出したマーカーの4隅を実世界と相似になるように変換するようにしました。特に今回はカメラ固定を想定していないのでこのプロセスはかなり重要になってくると思っています。

```swift
let img = Mat(uiImage: inputImage)

// RGBA -> RGB
Imgproc.cvtColor(src: img, dst: img, code: .COLOR_RGBA2RGB)

// detect ArUco
let dict = Aruco.getPredefinedDictionary(dict: 0)
var markerCorners = [Mat]()
let markerIds = Mat()
Aruco.detectMarkers(image: img, dictionary: dict, corners: &markerCorners, ids: markerIds)
//Aruco.drawDetectedMarkers(image: img, corners: markerCorners)

var success_transform = false
var ppmm = 3000.0 / 400.0
// if detected 4 markers do warp
if markerCorners.count == 4 {
    print("detected 4 aruco markers")
    
    // IDから必要なコーナーのIndexに変換（例えばIDが左上を示すなら左上の角）
    var corners: [[Double]] = []
    let cornerIndexes = [0: 0, 1: 1, 2: 2, 3: 3]  // TODO: 任意のIDのマーカーに対応させる
    for i in 0..<4 {
        let mId = Int(markerIds.get(indices: [Int32(i), 0])[0])
        if let cIdx = cornerIndexes[mId] {
            corners.append(markerCorners[i].get(indices: [0, Int32(cIdx)]))
        } else {
            print("wrong marker id")
        }
    }
    
    if corners.count == 4 {
        let width = 3000.0 // 3000.0に固定しているのに特に意味はない（もっと意味のある値にすべき）
        var real_width = 400.0 // mm
        if let tmp = Double(setData.width) {
            real_width = tmp
            ppmm = width / real_width
        }
        var real_height = 300.0 // mm
        if let tmp = Double(setData.height) {
            real_height = tmp
        }
        let height = width * real_height / real_width
        let trueCornersDict = [
            0: [0.0, 0.0],
            1: [width, 0.0],
            2: [width, height],
            3: [0.0, height],
        ]
        var trueCorners: [[Double]] = []
        for i in 0..<4 {
            let mId = Int(markerIds.get(indices: [Int32(i), 0])[0])
            trueCorners.append(trueCornersDict[mId].unsafelyUnwrapped)
        }
        print(corners)
        print(trueCorners)
        print(markerIds)
        let cornersMat = Mat(rows: 4, cols: 2, type: CvType.CV_32F)
        let trueCornersMat = Mat(rows: 4, cols: 2, type: CvType.CV_32F)
        for i in 0..<4 {
            try! cornersMat.put(row: Int32(i), col:0, data: [Float(corners[i][0])])
            try! cornersMat.put(row: Int32(i), col:1, data: [Float(corners[i][1])])
            try! trueCornersMat.put(row: Int32(i), col:0, data: [Float(trueCorners[i][0])])
            try! trueCornersMat.put(row: Int32(i), col:1, data: [Float(trueCorners[i][1])])
        }
        
        // 画像内のArUcoの位置関係が実世界の位置関係と相似になるように透視変換
        let M = Imgproc.getPerspectiveTransform(src: cornersMat, dst: trueCornersMat)
        Imgproc.warpPerspective(src: img, dst: img, M: M,
                                dsize: Size2i(width: Int32(width), height: Int32(height)))
        success_transform = true
    }
}
```

かなりごちゃごちゃですが重要なのは `Aruco.detectMarkers` と `Imgproc.warpPerspective` だけです。`try`が連発する部分とかもっと綺麗にしたい部分はたくさんありますがSwift超初心者なので今回は妥協します。

### 3.2 緑色領域を検出する

続いて物体検出部分です。今回は緑っぽい物体（例えば葉っぱとか）を検出できるようにしました（ゆくゆくは個人的に植物の形質調査とかに使ってみたいので）。

具体的には、Lab空間のaチャネルの値で大津法による閾値処理を行うことにしました。

```swift
// RGB -> Lab
let lab = Mat()
Imgproc.cvtColor(src: img, dst: lab, code: .COLOR_RGB2Lab)

// extract a* channel
var lab_splited = [Mat]()
Core.split(m: lab, mv: &lab_splited)
let a_img = lab_splited[1]

// median blur to a* channel
Imgproc.medianBlur(src: a_img, dst: a_img, ksize: 5)

// thresholding
let mask = Mat()
if let tt = ThresholdTypes(rawValue: 9) {
    Imgproc.threshold(src: a_img, dst: mask, thresh: 0, maxval: 255, type: tt)
} else {
    Imgproc.threshold(src: a_img, dst: mask, thresh: 0, maxval: 255, type: .THRESH_OTSU)
    Core.bitwise_not(src: mask, dst: mask)
}
```

### 3.3 検出領域の面積を図る＋輪郭を描画する

最後に二値化によって得られた検出領域の面積を算出する部分を実装しました。ArUcoで作られる長方形の実寸幅とピクセル数での幅を基に **pixels per mm** を計算し、得られたピクセル数版面積をmm<sup>2</sup>版の面積に変えています。

細かいところでは、面積の大きさでフィルタリングしたり、見切れているものを除いたりしています。

解析結果の図には輪郭と最小外接長方形を描画してみました。

```swift
// get connected components
let labels = Mat()
let stats = Mat()
let centroids = Mat()
var n_components = Imgproc.connectedComponentsWithStats(
    image: mask, labels: labels, stats: stats, centroids: centroids)

// remove small (1/10000 image size) components and components on border
let whole_image_area = img.width() * img.height()
let minimum_area = whole_image_area / 10000
for i in 0..<n_components {
    let AREA = ConnectedComponentsTypes.CC_STAT_AREA.rawValue
    let LEFT = ConnectedComponentsTypes.CC_STAT_LEFT.rawValue
    let TOP = ConnectedComponentsTypes.CC_STAT_TOP.rawValue
    let WIDTH = ConnectedComponentsTypes.CC_STAT_WIDTH.rawValue
    let HEIGHT = ConnectedComponentsTypes.CC_STAT_HEIGHT.rawValue
    
    let area:Int32 = stats.at(row: i, col: AREA).v
    let left:Int32 = stats.at(row: i, col: LEFT).v
    let top:Int32 = stats.at(row: i, col: TOP).v
    let right:Int32 = left + stats.at(row: i, col: WIDTH).v
    let bottom:Int32 = top + stats.at(row: i, col: HEIGHT).v
    if area < minimum_area || left == 0 || top == 0 || right == img.width() || bottom == img.height() {
        let tmp = Mat()
        Core.compare(src1: labels, srcScalar: Scalar(Double(i)), dst: tmp, cmpop: .CMP_EQ)
        mask.setTo(scalar: Scalar(0.0), mask: tmp)
    }
}

// re-calculate connected components
n_components = Imgproc.connectedComponentsWithStats(
    image: mask, labels: labels, stats: stats, centroids: centroids)

// callculate distance between components
// merge components which is near each other


// draw contours and min area rect, put label text
for i in 1..<n_components {
    
    // draw contours
    let tmp = Mat()
    Core.compare(src1: labels, srcScalar: Scalar(Double(i)), dst: tmp, cmpop: .CMP_EQ)
    var contours = [[Point2i]]()
    let hierarchy = Mat()
    Imgproc.findContours(
        image: tmp, contours: &contours, hierarchy: hierarchy,
        mode: .RETR_CCOMP, method: .CHAIN_APPROX_NONE)
    Imgproc.drawContours(
        image: img, contours: contours, contourIdx: -1,
        color: Scalar(0.0, 255.0, 255.0), thickness: 5)
    
    // draw min area rect
    var points = [Point2f]()
    for pt in contours[0] {
        points.append(Point2f(x: Float(pt.x), y: Float(pt.y)))
    }
    let rrect = Imgproc.minAreaRect(points: points)
    let boxPoints = Mat()
    Imgproc.boxPoints(box: rrect, points: boxPoints)
    for j:Int32 in 0..<4 {
        let pt1 = Point2i(
            x: Int32(boxPoints.get(row: j, col: 0)[0]),
            y: Int32(boxPoints.get(row: j, col: 1)[0]) )
        let pt2 = Point2i(
            x: Int32(boxPoints.get(row: (j+1)%4, col: 0)[0]),
            y: Int32(boxPoints.get(row: (j+1)%4, col: 1)[0]) )
        Imgproc.line(img: img, pt1: pt1, pt2: pt2, color: Scalar(0.0, 255.0, 255.0), thickness: 5)
    }
    
    // put label text
    let center = Point2i(
        x: Int32((boxPoints.get(row: 0, col: 0)[0] + boxPoints.get(row: 2, col: 0)[0]) / 2),
        y: Int32((boxPoints.get(row: 0, col: 1)[0] + boxPoints.get(row: 2, col: 1)[0]) / 2))
    Imgproc.putText(img: img, text: String(i), org: center, fontFace: .FONT_HERSHEY_SIMPLEX, fontScale: 5, color: Scalar(255.0, 255.0, 0.0), thickness: 5)
}

// count area for each component
var area: [Int32] = []
let mask_i = Mat()
for i in 1..<n_components {
    Core.compare(src1: labels, srcScalar: Scalar(Double(i)), dst: mask_i, cmpop: .CMP_EQ)
    area.append(Core.countNonZero(src: mask_i))
}

// real area
var area_real: [Double] = []
if success_transform {
    for a in area {
        area_real.append(Double(a) / ppmm / ppmm)
    }
}

// OpenCVで輪郭描画したものをUIImageに戻す
let rtnCGImage = img.toCGImage()
let rtnUIImage: UIImage
if markerCorners.count == 4 {
    rtnUIImage = UIImage(cgImage: rtnCGImage, scale: 1.0, orientation: .up)
} else {
    rtnUIImage = UIImage(cgImage: rtnCGImage, scale: 1.0, orientation: inputImage.imageOrientation)
}

return (rtnUIImage, area, area_real)

```

## 4. 試してみた結果

全てのコードを書き終えたらビルドしてiPhoneに転送して完了です。

手元に緑色の物体が無かったので青い付箋の面積を測ってみました。その時の様子を以下にGIFとして載っけておきます。

A4の紙に適当にArUcoマーカーを長方形の4点にあたる位置に印刷しましました。ArUcoで構成される長方形（ArUcoの外側に接する）の幅と高さはそれぞれ261mmと177mmになっていたのでその値をまずは設定しています。続いて印刷した用紙にたまたま手元にあった付箋を貼って撮影し面積測定結果を表示させています。

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1057021/38258b3b-28a3-71ae-0c37-b2fdeabf652b.gif)

横幅15mm、長さ50mmの付箋なので750mm<sup>2</sup>と出て欲しいところですが、743mm<sup>2</sup>から770mm<sup>2</sup>となっています。2.7%ほどの誤差が出てしまっています。まだ色々と考慮すべきところが多そうですが、ひとまず形になったのでよしとしたいと思います。

## 最後に

iOS開発初心者でもなんとか走るコードを書けることがわかりました。今回のコードはとりあえず動くことを目標にしていて、ツッコミどころ満載なので今後ちゃんとした整理していきたいと思います。

（ほんとはARKitなんかを使ってArUcoマーカーを使わずとも実世界のスケールを求めて対象物体の表面積を測るみたいなことがしたいです。）